Compare commits

..
Author SHA1 Message Date
dependabot[bot] af290b0a09 ⬆ Bump ruff from 0.16.0 to 0.16.5
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.0 to 0.16.5.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.0...0.16.5)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 21:44:13 +00:00
d3vyce e09b911277 Merge pull request #393 from d3vyce/392-searchfalsefilterfalseorderfalse-dont-clear-the-response-metadata
fix: search=False/filter=False/order=False don't clear the response metadata
2026-09-01 19:46:46 +02:00
d3vyce f7ecb76e8d fix: search=False/filter=False/order=False don't clear the response metadata 2026-09-01 13:43:33 -04:00
d3vyce ef269833b9 Version 5.1.2 2026-08-31 16:51:38 -04:00
d3vyce 610b3e1ab4 revert: drop populate_existing reload in create()/update() 2026-08-31 16:51:04 -04:00
d3vyce 312723d66b Version 5.1.1 2026-08-29 17:48:03 -04:00
d3vyce 3d426ac567 Merge pull request #391 from d3vyce/dependabot/uv/ty-0.0.75
⬆ Bump ty from 0.0.64 to 0.0.75
2026-08-29 23:44:03 +02:00
d3vyce 0cc189117d fix: ty warning 2026-08-29 17:41:53 -04:00
dependabot[bot] a7b78832fd ⬆ Bump ty from 0.0.64 to 0.0.75
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.64 to 0.0.75.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.64...0.0.75)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.75
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-29 21:40:22 +00:00
d3vyce 56c19971e2 Merge pull request #390 from d3vyce/dependabot/uv/prek-0.4.14
⬆ Bump prek from 0.4.9 to 0.4.14
2026-08-29 23:39:56 +02:00
d3vyce 7cd0ca2936 Merge pull request #378 from d3vyce/dependabot/uv/fastapi-0.141.1
⬆ Bump fastapi from 0.139.0 to 0.141.1
2026-08-29 23:38:14 +02:00
d3vyce 713e9a40c8 Merge pull request #375 from d3vyce/dependabot/uv/prometheus-client-0.26.0
⬆ Bump prometheus-client from 0.25.0 to 0.26.0
2026-08-29 23:38:04 +02:00
d3vyce 1432a2cc3a Merge pull request #374 from d3vyce/dependabot/uv/uv-build-gte-0.10-and-lt-0.13.0
⬆ Update uv-build requirement from <0.12.0,>=0.10 to >=0.10,<0.13.0
2026-08-29 23:37:55 +02:00
dependabot[bot] 375d349fc4 ⬆ Bump prek from 0.4.9 to 0.4.14
Bumps [prek](https://github.com/j178/prek) from 0.4.9 to 0.4.14.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.9...v0.4.14)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.4.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-29 21:36:25 +00:00
d3vyce 7506fa3093 Merge pull request #389 from d3vyce/388-pathdependency-bodydependency-build-a-bare-unconfigured-crud
fix: PathDependency and BodyDependency build a bare unconfigured CRUD
2026-08-29 23:34:42 +02:00
d3vyce 943115562b fix: PathDependency and BodyDependency build a bare unconfigured CRUD 2026-08-29 08:39:23 -04:00
d3vyce 716e4f7db7 Merge pull request #387 from d3vyce/386-pagination-over-a-to-many-join-duplicates-drops-and-truncates-results
fix: pagination over a to-many join duplicates, drops and truncates results
2026-08-29 14:09:25 +02:00
d3vyce 50529b1081 fix: pagination over a to-many join duplicates, drops and truncates results 2026-08-28 18:43:15 -04:00
d3vyce 8aea7247c9 Merge pull request #385 from d3vyce/384-eventsessioncommit-discards-eager-loaded-relations-on-watched-models
fix: EventSession.commit() discards eager-loaded relations on watched models
2026-08-28 22:23:09 +02:00
d3vyce 654347126d fix: EventSession.commit() discards eager-loaded relations on watched models 2026-08-28 13:28:22 -04:00
dependabot[bot] f1e50a947a ⬆ Bump fastapi from 0.139.0 to 0.141.1
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.139.0 to 0.141.1.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.139.0...0.141.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.141.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:04:01 +00:00
d3vyce 6dafd40277 Merge pull request #381 from d3vyce/380-dependsdb-resolved-twice-in-one-request-opens-two-sessions-and-silently-discards-writes
fix: Depends(db) resolved twice in one request opens two sessions and silently discards writes
2026-08-28 19:01:43 +02:00
d3vyce 1c806cccd9 fix: Depends(db) resolved twice in one request opens two sessions and silently discards writes 2026-08-28 12:58:47 -04:00
d3vyce 1354f59bb4 fix: searching an Enum column raises UndefinedFunctionError 2026-08-28 18:48:06 +02:00
dependabot[bot] 23dc5c86b2 ⬆ Bump pymdown-extensions from 11.0 to 11.0.1
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 11.0 to 11.0.1.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/11.0...11.0.1)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: 11.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 17:19:37 +02:00
dependabot[bot] 7c73488f7d ⬆ Bump prometheus-client from 0.25.0 to 0.26.0
Bumps [prometheus-client](https://github.com/prometheus/client_python) from 0.25.0 to 0.26.0.
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](https://github.com/prometheus/client_python/compare/v0.25.0...v0.26.0)

---
updated-dependencies:
- dependency-name: prometheus-client
  dependency-version: 0.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:43:47 +00:00
dependabot[bot] 518e22e921 ⬆ Update uv-build requirement from <0.12.0,>=0.10 to >=0.10,<0.13.0
Updates the requirements on [uv-build](https://github.com/astral-sh/uv) to permit the latest version.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.10.0...0.12.0)

---
updated-dependencies:
- dependency-name: uv-build
  dependency-version: 0.12.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:43:35 +00:00
d3vyce 88ecfa8486 Version 5.1.0 2026-07-28 08:19:33 -04:00
dependabot[bot] b4a7335e5e ⬆ Bump coverage from 7.15.0 to 7.15.2
Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.15.0 to 7.15.2.
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.15.0...7.15.2)

---
updated-dependencies:
- dependency-name: coverage
  dependency-version: 7.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:27 +02:00
dependabot[bot] 97828cc156 ⬆ Bump typer from 0.26.8 to 0.27.0
Bumps [typer](https://github.com/fastapi/typer) from 0.26.8 to 0.27.0.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.26.8...0.27.0)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:14 +02:00
dependabot[bot] e7540c6e27 ⬆ Bump zensical from 0.0.50 to 0.0.51
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.50 to 0.0.51.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.50...v0.0.51)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.51
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:05 +02:00
d3vyce 7779dcbadd fix: ty warning 2026-07-28 14:15:44 +02:00
dependabot[bot] ea354813d1 ⬆ Bump ty from 0.0.58 to 0.0.64
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.58 to 0.0.64.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.58...0.0.64)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.64
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:15:44 +02:00
d3vyce fd83a7142d fix: ruff warnings 2026-07-28 13:55:31 +02:00
dependabot[bot] a407677be0 ⬆ Bump ruff from 0.15.21 to 0.16.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.21 to 0.16.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.21...0.16.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 13:55:31 +02:00
dependabot[bot] ea5b5695b5 ⬆ Bump pymdown-extensions from 10.21.3 to 11.0
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.21.3 to 11.0.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.21.3...11.0)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: '11.0'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 13:20:29 +02:00
42 changed files with 1435 additions and 438 deletions
+10 -3
View File
@@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed
=== "Before (`v1`)" === "Before (`v1`)"
```python ```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`)" === "Now (`v2`)"
```python ```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): class BillingError(ApiException, abstract=True):
"""Base for all billing-related errors — not raised directly.""" """Base for all billing-related errors — not raised directly."""
class PaymentRequiredError(BillingError): 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
View File
@@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
```python ```python
from fastapi_toolsets.crud import OrderByClause from fastapi_toolsets.crud import OrderByClause
@router.get("/offset") @router.get("/offset")
async def list_articles_offset( async def list_articles_offset(
session: SessionDep, session: SessionDep,
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))], params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())], 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, search: str | None = None,
) -> OffsetPaginatedResponse[ArticleRead]: ) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate( return await ArticleCrud.offset_paginate(
@@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
), ),
], ],
) -> OffsetPaginatedResponse[ArticleRead]: ) -> 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: 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 ```python
from fastapi_toolsets.models import WatchedFieldsMixin, watch from fastapi_toolsets.models import WatchedFieldsMixin, watch
@watch("status") @watch("status")
class Order(Base, UUIDMixin, WatchedFieldsMixin): class Order(Base, UUIDMixin, WatchedFieldsMixin):
__tablename__ = "orders" __tablename__ = "orders"
@@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
```python ```python
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
class Order(Base, UUIDMixin): class Order(Base, UUIDMixin):
__tablename__ = "orders" __tablename__ = "orders"
__watched_fields__ = ("status",) __watched_fields__ = ("status",)
status: Mapped[str] status: Mapped[str]
@listens_for(Order, [ModelEvent.CREATE]) @listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None): async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
await notify_new_order(order.id) await notify_new_order(order.id)
@listens_for(Order, [ModelEvent.UPDATE]) @listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict): async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
if "status" in changes: if "status" in changes:
await notify_status_change(order.id, changes["status"]) await notify_status_change(order.id, changes["status"])
@listens_for(Order, [ModelEvent.DELETE]) @listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None): async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
await notify_order_cancelled(order.id) await notify_order_cancelled(order.id)
+3 -1
View File
@@ -35,6 +35,8 @@ The function creates and manages its own **dedicated session** internally, yield
user.balance += 100 user.balance += 100
# With a custom lock mode # With a custom lock mode
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session: async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id) await process_order(session, order_id)
``` ```
+10 -5
View File
@@ -24,9 +24,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst
get_db = create_db_dependency(session_maker=SessionLocal) get_db = create_db_dependency(session_maker=SessionLocal)
get_db_context = create_db_context(session_maker=SessionLocal) get_db_context = create_db_context(session_maker=SessionLocal)
@app.get("/users") @app.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)): async def list_users(session: AsyncSession = Depends(get_db)): ...
...
async def seed(): async def seed():
async with get_db_context() as session: async with get_db_context() as session:
@@ -40,9 +41,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst
db = Database(url="postgresql+asyncpg://...") db = Database(url="postgresql+asyncpg://...")
@app.get("/users") @app.get("/users")
async def list_users(session: AsyncSession = Depends(db)): async def list_users(session: AsyncSession = Depends(db)): ...
...
async def seed(): async def seed():
async with db.session() as session: async with db.session() as session:
@@ -89,7 +91,9 @@ The free `lock_tables(session_maker, tables, ...)` function still exists for cal
```python ```python
from fastapi_toolsets.db import lock_tables, LockMode from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session: async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id) await process_order(session, order_id)
``` ```
@@ -127,6 +131,7 @@ Both also change their first argument: instead of the fixture *function*, pass t
```python ```python
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
@fixtures.register(depends_on=["roles"]) @fixtures.register(depends_on=["roles"])
def users(): def users():
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin") admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
+1
View File
@@ -92,6 +92,7 @@ import typer
cli = typer.Typer() cli = typer.Typer()
@cli.command() @cli.command()
def hello(): def hello():
print("Hello from my app!") print("Hello from my app!")
+36 -6
View File
@@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User)
from fastapi_toolsets.crud.factory import AsyncCrud from fastapi_toolsets.crud.factory import AsyncCrud
from myapp.models import User from myapp.models import User
class UserCrud(AsyncCrud[User]): class UserCrud(AsyncCrud[User]):
model = User model = User
searchable_fields = [User.username, User.email] searchable_fields = [User.username, User.email]
@@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud
T = TypeVar("T", bound=DeclarativeBase) T = TypeVar("T", bound=DeclarativeBase)
class AuditedCrud(AsyncCrud[T], Generic[T]): class AuditedCrud(AsyncCrud[T], Generic[T]):
"""Base CRUD with custom function""" """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]) users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])
# Update # 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 # Delete
await UserCrud.delete(session=session, filters=[User.id == user_id]) await UserCrud.delete(session=session, filters=[User.id == user_id])
@@ -160,10 +164,14 @@ user = await UserCrud.get(session, [User.id == user_id], with_for_update=True)
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait") user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")
# Skip rows already locked by another transaction (e.g. job queues) # 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") 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) # 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) user = await UserCrud.update(
session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True
)
``` ```
!!! warning !!! warning
@@ -193,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends
@router.get("") @router.get("")
async def get_users( async def get_users(
session: SessionDep, session: SessionDep,
@@ -228,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C
@router.get("") @router.get("")
async def get_users( async def get_users(
session: SessionDep, 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]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
``` ```
@@ -303,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
```python ```python
from fastapi_toolsets.schemas import PaginatedResponse from fastapi_toolsets.schemas import PaginatedResponse
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
@@ -361,6 +373,16 @@ Or via the dependency to narrow which fields are exposed as query parameters:
params = UserCrud.offset_paginate_params(search_fields=[Post.title]) params = UserCrud.offset_paginate_params(search_fields=[Post.title])
``` ```
`search_fields`, `facet_fields` and `order_fields` follow the same override rule
everywhere they are accepted &mdash; `offset_paginate`, `cursor_paginate`,
`paginate` and the matching `*_paginate_params` dependencies:
| Passed | Effect |
| --- | --- |
| omitted or `None` | Use the class-level declaration |
| `[]` | Disable this feature for this call |
| `[...]` | Use exactly these fields (the primary key is **not** prepended &mdash; that only happens for the class-level `searchable_fields`) |
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate): This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
```python ```python
@@ -446,6 +468,7 @@ from typing import Annotated
from fastapi import Depends from fastapi import Depends
@router.get("", response_model_exclude_none=True) @router.get("", response_model_exclude_none=True)
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
@@ -540,6 +563,7 @@ from typing import Annotated
from fastapi import Depends from fastapi import Depends
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
@@ -632,7 +656,9 @@ PostCrud = CrudFactory(
m2m_fields={"tag_ids": Post.tags}, 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 ## Upsert
@@ -659,6 +685,7 @@ class UserRead(PydanticBase):
id: UUID id: UUID
username: str username: str
@router.get( @router.get(
"/{uuid}", "/{uuid}",
responses=generate_error_responses(NotFoundError), responses=generate_error_responses(NotFoundError),
@@ -670,12 +697,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
schema=UserRead, schema=UserRead,
) )
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())], params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
) -> OffsetPaginatedResponse[UserRead]: ) -> 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. 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.
+10 -4
View File
@@ -24,9 +24,9 @@ db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
app = FastAPI() app = FastAPI()
db.install(app) # commit middleware + engine disposal on shutdown db.install(app) # commit middleware + engine disposal on shutdown
@app.get("/users") @app.get("/users")
async def list_users(session: AsyncSession = Depends(db)): async def list_users(session: AsyncSession = Depends(db)): ...
...
``` ```
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it). The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
@@ -37,9 +37,11 @@ The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pyd
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import PostgresDsn from pydantic import PostgresDsn
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: PostgresDsn database_url: PostgresDsn
settings = Settings() settings = Settings()
db = Database( db = Database(
@@ -52,7 +54,7 @@ db = Database(
## Committing before the response ## Committing before the response
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again. [`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. The dependency commits only if the middleware did not: when a function-scoped dependency unwinds before the response, or when the response never passes through the middleware. Either way the request is committed exactly once.
The request is committed as a single transaction: The request is committed as a single transaction:
@@ -72,12 +74,14 @@ Without `install`, the session commits in the dependency teardown, which runs af
```python ```python
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@asynccontextmanager @asynccontextmanager
async def lifespan(app): async def lifespan(app):
await warm_cache() # your startup await warm_cache() # your startup
yield yield
await flush_metrics() # your shutdown await flush_metrics() # your shutdown
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
db.install(app) # your shutdown runs first, then the engine is disposed db.install(app) # your shutdown runs first, then the engine is disposed
``` ```
@@ -101,6 +105,7 @@ async def seed():
```python ```python
from fastapi_toolsets.db import transaction from fastapi_toolsets.db import transaction
async def create_user_with_role(session): async def create_user_with_role(session):
async with transaction(session): async with transaction(session):
... ...
@@ -207,6 +212,7 @@ For test isolation with automatic cleanup, use [`create_worker_database`](../ref
```python ```python
from fastapi_toolsets.db.testing import cleanup_tables from fastapi_toolsets.db.testing import cleanup_tables
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def clean(db_session): async def clean(db_session):
yield yield
+38 -2
View File
@@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
SessionDep = Annotated[AsyncSession, Depends(get_db)] SessionDep = Annotated[AsyncSession, Depends(get_db)]
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep) UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
@router.get("/users/{user_id}") @router.get("/users/{user_id}")
async def get_user(user: User = UserDep): async def get_user(user: User = UserDep):
return user return user
@@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id
```python ```python
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id") UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")
@router.get("/users/{id}") @router.get("/users/{id}")
async def get_user(user: User = UserDep): async def get_user(user: User = UserDep):
return user return user
@@ -43,11 +45,15 @@ async def get_user(user: User = UserDep):
from fastapi_toolsets.dependencies import BodyDependency from fastapi_toolsets.dependencies import BodyDependency
# Plain callable # 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 # Annotated
SessionDep = Annotated[AsyncSession, Depends(get_db)] 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") @router.post("/users")
@@ -56,6 +62,36 @@ async def create_user(body: UserCreateSchema, role: Role = RoleDep):
... ...
``` ```
## Eager loading
By default both factories fetch through a bare `CrudFactory(model)`, so relationships are not loaded. Pass `load_options` for a one-off, or `crud` to reuse a CRUD class you already configured:
```python
from sqlalchemy.orm import selectinload
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.dependencies import PathDependency
UserDep = PathDependency(
model=User,
field=User.id,
session_dep=get_db,
load_options=[selectinload(User.role)],
)
# Or reuse the app's configured CRUD and its default_load_options
UserCrud = CrudFactory(User, default_load_options=[selectinload(User.role)])
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, crud=UserCrud)
@router.get("/users/{user_id}")
async def get_user(user: User = UserDep):
return user.role.name # already loaded, no extra query
```
Both parameters work the same way on `BodyDependency`. When given together, the
usual [relationship loading](crud.md#relationship-loading) precedence applies.
--- ---
[:material-api: API Reference](../reference/dependencies.md) [:material-api: API Reference](../reference/dependencies.md)
+12 -3
View File
@@ -53,7 +53,9 @@ All built-in exceptions accept optional keyword arguments to customise the respo
| `data` | Overrides the `data` field | | `data` | Overrides the `data` field |
```python ```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 ## Custom exceptions
@@ -64,6 +66,7 @@ Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions
from fastapi_toolsets.exceptions import ApiException from fastapi_toolsets.exceptions import ApiException
from fastapi_toolsets.schemas import ApiError from fastapi_toolsets.schemas import ApiError
class PaymentRequiredError(ApiException): class PaymentRequiredError(ApiException):
api_error = ApiError( api_error = ApiError(
code=402, code=402,
@@ -105,11 +108,17 @@ Use `abstract=True` when creating a shared base that is not meant to be raised d
class BillingError(ApiException, abstract=True): class BillingError(ApiException, abstract=True):
"""Base for all billing-related errors.""" """Base for all billing-related errors."""
class PaymentRequiredError(BillingError): 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): 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 ## OpenAPI response documentation
+18 -5
View File
@@ -13,6 +13,7 @@ from fastapi_toolsets.fixtures import FixtureRegistry, Context
fixtures = FixtureRegistry() fixtures = FixtureRegistry()
@fixtures.register @fixtures.register
def roles(): def roles():
return [ return [
@@ -20,6 +21,7 @@ def roles():
Role(id=2, name="user"), Role(id=2, name="user"),
] ]
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING]) @fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users(): def test_users():
return [ return [
@@ -79,14 +81,17 @@ Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is
```python ```python
from enum import Enum from enum import Enum
class AppContext(str, Enum): class AppContext(str, Enum):
STAGING = "staging" STAGING = "staging"
DEMO = "demo" DEMO = "demo"
@fixtures.register(contexts=[AppContext.STAGING]) @fixtures.register(contexts=[AppContext.STAGING])
def staging_data(): def staging_data():
return [Config(key="feature_x", enabled=True)] return [Config(key="feature_x", enabled=True)]
# loads staging_data plus any Context.BASE fixtures # loads staging_data plus any Context.BASE fixtures
await load_fixtures_by_context(session, fixtures, AppContext.STAGING) await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
``` ```
@@ -98,7 +103,8 @@ Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registere
```python ```python
testing_registry = FixtureRegistry(contexts=[Context.TESTING]) testing_registry = FixtureRegistry(contexts=[Context.TESTING])
@testing_registry.register # implicitly contexts=[Context.TESTING]
@testing_registry.register # implicitly contexts=[Context.TESTING]
def test_orders(): def test_orders():
return [Order(id=1, total=99)] return [Order(id=1, total=99)]
``` ```
@@ -112,10 +118,12 @@ The same fixture name may be registered under different (non-overlapping) contex
def users(): def users():
return [User(id=1, username="admin")] return [User(id=1, username="admin")]
@fixtures.register(contexts=[Context.TESTING]) @fixtures.register(contexts=[Context.TESTING])
def users(): def users():
return [User(id=2, username="tester")] return [User(id=2, username="tester")]
# loads both admin and tester (Context.BASE is included automatically) # loads both admin and tester (Context.BASE is included automatically)
await load_fixtures_by_context(session, fixtures, Context.TESTING) await load_fixtures_by_context(session, fixtures, Context.TESTING)
``` ```
@@ -171,7 +179,9 @@ Looking the fixture up by name (instead of importing the `roles` function direct
```python ```python
@fixtures.register(depends_on=["roles"]) @fixtures.register(depends_on=["roles"])
def users(): def users():
return [User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))] 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. Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
@@ -189,18 +199,21 @@ from app.models import Base
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db" DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
@pytest.fixture @pytest.fixture
async def db_session(): 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 yield session
register_fixtures(registry=registry, namespace=globals()) register_fixtures(registry=registry, namespace=globals())
``` ```
```python ```python
# test_users.py # 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. 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.
+3
View File
@@ -47,10 +47,12 @@ If neither of these applies to you, declaring metrics at module level (e.g. `HTT
```python ```python
from prometheus_client import Counter, Histogram from prometheus_client import Counter, Histogram
@metrics.register @metrics.register
def http_requests(): def http_requests():
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"]) return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
@metrics.register @metrics.register
def request_duration(): def request_duration():
return Histogram("request_duration_seconds", "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") _queue_depth = Gauge("queue_depth", "Current queue depth")
@metrics.register(collect=True) @metrics.register(collect=True)
def collect_queue_depth(): def collect_queue_depth():
_queue_depth.set(get_current_queue_depth()) _queue_depth.set(get_current_queue_depth())
+19 -1
View File
@@ -11,6 +11,7 @@ The `models` module provides mixins that each add a single, well-defined column
```python ```python
from fastapi_toolsets.models import UUIDMixin, TimestampMixin from fastapi_toolsets.models import UUIDMixin, TimestampMixin
class Article(Base, UUIDMixin, TimestampMixin): class Article(Base, UUIDMixin, TimestampMixin):
__tablename__ = "articles" __tablename__ = "articles"
@@ -31,11 +32,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_ran
```python ```python
from fastapi_toolsets.models import UUIDMixin from fastapi_toolsets.models import UUIDMixin
class User(Base, UUIDMixin): class User(Base, UUIDMixin):
__tablename__ = "users" __tablename__ = "users"
username: Mapped[str] username: Mapped[str]
# id is None before flush # id is None before flush
user = User(username="alice") user = User(username="alice")
session.add(user) session.add(user)
@@ -54,11 +57,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7(
```python ```python
from fastapi_toolsets.models import UUIDv7Mixin from fastapi_toolsets.models import UUIDv7Mixin
class Event(Base, UUIDv7Mixin): class Event(Base, UUIDv7Mixin):
__tablename__ = "events" __tablename__ = "events"
name: Mapped[str] name: Mapped[str]
# id is None before flush # id is None before flush
event = Event(name="user.signup") event = Event(name="user.signup")
session.add(event) session.add(event)
@@ -73,6 +78,7 @@ Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The c
```python ```python
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
class Order(Base, UUIDMixin, CreatedAtMixin): class Order(Base, UUIDMixin, CreatedAtMixin):
__tablename__ = "orders" __tablename__ = "orders"
@@ -86,11 +92,13 @@ Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and a
```python ```python
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
class Post(Base, UUIDMixin, UpdatedAtMixin): class Post(Base, UUIDMixin, UpdatedAtMixin):
__tablename__ = "posts" __tablename__ = "posts"
title: Mapped[str] title: Mapped[str]
post = Post(title="Hello") post = Post(title="Hello")
await session.flush() await session.flush()
await session.refresh(post) await session.refresh(post)
@@ -111,6 +119,7 @@ Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastap
```python ```python
from fastapi_toolsets.models import UUIDMixin, TimestampMixin from fastapi_toolsets.models import UUIDMixin, TimestampMixin
class Article(Base, UUIDMixin, TimestampMixin): class Article(Base, UUIDMixin, TimestampMixin):
__tablename__ = "articles" __tablename__ = "articles"
@@ -166,10 +175,12 @@ class Order(Base, UUIDMixin):
__watched_fields__ = ("status",) __watched_fields__ = ("status",)
... ...
class UrgentOrder(Order): class UrgentOrder(Order):
# inherits __watched_fields__ = ("status",) # inherits __watched_fields__ = ("status",)
... ...
class PriorityOrder(Order): class PriorityOrder(Order):
__watched_fields__ = ("priority",) __watched_fields__ = ("priority",)
# overrides parent — UPDATE fires only for priority changes # overrides parent — UPDATE fires only for priority changes
@@ -183,20 +194,24 @@ Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolse
```python ```python
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
class Order(Base, UUIDMixin): class Order(Base, UUIDMixin):
__tablename__ = "orders" __tablename__ = "orders"
__watched_fields__ = ("status",) __watched_fields__ = ("status",)
status: Mapped[str] status: Mapped[str]
@listens_for(Order, [ModelEvent.CREATE]) @listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None): async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
await notify_new_order(order.id) await notify_new_order(order.id)
@listens_for(Order, [ModelEvent.DELETE]) @listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None): async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
await notify_order_cancelled(order.id) await notify_order_cancelled(order.id)
@listens_for(Order, [ModelEvent.UPDATE]) @listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict): async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
if "status" in changes: 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): async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
await invalidate_cache(order.id) await invalidate_cache(order.id)
@listens_for(Order) # all events @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) await audit_log(order.id, event_type)
``` ```
+11 -2
View File
@@ -21,6 +21,7 @@ Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils
```python ```python
from fastapi_toolsets.pytest import create_async_client from fastapi_toolsets.pytest import create_async_client
@pytest.fixture @pytest.fixture
async def http_client(db_session): async def http_client(db_session):
async def _override_get_db(): async def _override_get_db():
@@ -52,6 +53,7 @@ Use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.ut
```python ```python
from fastapi_toolsets.pytest import create_worker_database, create_db_session from fastapi_toolsets.pytest import create_worker_database, create_db_session
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def worker_db_url(): async def worker_db_url():
async with create_worker_database( async with create_worker_database(
@@ -96,11 +98,17 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
```python ```python
from fastapi_toolsets.pytest import worker_database_url from fastapi_toolsets.pytest import worker_database_url
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test") 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/gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise # → "postgresql+asyncpg://user:pass@localhost/test" otherwise
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp") 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_gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise # → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
``` ```
@@ -112,6 +120,7 @@ url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", defa
```python ```python
from fastapi_toolsets.pytest import cleanup_tables from fastapi_toolsets.pytest import cleanup_tables
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def clean(db_session): async def clean(db_session):
yield yield
+4
View File
@@ -15,6 +15,7 @@ The most common wrapper for a single resource response.
```python ```python
from fastapi_toolsets.schemas import Response from fastapi_toolsets.schemas import Response
@router.get("/users/{id}") @router.get("/users/{id}")
async def get_user(user: User = UserDep) -> Response[UserSchema]: async def get_user(user: User = UserDep) -> Response[UserSchema]:
return Response(data=user, message="User retrieved") 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 ```python
from fastapi_toolsets.schemas import OffsetPaginatedResponse from fastapi_toolsets.schemas import OffsetPaginatedResponse
@router.get("/users") @router.get("/users")
async def list_users( async def list_users(
page: int = 1, page: int = 1,
@@ -74,6 +76,7 @@ Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md
```python ```python
from fastapi_toolsets.schemas import CursorPaginatedResponse from fastapi_toolsets.schemas import CursorPaginatedResponse
@router.get("/events") @router.get("/events")
async def list_events( async def list_events(
cursor: str | None = None, cursor: str | None = None,
@@ -110,6 +113,7 @@ When used as a return annotation, `PaginatedResponse[T]` automatically expands t
from fastapi_toolsets.crud import PaginationType from fastapi_toolsets.crud import PaginationType
from fastapi_toolsets.schemas import PaginatedResponse from fastapi_toolsets.schemas import PaginatedResponse
@router.get("/users") @router.get("/users")
async def list_users( async def list_users(
pagination_type: PaginationType = PaginationType.OFFSET, pagination_type: PaginationType = PaginationType.OFFSET,
+5 -1
View File
@@ -6,7 +6,11 @@ You can import the main symbols from `fastapi_toolsets.crud`:
```python ```python
from fastapi_toolsets.crud import CrudFactory, AsyncCrud 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 ## ::: fastapi_toolsets.crud.factory.AsyncCrud
+14 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.0.0" version = "5.1.2"
description = "Production-ready utilities for FastAPI applications" description = "Production-ready utilities for FastAPI applications"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@@ -91,9 +91,21 @@ docs-src = [
] ]
[build-system] [build-system]
requires = ["uv_build>=0.10,<0.12.0"] requires = ["uv_build>=0.10,<0.13.0"]
build-backend = "uv_build" 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", "fastapi.Security"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
filterwarnings = [ filterwarnings = [
+1 -1
View File
@@ -24,4 +24,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") return Response(data={"user": user.username}, message="Success")
""" """
__version__ = "5.0.0" __version__ = "5.1.2"
+1 -1
View File
@@ -22,7 +22,6 @@ __all__ = [
"AsyncCrud", "AsyncCrud",
"CrudFactory", "CrudFactory",
"FacetFieldType", "FacetFieldType",
"get_searchable_fields",
"InvalidFacetFilterError", "InvalidFacetFilterError",
"InvalidSearchColumnError", "InvalidSearchColumnError",
"JoinType", "JoinType",
@@ -34,4 +33,5 @@ __all__ = [
"SearchConfig", "SearchConfig",
"SearchFieldType", "SearchFieldType",
"UnsupportedFacetTypeError", "UnsupportedFacetTypeError",
"get_searchable_fields",
] ]
+209 -77
View File
@@ -14,12 +14,25 @@ from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overl
from fastapi import Query from fastapi import Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import Date, DateTime, Float, Integer, Numeric, Uuid, and_, func, select from sqlalchemy import (
Date,
DateTime,
Float,
Integer,
Numeric,
Uuid,
and_,
func,
select,
tuple_,
)
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
from sqlalchemy.sql import operators
from sqlalchemy.sql.base import ExecutableOption from sqlalchemy.sql.base import ExecutableOption
from sqlalchemy.sql.elements import UnaryExpression
from sqlalchemy.sql.roles import WhereHavingRole from sqlalchemy.sql.roles import WhereHavingRole
from ..db import transaction from ..db import transaction
@@ -128,6 +141,32 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
return q return q
def _fans_out(
search_joins: Sequence[Any] | None, order_joins: Sequence[Any] | None
) -> bool:
"""True if any relationship join yields a collection."""
return any(
rel.property.uselist for rel in (*(search_joins or ()), *(order_joins or ()))
)
def _grouped_order(clause: Any, table: Any) -> Any:
"""Recast an order clause for a query grouped by the entity's key."""
inner = clause.element if isinstance(clause, UnaryExpression) else clause
expr = inner.__clause_element__() if hasattr(inner, "__clause_element__") else inner
tables = {
t
for c in getattr(expr, "base_columns", ())
if (t := getattr(c, "table", None)) is not None
}
if tables and tables <= {table}:
return clause
agg = func.min(inner)
if isinstance(clause, UnaryExpression) and clause.modifier is operators.desc_op:
return agg.desc()
return agg.asc()
class AsyncCrud(Generic[ModelType]): class AsyncCrud(Generic[ModelType]):
"""Generic async CRUD operations for SQLAlchemy models. """Generic async CRUD operations for SQLAlchemy models.
@@ -163,6 +202,64 @@ class AsyncCrud(Generic[ModelType]):
): ):
cls.searchable_fields = [pk_col, *raw_fields] cls.searchable_fields = [pk_col, *raw_fields]
@classmethod
def _pk_attrs(cls: type[Self]) -> list[QueryableAttribute[Any]]:
"""The model's primary key columns as instrumented attributes."""
return [
getattr(cls.model, cast(str, col.key))
for col in cls.model.__mapper__.primary_key
]
@classmethod
async def _page_entities(
cls: type[Self],
session: AsyncSession,
q: Any,
*,
order_clauses: Sequence[Any],
limit: int,
offset: int | None = None,
load_options: Sequence[ExecutableOption] | None = None,
with_for_update: _ForUpdateMode = False,
) -> list[ModelType]:
"""Return up to *limit* entities, paging over distinct primary keys."""
pk_attrs = cls._pk_attrs()
table = cls.model.__table__
# Fall back to the primary key so the page boundary is deterministic.
grouped = [_grouped_order(c, table) for c in order_clauses] or [pk_attrs[0]]
id_q = (
q.order_by(None)
.with_only_columns(*pk_attrs)
.group_by(*pk_attrs)
.order_by(*grouped)
.limit(limit)
)
if offset:
id_q = id_q.offset(offset)
rows = (await session.execute(id_q)).all()
ids = [row[0] if len(pk_attrs) == 1 else tuple(row) for row in rows]
if not ids:
return []
where = (
pk_attrs[0].in_(ids) if len(pk_attrs) == 1 else tuple_(*pk_attrs).in_(ids)
)
item_q = select(cls.model).where(where)
if resolved := cls._resolve_load_options(load_options):
item_q = item_q.options(*resolved)
item_q = _apply_for_update(item_q, with_for_update)
found = (await session.execute(item_q)).unique().scalars().all()
rank = {pk: n for n, pk in enumerate(ids)}
def _key(obj: Any) -> Any:
values = tuple(getattr(obj, a.key) for a in pk_attrs)
return values[0] if len(pk_attrs) == 1 else values
return cast(
list[ModelType], sorted(found, key=lambda o: rank.get(_key(o), len(ids)))
)
@classmethod @classmethod
def _resolve_load_options( def _resolve_load_options(
cls, load_options: Sequence[ExecutableOption] | None cls, load_options: Sequence[ExecutableOption] | None
@@ -173,30 +270,17 @@ class AsyncCrud(Generic[ModelType]):
return cls.default_load_options return cls.default_load_options
@classmethod @classmethod
def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]: async def _reload_with_options(
"""Capture PK values off instance — call before commit expires attributes.""" cls: type[Self], session: AsyncSession, instance: DeclarativeBase
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: ) -> ModelType:
"""Re-query by previously captured PK values, with default_load_options applied.""" """Re-query instance by PK with default_load_options applied."""
# Only called when cls.default_load_options is set (see call sites). mapper = cls.model.__mapper__
pk_filters = [ pk_filters = [
getattr(cls.model, key) == value for key, value in pk_values.items() getattr(cls.model, cast(str, col.key))
== getattr(instance, cast(str, col.key))
for col in mapper.primary_key
] ]
q = select(cls.model).where(and_(*pk_filters)) return await cls.get(session, filters=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 @classmethod
async def _resolve_m2m( async def _resolve_m2m(
@@ -302,13 +386,29 @@ class AsyncCrud(Generic[ModelType]):
own_filters=own_filters, own_filters=own_filters,
) )
@classmethod
def _resolve_search_fields(
cls: type[Self],
search_fields: Sequence[SearchFieldType] | None,
) -> Sequence[SearchFieldType] | None:
"""Return search_fields if given, otherwise fall back to the class-level default."""
return search_fields if search_fields is not None else cls.searchable_fields
@classmethod
def _resolve_order_fields(
cls: type[Self],
order_fields: Sequence[OrderFieldType] | None,
) -> Sequence[OrderFieldType] | None:
"""Return order_fields if given, otherwise fall back to the class-level default."""
return order_fields if order_fields is not None else cls.order_fields
@classmethod @classmethod
def _resolve_search_columns( def _resolve_search_columns(
cls: type[Self], cls: type[Self],
search_fields: Sequence[SearchFieldType] | None, search_fields: Sequence[SearchFieldType] | None,
) -> list[str] | None: ) -> list[str] | None:
"""Return search column keys, or None if no searchable fields configured.""" """Return search column keys, or None if no searchable fields configured."""
fields = search_fields if search_fields is not None else cls.searchable_fields fields = cls._resolve_search_fields(search_fields)
if not fields: if not fields:
return None return None
return search_field_keys(fields) return search_field_keys(fields)
@@ -319,7 +419,7 @@ class AsyncCrud(Generic[ModelType]):
order_fields: Sequence[OrderFieldType] | None, order_fields: Sequence[OrderFieldType] | None,
) -> list[str] | None: ) -> list[str] | None:
"""Return sort column keys, or None if no order fields configured.""" """Return sort column keys, or None if no order fields configured."""
fields = order_fields if order_fields is not None else cls.order_fields fields = cls._resolve_order_fields(order_fields)
if not fields: if not fields:
return None return None
return sorted(facet_keys(fields)) return sorted(facet_keys(fields))
@@ -398,9 +498,7 @@ class AsyncCrud(Generic[ModelType]):
order_field_map: dict[str, OrderFieldType] | None = None order_field_map: dict[str, OrderFieldType] | None = None
order_valid_keys: list[str] | None = None order_valid_keys: list[str] | None = None
if order: if order:
resolved_order = ( resolved_order = cls._resolve_order_fields(order_fields)
order_fields if order_fields is not None else cls.order_fields
)
if resolved_order: if resolved_order:
keys = facet_keys(resolved_order) keys = facet_keys(resolved_order)
order_field_map = dict(zip(keys, resolved_order)) order_field_map = dict(zip(keys, resolved_order))
@@ -426,8 +524,21 @@ class AsyncCrud(Generic[ModelType]):
] ]
) )
fixed: dict[str, Any] = {
**pagination_fixed,
"search_fields": (cls._resolve_search_fields(search_fields) or [])
if search
else [],
"facet_fields": (cls._resolve_facet_fields(facet_fields) or [])
if filter
else [],
"order_fields": (cls._resolve_order_fields(order_fields) or [])
if order
else [],
}
async def dependency(**kwargs: Any) -> dict[str, Any]: async def dependency(**kwargs: Any) -> dict[str, Any]:
result: dict[str, Any] = dict(pagination_fixed) result: dict[str, Any] = dict(fixed)
for name in pagination_param_names: for name in pagination_param_names:
result[name] = kwargs[name] result[name] = kwargs[name]
@@ -750,14 +861,9 @@ class AsyncCrud(Generic[ModelType]):
setattr(db_model, rel_attr, related_instances) setattr(db_model, rel_attr, related_instances)
session.add(db_model) session.add(db_model)
pk_values: dict[str, Any] | None = None await session.refresh(db_model)
if cls.default_load_options: if cls.default_load_options:
await session.flush() db_model = await cls._reload_with_options(session, db_model)
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) result = cast(ModelType, db_model)
if schema: if schema:
return Response(data=schema.model_validate(result)) return Response(data=schema.model_validate(result))
@@ -1023,9 +1129,21 @@ class AsyncCrud(Generic[ModelType]):
q = q.where(and_(*filters)) q = q.where(and_(*filters))
if resolved := cls._resolve_load_options(load_options): if resolved := cls._resolve_load_options(load_options):
q = q.options(*resolved) q = q.options(*resolved)
q = _apply_for_update(q, with_for_update)
if order_by is not None: if order_by is not None:
q = q.order_by(order_by) q = q.order_by(order_by)
if limit is not None and joins:
return await cls._page_entities(
session,
q,
order_clauses=[] if order_by is None else [order_by],
limit=limit,
offset=offset,
load_options=load_options,
with_for_update=with_for_update,
)
q = _apply_for_update(q, with_for_update)
if offset is not None: if offset is not None:
q = q.offset(offset) q = q.offset(offset)
if limit is not None: if limit is not None:
@@ -1122,15 +1240,9 @@ class AsyncCrud(Generic[ModelType]):
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True) m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
for rel_attr, related_instances in m2m_resolved.items(): for rel_attr, related_instances in m2m_resolved.items():
setattr(db_model, rel_attr, related_instances) setattr(db_model, rel_attr, related_instances)
await session.refresh(db_model)
pk_values: dict[str, Any] | None = None if cls.default_load_options:
if cls.default_load_options: db_model = await cls._reload_with_options(session, db_model)
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: if schema:
return Response(data=schema.model_validate(db_model)) return Response(data=schema.model_validate(db_model))
return db_model return db_model
@@ -1374,17 +1486,31 @@ class AsyncCrud(Generic[ModelType]):
q = q.where(and_(*filters)) q = q.where(and_(*filters))
if resolved := cls._resolve_load_options(load_options): if resolved := cls._resolve_load_options(load_options):
q = q.options(*resolved) q = q.options(*resolved)
if order_by is not None: order_clauses: list[Any] = [] if order_by is None else [order_by]
q = q.order_by(order_by) q = q.order_by(*order_clauses)
fetch_limit = items_per_page if include_total else items_per_page + 1
total_count: int | None = None
# A to-many join repeats each entity, so LIMIT would slice joined rows
# and `.unique()` would shrink the page after the fact.
if _fans_out(search_joins, order_joins):
raw_items = await cls._page_entities(
session,
q,
order_clauses=order_clauses,
limit=fetch_limit,
offset=offset,
load_options=load_options,
)
else:
result = await session.execute(q.offset(offset).limit(fetch_limit))
raw_items = cast(list[ModelType], result.unique().scalars().all())
fetched = len(raw_items)
raw_items = raw_items[:items_per_page]
if include_total: if include_total:
q = q.offset(offset).limit(items_per_page)
result = await session.execute(q)
raw_items = cast(list[ModelType], result.unique().scalars().all())
# Count query (with same joins and filters) # Count query (with same joins and filters)
pk_col = cls.model.__mapper__.primary_key[0] count_q = select(func.count(func.distinct(cls._pk_attrs()[0])))
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
count_q = count_q.select_from(cls.model) count_q = count_q.select_from(cls.model)
# Apply explicit joins to count query # Apply explicit joins to count query
@@ -1397,16 +1523,11 @@ class AsyncCrud(Generic[ModelType]):
count_q = count_q.where(and_(*filters)) count_q = count_q.where(and_(*filters))
count_result = await session.execute(count_q) count_result = await session.execute(count_q)
total_count: int = count_result.scalar_one() total_count = count_result.scalar_one()
has_more = page * items_per_page < total_count has_more = page * items_per_page < total_count
else: else:
# Fetch one extra row to detect if a next page exists without COUNT # One extra row was fetched to detect a next page without COUNT
q = q.offset(offset).limit(items_per_page + 1) has_more = fetched > items_per_page
result = await session.execute(q)
raw_items = cast(list[ModelType], result.unique().scalars().all())
has_more = len(raw_items) > items_per_page
raw_items = raw_items[:items_per_page]
total_count = None
items: list[Any] = [schema.model_validate(item) for item in raw_items] items: list[Any] = [schema.model_validate(item) for item in raw_items]
@@ -1543,18 +1664,30 @@ class AsyncCrud(Generic[ModelType]):
q = q.options(*resolved) q = q.options(*resolved)
# Cursor column is always the primary sort; reverse direction for prev traversal # Cursor column is always the primary sort; reverse direction for prev traversal
if direction is _CursorDirection.PREV: cursor_clause = (
q = q.order_by(cursor_column.desc()) cursor_column.desc()
else: if direction is _CursorDirection.PREV
q = q.order_by(cursor_column) else cursor_column
)
order_clauses: list[Any] = [cursor_clause]
if order_by is not None: if order_by is not None:
q = q.order_by(order_by) order_clauses.append(order_by)
q = q.order_by(*order_clauses)
# Fetch one extra to detect whether another page exists in this direction
q = q.limit(items_per_page + 1)
result = await session.execute(q)
raw_items = cast(list[ModelType], result.unique().scalars().all())
# One extra row detects whether another page exists in this direction.
# Under a to-many join that extra row may be a duplicate of one already
# on the page, which reads as "no next page" and ends traversal early.
if _fans_out(search_joins, order_joins):
raw_items = await cls._page_entities(
session,
q,
order_clauses=order_clauses,
limit=items_per_page + 1,
load_options=load_options,
)
else:
result = await session.execute(q.limit(items_per_page + 1))
raw_items = cast(list[ModelType], result.unique().scalars().all())
has_more = len(raw_items) > items_per_page has_more = len(raw_items) > items_per_page
items_page = raw_items[:items_per_page] items_page = raw_items[:items_per_page]
@@ -1580,11 +1713,10 @@ class AsyncCrud(Generic[ModelType]):
# prev_cursor: points before the first item in ascending order # prev_cursor: points before the first item in ascending order
prev_cursor: str | None = None prev_cursor: str | None = None
if direction is _CursorDirection.NEXT and cursor is not None and items_page: if items_page and (
prev_cursor = _encode_cursor( (direction is _CursorDirection.NEXT and cursor is not None)
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV or (direction is _CursorDirection.PREV and has_more)
) ):
elif direction is _CursorDirection.PREV and has_more and items_page:
prev_cursor = _encode_cursor( prev_cursor = _encode_cursor(
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
) )
+5 -3
View File
@@ -160,9 +160,11 @@ def build_search_filters(
column = field column = field
# Build the filter (cast to String only when needed, to preserve # Build the filter (cast to String only when needed, to preserve
# pg_trgm GIN index usability on already-String columns) # pg_trgm GIN index usability on already-String columns).
column_as_string = ( column_as_string = (
column if isinstance(column.type, String) else column.cast(String) column
if isinstance(column.type, String) and not isinstance(column.type, Enum)
else column.cast(String)
) )
if config.case_sensitive: if config.case_sensitive:
filters.append(column_as_string.like(f"%{query}%")) filters.append(column_as_string.like(f"%{query}%"))
@@ -386,7 +388,7 @@ def build_filter_by(
enum_class = col_type.enum_class enum_class = col_type.enum_class
if enum_class is not None: if enum_class is not None:
def _coerce_enum(v: Any) -> Any: def _coerce_enum(v: Any, enum_class: Any = enum_class) -> Any:
if isinstance(v, enum_class): if isinstance(v, enum_class):
return v return v
return enum_class[v] # lookup by name: "PENDING", "RED" return enum_class[v] # lookup by name: "PENDING", "RED"
+11 -9
View File
@@ -66,10 +66,8 @@ class _CommitOnResponseMiddleware:
async def send_wrapper(message: Message) -> None: async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start": 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") state = scope.get("state")
session = state.get(self.state_attr) if state else None session = state.pop(self.state_attr, None) if state else None
if session is not None and session.in_transaction(): if session is not None and session.in_transaction():
await session.commit() await session.commit()
await send(message) await send(message)
@@ -158,7 +156,6 @@ class Database:
# Private, per-instance state attribute; cannot collide with another # Private, per-instance state attribute; cannot collide with another
# Database or be mismatched against the middleware. # Database or be mismatched against the middleware.
self._state_attr = f"_ft_db_session_{id(self):x}" self._state_attr = f"_ft_db_session_{id(self):x}"
self._middleware_installed = False
self._disposed = False self._disposed = False
async def _dispose(self) -> None: async def _dispose(self) -> None:
@@ -206,15 +203,13 @@ class Database:
``` ```
""" """
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr) app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
self._middleware_installed = True
inner_lifespan = app.router.lifespan_context inner_lifespan = app.router.lifespan_context
@asynccontextmanager @asynccontextmanager
async def _composed(app_: Any) -> AsyncGenerator[None, None]: async def _composed(app_: Any) -> AsyncGenerator[None, None]:
async with self.lifespan(app_): async with self.lifespan(app_), inner_lifespan(app_):
async with inner_lifespan(app_): yield
yield
app.router.lifespan_context = _composed app.router.lifespan_context = _composed
@@ -244,10 +239,17 @@ class Database:
return await UserCrud.get(session, [User.id == user_id]) return await UserCrud.get(session, [User.id == user_id])
``` ```
""" """
borrowed = getattr(request.state, self._state_attr, None)
if borrowed is not None:
yield borrowed
return
async with self._open() as session: async with self._open() as session:
setattr(request.state, self._state_attr, session) setattr(request.state, self._state_attr, session)
yield session yield session
if not self._middleware_installed and session.in_transaction(): if (
getattr(request.state, self._state_attr, None) is session
and session.in_transaction()
):
await session.commit() await session.commit()
@asynccontextmanager @asynccontextmanager
+1 -3
View File
@@ -1,6 +1,6 @@
"""Many-to-Many association-table helpers (direct, without loading collections).""" """Many-to-Many association-table helpers (direct, without loading collections)."""
from typing import Any, TypeVar, cast from typing import Any, cast
from sqlalchemy import ColumnElement, Table, delete, tuple_ from sqlalchemy import ColumnElement, Table, delete, tuple_
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -8,8 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
from sqlalchemy.orm.relationships import RelationshipProperty from sqlalchemy.orm.relationships import RelationshipProperty
_M = TypeVar("_M", bound=DeclarativeBase)
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg] def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
"""Return the validated M2M RelationshipProperty and its secondary table. """Return the validated M2M RelationshipProperty and its secondary table.
+74 -63
View File
@@ -2,14 +2,15 @@
import inspect import inspect
import typing import typing
from collections.abc import Callable from collections.abc import Callable, Sequence
from typing import Any, cast from typing import Any, cast
from fastapi import Depends from fastapi import Depends
from fastapi.params import Depends as DependsClass from fastapi.params import Depends as DependsClass
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.base import ExecutableOption
from .crud import CrudFactory from .crud import AsyncCrud, CrudFactory
from .types import ModelType, SessionDependency from .types import ModelType, SessionDependency
__all__ = ["BodyDependency", "PathDependency"] __all__ = ["BodyDependency", "PathDependency"]
@@ -24,12 +25,59 @@ def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
return session_dep return session_dep
def _fetch_dependency(
model: type[ModelType],
field: Any,
*,
session_dep: SessionDependency,
param_name: str,
crud: type[AsyncCrud[ModelType]] | None,
load_options: Sequence[ExecutableOption] | None,
) -> ModelType:
"""Build a Depends() that fetches one row by ``field == <param_name>``."""
session_callable = _unwrap_session_dep(session_dep)
if crud is not None and crud.model is not model:
raise ValueError(
f"crud is bound to {crud.model.__name__}, not {model.__name__}"
)
crud = crud or CrudFactory(model)
# `session` has no default here: the __signature__ override below is what
# FastAPI reads, and it always passes `session` explicitly.
async def dependency(session: AsyncSession, **kwargs: Any) -> ModelType:
return await crud.get(
session,
filters=[field == kwargs[param_name]],
load_options=load_options,
)
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
parameters=[
inspect.Parameter(
param_name,
inspect.Parameter.KEYWORD_ONLY,
annotation=field.type.python_type,
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
def PathDependency( def PathDependency(
model: type[ModelType], model: type[ModelType],
field: Any, field: Any,
*, *,
session_dep: SessionDependency, session_dep: SessionDependency,
param_name: str | None = None, param_name: str | None = None,
crud: type[AsyncCrud[ModelType]] | None = None,
load_options: Sequence[ExecutableOption] | None = None,
) -> ModelType: ) -> ModelType:
"""Create a dependency that fetches a DB object from a path parameter. """Create a dependency that fetches a DB object from a path parameter.
@@ -38,6 +86,10 @@ def PathDependency(
field: Model field to filter by (e.g., User.id) field: Model field to filter by (e.g., User.id)
session_dep: Session dependency function (e.g., get_db) session_dep: Session dependency function (e.g., get_db)
param_name: Path parameter name (defaults to model_field, e.g., user_id) param_name: Path parameter name (defaults to model_field, e.g., user_id)
crud: Existing CRUD class to fetch with, so its ``default_load_options``
apply. Defaults to a bare ``CrudFactory(model)``.
load_options: SQLAlchemy loader options for the fetch. Overrides the CRUD's
``default_load_options`` entirely rather than merging with them.
Returns: Returns:
A Depends() instance that resolves to the model instance A Depends() instance that resolves to the model instance
@@ -55,40 +107,14 @@ def PathDependency(
): ... ): ...
``` ```
""" """
session_callable = _unwrap_session_dep(session_dep) return _fetch_dependency(
crud = CrudFactory(model) model,
name = ( field,
param_name session_dep=session_dep,
if param_name is not None param_name=param_name or f"{model.__name__.lower()}_{field.key}",
else "{}_{}".format(model.__name__.lower(), field.key) crud=crud,
load_options=load_options,
) )
python_type = field.type.python_type
async def dependency(
session: AsyncSession = Depends(session_callable), **kwargs: Any
) -> ModelType:
value = kwargs[name]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
),
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
def BodyDependency( def BodyDependency(
@@ -97,6 +123,8 @@ def BodyDependency(
*, *,
session_dep: SessionDependency, session_dep: SessionDependency,
body_field: str, body_field: str,
crud: type[AsyncCrud[ModelType]] | None = None,
load_options: Sequence[ExecutableOption] | None = None,
) -> ModelType: ) -> ModelType:
"""Create a dependency that fetches a DB object from a body field. """Create a dependency that fetches a DB object from a body field.
@@ -105,6 +133,10 @@ def BodyDependency(
field: Model field to filter by (e.g., User.id) field: Model field to filter by (e.g., User.id)
session_dep: Session dependency function (e.g., get_db) session_dep: Session dependency function (e.g., get_db)
body_field: Name of the field in the request body body_field: Name of the field in the request body
crud: Existing CRUD class to fetch with, so its ``default_load_options``
apply. Defaults to a bare ``CrudFactory(model)``.
load_options: SQLAlchemy loader options for the fetch. Overrides the CRUD's
``default_load_options`` entirely rather than merging with them.
Returns: Returns:
A Depends() instance that resolves to the model instance A Depends() instance that resolves to the model instance
@@ -124,32 +156,11 @@ def BodyDependency(
): ... ): ...
``` ```
""" """
session_callable = _unwrap_session_dep(session_dep) return _fetch_dependency(
crud = CrudFactory(model) model,
python_type = field.type.python_type field,
session_dep=session_dep,
async def dependency( param_name=body_field,
session: AsyncSession = Depends(session_callable), **kwargs: Any crud=crud,
) -> ModelType: load_options=load_options,
value = kwargs[body_field]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
),
) )
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
+2 -2
View File
@@ -23,8 +23,6 @@ __all__ = [
"ApiException", "ApiException",
"ConflictError", "ConflictError",
"ForbiddenError", "ForbiddenError",
"generate_error_responses",
"init_exceptions_handlers",
"InvalidFacetFilterError", "InvalidFacetFilterError",
"InvalidOrderFieldError", "InvalidOrderFieldError",
"InvalidSearchColumnError", "InvalidSearchColumnError",
@@ -34,4 +32,6 @@ __all__ = [
"PoolExhaustedError", "PoolExhaustedError",
"UnauthorizedError", "UnauthorizedError",
"UnsupportedFacetTypeError", "UnsupportedFacetTypeError",
"generate_error_responses",
"init_exceptions_handlers",
] ]
+25 -26
View File
@@ -146,35 +146,34 @@ def _patched_openapi(
for path_data in openapi_schema.get("paths", {}).values(): for path_data in openapi_schema.get("paths", {}).values():
for operation in path_data.values(): for operation in path_data.values():
if isinstance(operation, dict) and "responses" in operation: if isinstance(operation, dict) and "422" in operation.get("responses", {}):
if "422" in operation["responses"]: operation["responses"]["422"] = {
operation["responses"]["422"] = { "description": "Validation Error",
"description": "Validation Error", "content": {
"content": { "application/json": {
"application/json": { "examples": {
"examples": { "VAL-422": {
"VAL-422": { "summary": "Validation Error",
"summary": "Validation Error", "value": {
"value": { "data": {
"data": { "errors": [
"errors": [ {
{ "field": "field_name",
"field": "field_name", "message": "value is not valid",
"message": "value is not valid", "type": "value_error",
"type": "value_error", }
} ]
]
},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
}, },
} "status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
},
} }
} }
}, }
} },
}
app.openapi_schema = openapi_schema app.openapi_schema = openapi_schema
return app.openapi_schema return app.openapi_schema
+3 -3
View File
@@ -3,19 +3,19 @@
from .columns import ( from .columns import (
CreatedAtMixin, CreatedAtMixin,
TimestampMixin, TimestampMixin,
UpdatedAtMixin,
UUIDMixin, UUIDMixin,
UUIDv7Mixin, UUIDv7Mixin,
UpdatedAtMixin,
) )
from .watched import EventSession, ModelEvent, listens_for from .watched import EventSession, ModelEvent, listens_for
__all__ = [ __all__ = [
"CreatedAtMixin",
"EventSession", "EventSession",
"ModelEvent", "ModelEvent",
"TimestampMixin",
"UUIDMixin", "UUIDMixin",
"UUIDv7Mixin", "UUIDv7Mixin",
"CreatedAtMixin",
"UpdatedAtMixin", "UpdatedAtMixin",
"TimestampMixin",
"listens_for", "listens_for",
] ]
+38 -9
View File
@@ -8,6 +8,7 @@ from typing import Any
from sqlalchemy import event, select, tuple_ from sqlalchemy import event, select, tuple_
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
from ..logger import get_logger from ..logger import get_logger
@@ -189,24 +190,52 @@ async def _invoke_callback(
await result await result
def _loaded_relationships(obj: Any) -> set[str]:
"""Relationship keys currently loaded on *obj*."""
state = sa_inspect(obj)
unloaded = state.unloaded
return {
rel.key
for rel in state.mapper.relationships
if rel.key not in unloaded and rel.lazy not in ("dynamic", "write_only")
}
def _snapshot_loaded_relationships(session: Any) -> dict[int, set[str]]:
"""Record loaded relationships for the tracked objects, keyed by ``id``."""
objs = list(session.info.get(_SESSION_CREATES, []))
objs += [obj for obj, _ in session.info.get(_SESSION_UPDATES, {}).values()]
return {id(obj): _loaded_relationships(obj) for obj in objs}
async def _batch_reload( async def _batch_reload(
session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]] session: AsyncSession,
model: type,
objs: list[Any],
preloaded: dict[int, set[str]],
) -> None: ) -> None:
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip.""" """Re-populate all rows of *model* in one round trip."""
pk_cols = sa_inspect(model).primary_key pk_cols = sa_inspect(model, raiseerr=True).primary_key
pk_tuples = [sa_inspect(obj).key[1] for obj in objs]
where = ( where = (
pk_cols[0].in_([pk[0] for pk in pk_tuples]) pk_cols[0].in_([pk[0] for pk in pk_tuples])
if len(pk_cols) == 1 if len(pk_cols) == 1
else tuple_(*pk_cols).in_(pk_tuples) else tuple_(*pk_cols).in_(pk_tuples)
) )
q = select(model).where(where).execution_options(populate_existing=True) q = select(model).where(where).execution_options(populate_existing=True)
loaded: set[str] = set()
for obj in objs:
loaded |= preloaded.get(id(obj), set())
if loaded:
q = q.options(*(selectinload(getattr(model, key)) for key in loaded))
await session.execute(q) await session.execute(q)
class EventSession(AsyncSession): class EventSession(AsyncSession):
"""AsyncSession subclass that dispatches lifecycle callbacks after commit.""" """AsyncSession subclass that dispatches lifecycle callbacks after commit."""
async def commit(self) -> None: # noqa: C901 async def commit(self) -> None:
preloaded = _snapshot_loaded_relationships(self)
await super().commit() await super().commit()
creates: list[Any] = self.info.pop(_SESSION_CREATES, []) creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
@@ -249,25 +278,25 @@ class EventSession(AsyncSession):
# session.get() per object. # session.get() per object.
create_items: list[Any] = [] create_items: list[Any] = []
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = [] update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
pk_by_type: dict[type, list[tuple[Any, ...]]] = {} objs_by_type: dict[type, list[Any]] = {}
for obj in creates: for obj in creates:
state = sa_inspect(obj, raiseerr=False) 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 continue
create_items.append(obj) create_items.append(obj)
pk_by_type.setdefault(type(obj), []).append(state.key[1]) objs_by_type.setdefault(type(obj), []).append(obj)
for obj, changes in field_changes.values(): for obj, changes in field_changes.values():
state = sa_inspect(obj, raiseerr=False) 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 continue
update_items.append((obj, changes)) update_items.append((obj, changes))
pk_by_type.setdefault(type(obj), []).append(state.key[1]) objs_by_type.setdefault(type(obj), []).append(obj)
for model, pk_tuples in pk_by_type.items(): for model, objs in objs_by_type.items():
try: try:
await _batch_reload(self, model, pk_tuples) await _batch_reload(self, model, objs, preloaded)
except Exception as exc: except Exception as exc:
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc) _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
+4 -4
View File
@@ -2,7 +2,7 @@
import math import math
from enum import Enum 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 from pydantic import BaseModel, ConfigDict, Field, computed_field
@@ -10,11 +10,11 @@ from .types import DataT
__all__ = [ __all__ = [
"ApiError", "ApiError",
"CursorPagination",
"CursorPaginatedResponse", "CursorPaginatedResponse",
"CursorPagination",
"ErrorResponse", "ErrorResponse",
"OffsetPagination",
"OffsetPaginatedResponse", "OffsetPaginatedResponse",
"OffsetPagination",
"PaginatedResponse", "PaginatedResponse",
"PaginationType", "PaginationType",
"PydanticBase", "PydanticBase",
@@ -174,7 +174,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
cached = cls._discriminated_union_cache.get(item) cached = cls._discriminated_union_cache.get(item)
if cached is None: if cached is None:
cached = Annotated[ 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"), Field(discriminator="pagination_type"),
] ]
cls._discriminated_union_cache[item] = cached cls._discriminated_union_cache[item] = cached
+6 -7
View File
@@ -1,27 +1,28 @@
"""Shared pytest fixtures for fastapi-utils tests.""" """Shared pytest fixtures for fastapi-utils tests."""
import datetime
import decimal
import os import os
import uuid import uuid
from enum import Enum from enum import Enum
import pytest import pytest
from pydantic import BaseModel from pydantic import BaseModel
import datetime
import decimal
from sqlalchemy import ( from sqlalchemy import (
JSON,
Column, Column,
Date, Date,
DateTime, DateTime,
Enum as SAEnum,
ForeignKey, ForeignKey,
Integer, Integer,
JSON,
Numeric, Numeric,
String, String,
Table, Table,
Uuid, Uuid,
) )
from sqlalchemy import (
Enum as SAEnum,
)
from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -38,8 +39,6 @@ DATABASE_URL = os.getenv(
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Base class for test models.""" """Base class for test models."""
pass
class Role(Base): class Role(Base):
"""Test role model.""" """Test role model."""
-1
View File
@@ -554,7 +554,6 @@ class TestAsyncCommand:
@async_command @async_command
async def async_func() -> None: async def async_func() -> None:
"""This is a docstring.""" """This is a docstring."""
pass
assert async_func.__doc__ == """This is a docstring.""" assert async_func.__doc__ == """This is a docstring."""
+24
View File
@@ -466,6 +466,30 @@ class TestDefaultLoadOptionsIntegration:
assert updated.role is not None assert updated.role is not None
assert updated.role.name == "admin" assert updated.role.name == "admin"
@pytest.mark.anyio
async def test_create_does_not_expire_already_loaded_relationships(
self, db_session: AsyncSession
):
"""create()'s reload must not blow away loaded state on related objects."""
UserWithDefaultLoad = CrudFactory(
User, default_load_options=[selectinload(User.role)]
)
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
role = await RoleCrud.get(
db_session,
filters=[Role.id == role.id],
load_options=[selectinload(Role.users)],
)
assert role.users == []
await UserWithDefaultLoad.create(
db_session,
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
)
# must not trigger a lazy load
assert role.users == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_load_options_overrides_default_load_options( async def test_load_options_overrides_default_load_options(
self, db_session: AsyncSession self, db_session: AsyncSession
+353 -4
View File
@@ -5,6 +5,7 @@ import uuid
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.sql.elements import ColumnElement, UnaryExpression from sqlalchemy.sql.elements import ColumnElement, UnaryExpression
from fastapi_toolsets.crud import ( from fastapi_toolsets.crud import (
@@ -16,7 +17,7 @@ from fastapi_toolsets.crud import (
get_searchable_fields, get_searchable_fields,
) )
from fastapi_toolsets.exceptions import InvalidOrderFieldError from fastapi_toolsets.exceptions import InvalidOrderFieldError
from fastapi_toolsets.schemas import OffsetPagination, PaginationType from fastapi_toolsets.schemas import OffsetPagination, PaginationType, PydanticBase
from .conftest import ( from .conftest import (
Article, Article,
@@ -29,15 +30,19 @@ from .conftest import (
OrderCrud, OrderCrud,
OrderRead, OrderRead,
OrderStatus, OrderStatus,
Post,
PostCrud,
Role, Role,
RoleCreate, RoleCreate,
RoleCrud, RoleCrud,
RoleCursorCrud, RoleCursorCrud,
RoleRead, RoleRead,
Tag,
User, User,
UserCreate, UserCreate,
UserCrud, UserCrud,
UserRead, UserRead,
post_tags,
) )
@@ -388,6 +393,295 @@ class TestBuildSearchFilters:
assert "CAST" in str(filters[0]) assert "CAST" in str(filters[0])
def test_casts_enum_column(self):
"""Enum subclasses String but maps to a native DB enum, which has no ILIKE."""
from fastapi_toolsets.crud.search import build_search_filters
filters, _ = build_search_filters(Order, "PEND", search_fields=[Order.status])
assert "CAST" in str(filters[0])
class _PostTitle(PydanticBase):
"""Minimal read schema for the to-many pagination tests."""
id: uuid.UUID
title: str
class _TagName(PydanticBase):
name: str
class _PostWithTags(PydanticBase):
"""Serialising `tags` fails unless the relation was eager-loaded."""
title: str
tags: list[_TagName]
PostTagSearchCrud = CrudFactory(
Post,
searchable_fields=[Post.title, (Post.tags, Tag.name)],
cursor_column=Post.id,
)
_POST_COUNT = 10
async def _seed_posts_with_tags(session) -> None:
"""10 posts, each with 3 tags whose names all match the search term."""
author = await UserCrud.create(
session, UserCreate(username="fanout", email="fanout@test.com")
)
for i in range(_POST_COUNT):
tags = [Tag(name=f"shared-{i}-{j}") for j in range(3)]
session.add_all(tags)
session.add(Post(title=f"post{i:02d}", author_id=author.id, tags=tags))
await session.flush()
class TestPaginateToManyJoin:
"""Searching a to-many relationship must not truncate or duplicate pages."""
@pytest.mark.anyio
async def test_offset_pages_are_full_and_complete(self, db_session: AsyncSession):
"""Every page is full and every post is returned exactly once."""
await _seed_posts_with_tags(db_session)
seen: list[str] = []
for page in (1, 2):
result = await PostTagSearchCrud.offset_paginate(
db_session,
page=page,
items_per_page=5,
search="shared",
schema=_PostTitle,
)
assert result.pagination.total_count == _POST_COUNT
assert len(result.data) == 5, f"page {page} came back short"
seen += [p.title for p in result.data]
assert len(set(seen)) == _POST_COUNT, "posts duplicated or missing"
@pytest.mark.anyio
async def test_offset_without_total_reports_has_more(
self, db_session: AsyncSession
):
"""``has_more`` counts entities, not joined rows."""
await _seed_posts_with_tags(db_session)
first = await PostTagSearchCrud.offset_paginate(
db_session,
page=1,
items_per_page=5,
search="shared",
include_total=False,
schema=_PostTitle,
)
assert len(first.data) == 5
assert first.pagination.has_more is True
last = await PostTagSearchCrud.offset_paginate(
db_session,
page=2,
items_per_page=5,
search="shared",
include_total=False,
schema=_PostTitle,
)
assert len(last.data) == 5
assert last.pagination.has_more is False
@pytest.mark.anyio
async def test_cursor_traverses_every_row(self, db_session: AsyncSession):
"""Cursor traversal must not stop early."""
await _seed_posts_with_tags(db_session)
seen: list[str] = []
cursor: str | None = None
for _ in range(_POST_COUNT):
result = await PostTagSearchCrud.cursor_paginate(
db_session,
cursor=cursor,
items_per_page=5,
search="shared",
schema=_PostTitle,
)
seen += [p.title for p in result.data]
cursor = result.pagination.next_cursor
if cursor is None:
break
assert len(seen) == _POST_COUNT, "traversal stopped early or repeated rows"
assert len(set(seen)) == _POST_COUNT
@pytest.mark.anyio
async def test_orders_by_a_to_many_column(self, db_session: AsyncSession):
"""Ordering by a related column has to be collapsed to an aggregate."""
await _seed_posts_with_tags(db_session)
result = await PostTagSearchCrud.offset_paginate(
db_session,
page=1,
items_per_page=5,
search="shared",
order_by=Tag.name.asc(),
order_joins=[Post.tags],
schema=_PostTitle,
)
assert len(result.data) == 5
assert result.pagination.total_count == _POST_COUNT
@pytest.mark.anyio
async def test_orders_by_a_bare_to_many_column(self, db_session: AsyncSession):
"""An order clause with no explicit direction still needs aggregating."""
await _seed_posts_with_tags(db_session)
result = await PostTagSearchCrud.offset_paginate(
db_session,
page=1,
items_per_page=5,
search="shared",
order_by=Tag.name,
order_joins=[Post.tags],
schema=_PostTitle,
)
assert len(result.data) == 5
@pytest.mark.anyio
async def test_page_past_the_end_is_empty(self, db_session: AsyncSession):
"""No keys on the page means no second query and an empty result."""
await _seed_posts_with_tags(db_session)
result = await PostTagSearchCrud.offset_paginate(
db_session, page=99, items_per_page=5, search="shared", schema=_PostTitle
)
assert result.data == []
assert result.pagination.total_count == _POST_COUNT
@pytest.mark.anyio
async def test_load_options_apply_on_the_fan_out_path(
self, db_session: AsyncSession
):
"""The entity query still honours loader options."""
await _seed_posts_with_tags(db_session)
result = await PostTagSearchCrud.offset_paginate(
db_session,
page=1,
items_per_page=5,
search="shared",
load_options=[selectinload(Post.tags)],
schema=_PostWithTags,
)
assert len(result.data) == 5
assert all(len(p.tags) == 3 for p in result.data)
@pytest.mark.anyio
async def test_get_multi_is_not_truncated_by_a_to_many_join(
self, db_session: AsyncSession
):
"""`get_multi` takes a raw join, so cardinality cannot be inspected."""
await _seed_posts_with_tags(db_session)
rows = await PostCrud.get_multi(
db_session,
joins=[(post_tags, post_tags.c.post_id == Post.id)],
outer_join=True,
limit=5,
)
assert len(rows) == 5
assert len({r.id for r in rows}) == 5
def test_grouped_order_only_aggregates_foreign_columns(self):
"""A base-table column is left alone; anything else collapses to min()."""
from fastapi_toolsets.crud.factory import _grouped_order
table = Post.__table__
assert "min" not in str(_grouped_order(Post.title.desc(), table)).lower()
asc_on_join = str(_grouped_order(Tag.name.asc(), table))
desc_on_join = str(_grouped_order(Tag.name.desc(), table))
assert "min" in asc_on_join.lower() and asc_on_join.endswith("ASC")
assert "min" in desc_on_join.lower() and desc_on_join.endswith("DESC")
def test_to_one_join_does_not_take_the_fan_out_path(self):
"""A to-one join keeps the single-query path."""
from fastapi_toolsets.crud.factory import _fans_out
assert _fans_out([User.role], None) is False
assert _fans_out([Post.tags], None) is True
assert _fans_out(None, [Post.tags]) is True
class TestSearchEnumColumn:
"""Searching an enum column must reach the database, not just build SQL."""
@pytest.mark.anyio
async def test_search_int_backed_enum(self, db_session: AsyncSession):
"""Enum(int, Enum) stores names, so the cast makes 'PEND' match PENDING."""
await OrderCrud.create(
db_session, OrderCreate(name="a", status=OrderStatus.PENDING)
)
await OrderCrud.create(
db_session, OrderCreate(name="b", status=OrderStatus.SHIPPED)
)
result = await OrderCrud.offset_paginate(
db_session,
search="PEND",
search_fields=[Order.status],
schema=OrderRead,
)
assert result.pagination.total_count == 1
assert result.data[0].status is OrderStatus.PENDING
@pytest.mark.anyio
async def test_search_str_backed_enum(self, db_session: AsyncSession):
"""Same for Enum(str, Enum) — still a native DB enum, still needs the cast."""
await OrderCrud.create(
db_session,
OrderCreate(name="a", status=OrderStatus.PENDING, color=Color.BLUE),
)
await OrderCrud.create(
db_session,
OrderCreate(name="b", status=OrderStatus.PENDING, color=Color.RED),
)
result = await OrderCrud.offset_paginate(
db_session,
search="BLU",
search_fields=[Order.color],
schema=OrderRead,
)
assert result.pagination.total_count == 1
assert result.data[0].color is Color.BLUE
@pytest.mark.anyio
async def test_search_mixed_enum_and_string_columns(self, db_session: AsyncSession):
"""An enum column alongside a plain String column (the get_searchable_fields shape)."""
await OrderCrud.create(
db_session, OrderCreate(name="widget", status=OrderStatus.SHIPPED)
)
result = await OrderCrud.offset_paginate(
db_session,
search="widget",
search_fields=[Order.name, Order.status, Order.color],
schema=OrderRead,
)
assert result.pagination.total_count == 1
class TestSearchConfig: class TestSearchConfig:
"""Tests for SearchConfig options.""" """Tests for SearchConfig options."""
@@ -2249,6 +2543,16 @@ class TestOrderParamsViaConsolidated:
assert len(result.data) == 2 assert len(result.data) == 2
def _fully_declared_user_crud():
"""A CRUD class declaring all three field sets, as a real app would."""
return CrudFactory(
User,
searchable_fields=[User.username],
facet_fields=[User.email],
order_fields=[User.username],
)
class TestOffsetPaginateParamsSchema: class TestOffsetPaginateParamsSchema:
"""Tests for AsyncCrud.offset_paginate_params().""" """Tests for AsyncCrud.offset_paginate_params()."""
@@ -2261,7 +2565,7 @@ class TestOffsetPaginateParamsSchema:
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model.""" """Dependency function is named after the model."""
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False) 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): def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page.""" """default_page_size is used as the default for items_per_page."""
@@ -2318,6 +2622,9 @@ class TestOffsetPaginateParamsSchema:
"items_per_page": 10, "items_per_page": 10,
"include_total": False, "include_total": False,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio
@@ -2361,6 +2668,42 @@ class TestOffsetPaginateParamsSchema:
assert "search" not in param_names assert "search" not in param_names
assert "search_column" not in param_names assert "search_column" not in param_names
@pytest.mark.anyio
@pytest.mark.parametrize(
"kwargs",
[
{"search": False, "filter": False, "order": False},
{"search_fields": [], "facet_fields": [], "order_fields": []},
],
ids=["flags", "empty-overrides"],
)
async def test_disabled_features_clear_response_metadata(
self, db_session: AsyncSession, kwargs
):
"""Disabling a feature on one endpoint also drops it from the response."""
await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io"))
Crud = _fully_declared_user_crud()
dep = Crud.offset_paginate_params(**kwargs)
params = await dep(page=1, items_per_page=10)
result = await Crud.offset_paginate(db_session, **params, schema=UserRead)
assert result.search_columns is None
assert result.order_columns is None
assert result.filter_attributes is None
@pytest.mark.anyio
async def test_enabled_features_keep_response_metadata(
self, db_session: AsyncSession
):
"""The declared class defaults still reach the response when left enabled."""
await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io"))
Crud = _fully_declared_user_crud()
dep = Crud.offset_paginate_params()
params = await dep(page=1, items_per_page=10)
result = await Crud.offset_paginate(db_session, **params, schema=UserRead)
assert result.search_columns == ["id", "username"]
assert result.order_columns == ["username"]
assert result.filter_attributes == {"email": ["b@x.io"]}
def test_filter_enabled_but_no_facet_fields(self): def test_filter_enabled_but_no_facet_fields(self):
"""filter=True with no facet_fields silently skips filter params.""" """filter=True with no facet_fields silently skips filter params."""
dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False) dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False)
@@ -2391,7 +2734,7 @@ class TestCursorPaginateParamsSchema:
dep = RoleCursorCrud.cursor_paginate_params( dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False 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): def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page.""" """default_page_size is used as the default for items_per_page."""
@@ -2433,6 +2776,9 @@ class TestCursorPaginateParamsSchema:
"cursor": None, "cursor": None,
"items_per_page": 5, "items_per_page": 5,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio
@@ -2461,7 +2807,7 @@ class TestPaginateParamsSchema:
def test_dependency_name_includes_model_name(self): def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model.""" """Dependency function is named after the model."""
dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False) 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): def test_default_pagination_type(self):
"""default_pagination_type is reflected in pagination_type default.""" """default_pagination_type is reflected in pagination_type default."""
@@ -2543,6 +2889,9 @@ class TestPaginateParamsSchema:
"items_per_page": 10, "items_per_page": 10,
"include_total": True, "include_total": True,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio
+128 -9
View File
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, Security
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pydantic import PostgresDsn from pydantic import PostgresDsn
@@ -287,23 +287,37 @@ class TestDatabaseDependency:
break break
@pytest.mark.anyio @pytest.mark.anyio
async def test_skips_commit_when_middleware_installed(self, engine, session_maker): async def test_second_resolution_borrows_session(self, engine):
"""With ``install()``, the dependency must NOT commit — the middleware owns it. """A second ``Depends(db)`` in one request reuses the stashed session."""
db = Database(engine=engine)
request = _make_request()
Here no middleware actually runs (we call the dependency directly), so the owner_gen = db(request)
open transaction is rolled back on session close and nothing persists. owner = await anext(owner_gen)
""" borrower_gen = db(request)
assert await anext(borrower_gen) is owner
with pytest.raises(StopAsyncIteration): # teardown runs borrower-first
await anext(borrower_gen)
assert owner.in_transaction() # the borrower must not close what it borrowed
with pytest.raises(StopAsyncIteration):
await anext(owner_gen)
@pytest.mark.anyio
async def test_commits_when_middleware_did_not_run(self, engine, session_maker):
"""``install()`` is per-``Database``, but the commit is per-request."""
db = Database(engine=engine) db = Database(engine=engine)
db.install(FastAPI()) db.install(FastAPI())
async for session in db(_make_request()): async for session in db(_make_request()):
role = Role(name="mw_owns_commit") role = Role(name="mw_never_ran")
session.add(role) session.add(role)
await session.flush() await session.flush()
async with session_maker() as verify: async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "mw_owns_commit"]) result = await RoleCrud.first(verify, [Role.name == "mw_never_ran"])
assert result is None assert result is not None
class TestDatabaseSession: class TestDatabaseSession:
@@ -1523,6 +1537,55 @@ def _build_app(db: Database) -> FastAPI:
await session.commit() await session.commit()
return {"id": str(role.id), "name": role.name} return {"id": str(role.id), "name": role.name}
async def _scoped_writer(
body: RoleCreate, session: AsyncSession = Security(db, scopes=["roles:write"])
) -> int:
# Security scopes give this a different dependency cache key than the
# endpoint's plain ``Depends(db)``. Without borrowing it opens a second
# session, and whichever one the middleware does not hold is discarded.
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_sub"))
return id(session)
@app.post("/roles-two-cache-keys")
async def create_via_two_cache_keys(
body: RoleCreate,
sub_session_id: int = Depends(_scoped_writer),
session: AsyncSession = Depends(db),
) -> dict:
await RoleCrud.create(session, body)
return {"same_session": sub_session_id == id(session)}
async def _fn_writer(
body: RoleCreate, session: AsyncSession = Depends(db, scope="function")
) -> None:
# ``scope="function"`` unwinds before the response is sent, taking the
# session with it — so the commit cannot be left to the middleware.
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_fn"))
@app.post("/roles-function-scope")
async def create_with_function_scope(
body: RoleCreate,
boom: bool = False,
_: None = Depends(_fn_writer),
session: AsyncSession = Depends(db),
) -> dict:
await RoleCrud.create(session, body)
if boom:
raise RuntimeError("boom after write")
return {"ok": True}
@app.post("/roles-function-scope-borrower")
async def function_scope_borrows(
body: RoleCreate,
session: AsyncSession = Depends(db),
_: None = Depends(_fn_writer),
) -> dict:
# Flipped order: the request-scoped dependency owns the session and the
# function-scoped one borrows it. The borrower unwinds early but must not
# commit or close — the commit still belongs to the middleware.
await RoleCrud.create(session, body)
return {"ok": True}
@app.get("/roles-stream/{name}") @app.get("/roles-stream/{name}")
async def stream_role( async def stream_role(
name: str, session: AsyncSession = Depends(db) name: str, session: AsyncSession = Depends(db)
@@ -1619,6 +1682,62 @@ class TestCommitIntegration:
# The write made before the stream began is durably committed. # The write made before the stream began is durably committed.
assert await _row_exists(session_maker, "streamed_role") assert await _row_exists(session_maker, "streamed_role")
@pytest.mark.anyio
async def test_two_cache_keys_share_one_session(self, engine, session_maker):
"""Two resolutions of ``Depends(db)`` in one request must share a session."""
app = _build_app(Database(engine=engine))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/roles-two-cache-keys", json={"name": "two_keys"})
assert resp.status_code == 200
assert resp.json()["same_session"] is True
assert await _row_exists(session_maker, "two_keys")
assert await _row_exists(session_maker, "two_keys_sub")
@pytest.mark.anyio
async def test_function_scope_commits_before_response(self, engine, session_maker):
"""``scope="function"`` unwinds before response-start, so the dependency
commits on its way out instead of leaving it to the middleware."""
app = _build_app(Database(engine=engine))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/roles-function-scope", json={"name": "fn_scope"})
assert resp.status_code == 200
assert await _row_exists(session_maker, "fn_scope")
assert await _row_exists(session_maker, "fn_scope_fn")
@pytest.mark.anyio
async def test_function_scope_borrower_leaves_commit_to_middleware(
self, engine, session_maker
):
"""A function-scoped *borrower* unwinds early but owns nothing."""
app = _build_app(Database(engine=engine))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/roles-function-scope-borrower", json={"name": "fn_borrow"}
)
assert resp.status_code == 200
assert await _row_exists(session_maker, "fn_borrow")
assert await _row_exists(session_maker, "fn_borrow_fn")
@pytest.mark.anyio
async def test_function_scope_error_rolls_back(self, engine, session_maker):
"""The early commit must still not fire when the request fails."""
app = _build_app(Database(engine=engine))
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/roles-function-scope?boom=true", json={"name": "fn_ghost"}
)
assert resp.status_code == 500
assert not await _row_exists(session_maker, "fn_ghost")
assert not await _row_exists(session_maker, "fn_ghost_fn")
@pytest.mark.anyio @pytest.mark.anyio
async def test_multi_write_atomicity(self, engine, session_maker): async def test_multi_write_atomicity(self, engine, session_maker):
"""When the 2nd write fails, the 1st must roll back too (one txn).""" """When the 2nd write fails, the 1st must roll back too (one txn)."""
+79 -1
View File
@@ -7,15 +7,18 @@ from typing import Annotated, Any, cast
import pytest import pytest
from fastapi.params import Depends from fastapi.params import Depends
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.dependencies import ( from fastapi_toolsets.dependencies import (
BodyDependency, BodyDependency,
PathDependency, PathDependency,
_unwrap_session_dep, _unwrap_session_dep,
) )
from .conftest import Role, RoleCreate, RoleCrud, User from .conftest import Role, RoleCreate, RoleCrud, User, UserCreate, UserCrud
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]: async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
@@ -275,3 +278,78 @@ class TestBodyDependency:
assert result.id == role.id assert result.id == role.id
assert result.name == "body_annotated_role" assert result.name == "body_annotated_role"
class TestDependencyLoadOptions:
"""Both factories can eager-load relations instead of using a bare CRUD."""
@staticmethod
async def _make_user(db_session):
role = await RoleCrud.create(db_session, RoleCreate(name="load_opts_role"))
user = await UserCrud.create(
db_session,
UserCreate(username="load_opts", email="load@opts", role_id=role.id),
)
db_session.expunge_all()
return user
@pytest.mark.anyio
async def test_bare_crud_leaves_relation_unloaded(self, db_session):
"""Baseline: without options the relation is not loaded (what the ticket reports)."""
user = await self._make_user(db_session)
dep = cast(Any, PathDependency(User, User.id, session_dep=mock_get_db))
result = await dep.dependency(session=db_session, user_id=user.id)
assert "role" in sa_inspect(result).unloaded
@pytest.mark.anyio
async def test_load_options_and_crud_eager_load(self, db_session):
"""Every way of asking for eager loading, on both factories, actually loads."""
user = await self._make_user(db_session)
eager = [selectinload(User.role)]
eager_crud = CrudFactory(User, default_load_options=eager)
deps = {
"path/load_options": PathDependency(
User, User.id, session_dep=mock_get_db, load_options=eager
),
"path/crud": PathDependency(
User, User.id, session_dep=mock_get_db, crud=eager_crud
),
"body/load_options": BodyDependency(
User,
User.id,
session_dep=mock_get_db,
body_field="user_id",
load_options=eager,
),
"body/crud": BodyDependency(
User,
User.id,
session_dep=mock_get_db,
body_field="user_id",
crud=eager_crud,
),
}
for label, dep in deps.items():
# Drop the identity map, or the next fetch reuses the already-loaded
# instance and the assertion passes for the wrong reason.
db_session.expunge_all()
result = await cast(Any, dep).dependency(
session=db_session, user_id=user.id
)
assert "role" not in sa_inspect(result).unloaded, label
assert result.role.name == "load_opts_role", label
def test_crud_bound_to_another_model_is_rejected(self):
"""A crud= for a different model would silently query the wrong table.
``ty`` rejects this statically; the runtime guard covers untyped callers.
"""
with pytest.raises(ValueError, match="bound to Role, not User"):
PathDependency(
User, User.id, session_dep=mock_get_db, crud=cast(Any, RoleCrud)
)
+1 -1
View File
@@ -56,7 +56,7 @@ async def seed(session: AsyncSession):
session.add_all([python, backend]) session.add_all([python, backend])
await session.flush() 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( session.add_all(
[ [
Article( Article(
+2
View File
@@ -250,6 +250,7 @@ class TestDbExceptions:
def test_pool_exhausted_handled_as_503(self): def test_pool_exhausted_handled_as_503(self):
"""init_exceptions_handlers turns PoolExhaustedError into a 503 response.""" """init_exceptions_handlers turns PoolExhaustedError into a 503 response."""
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI() app = FastAPI()
@@ -267,6 +268,7 @@ class TestDbExceptions:
def test_lock_timeout_handled_as_503(self): def test_lock_timeout_handled_as_503(self):
"""init_exceptions_handlers turns LockTimeoutError into a 503 response.""" """init_exceptions_handlers turns LockTimeoutError into a 503 response."""
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI() app = FastAPI()
+1 -1
View File
@@ -74,7 +74,7 @@ class TestCustomEnumContext:
"""Python prohibits extending an Enum that already has members.""" """Python prohibits extending an Enum that already has members."""
with pytest.raises(TypeError): 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" STAGING = "staging"
def test_custom_enum_values_interchangeable_with_context(self): def test_custom_enum_values_interchangeable_with_context(self):
+2 -2
View File
@@ -197,8 +197,8 @@ class TestCliImportGuard:
importlib.import_module("fastapi_toolsets.cli.app") importlib.import_module("fastapi_toolsets.cli.app")
finally: finally:
for key in list(sys.modules): for key in list(sys.modules):
if key.startswith("fastapi_toolsets.cli.app") or key.startswith( if key.startswith(
"fastapi_toolsets.cli.config" ("fastapi_toolsets.cli.app", "fastapi_toolsets.cli.config")
): ):
sys.modules.pop(key, None) sys.modules.pop(key, None)
sys.modules.update(saved) sys.modules.update(saved)
+90 -6
View File
@@ -6,9 +6,16 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from sqlalchemy import String from sqlalchemy import ForeignKey, String, select
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
relationship,
selectinload,
)
import fastapi_toolsets.models.watched as _watched_module import fastapi_toolsets.models.watched as _watched_module
from fastapi_toolsets.models import ( from fastapi_toolsets.models import (
@@ -107,6 +114,27 @@ async def _watched_on_update(obj, event_type, changes):
_test_events.append({"event": "update", "obj_id": obj.id, "changes": changes}) _test_events.append({"event": "update", "obj_id": obj.id, "changes": changes})
class RelTarget(MixinBase, UUIDMixin):
__tablename__ = "mixin_rel_targets"
name: Mapped[str] = mapped_column(String(50))
class RelOwner(MixinBase, UUIDMixin):
"""Watched model with a relationship, to check eager loads survive commit."""
__tablename__ = "mixin_rel_owners"
title: Mapped[str] = mapped_column(String(50))
target_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mixin_rel_targets.id"))
target: Mapped[RelTarget] = relationship()
@listens_for(RelOwner, [ModelEvent.CREATE, ModelEvent.UPDATE])
async def _rel_owner_handler(obj, event_type, changes):
_test_events.append({"event": event_type.value, "obj_id": obj.id})
class WatchAllModel(MixinBase, UUIDMixin): class WatchAllModel(MixinBase, UUIDMixin):
"""Model without __watched_fields__ — watches all mapped fields by default.""" """Model without __watched_fields__ — watches all mapped fields by default."""
@@ -355,6 +383,62 @@ async def mixin_session_maker():
await engine.dispose() await engine.dispose()
class TestEventSessionPreservesEagerLoads:
"""EventSession.commit() must not discard relations an eager load populated."""
async def _seed_eager(self, session):
target = RelTarget(name="t")
session.add(target)
await session.flush()
owner = RelOwner(title="o", target_id=target.id)
session.add(owner)
await session.flush()
loaded = (
await session.execute(
select(RelOwner)
.where(RelOwner.id == owner.id)
.options(selectinload(RelOwner.target))
)
).scalar_one()
assert "target" not in sa_inspect(loaded).unloaded
return loaded
@pytest.mark.anyio
async def test_eager_load_survives_commit(self, mixin_session):
"""expire_on_commit=False: the reload must not expire the relation."""
owner = await self._seed_eager(mixin_session)
await mixin_session.commit()
assert "target" not in sa_inspect(owner).unloaded
assert owner.target.name == "t"
@pytest.mark.anyio
async def test_eager_load_survives_commit_expire_on_commit(
self, mixin_session_expire
):
"""expire_on_commit=True: what was loaded must be recorded before the commit."""
owner = await self._seed_eager(mixin_session_expire)
await mixin_session_expire.commit()
assert "target" not in sa_inspect(owner).unloaded
assert owner.target.name == "t"
@pytest.mark.anyio
async def test_unloaded_relation_stays_unloaded(self, mixin_session):
"""Only what was loaded is restored: the reload must not eager-load extra."""
target = RelTarget(name="t")
mixin_session.add(target)
await mixin_session.flush()
owner = RelOwner(title="o", target_id=target.id)
mixin_session.add(owner)
await mixin_session.commit()
assert "target" in sa_inspect(owner).unloaded
class TestUUIDMixin: class TestUUIDMixin:
@pytest.mark.anyio @pytest.mark.anyio
async def test_uuid_generated_by_db(self, mixin_session): async def test_uuid_generated_by_db(self, mixin_session):
@@ -1013,10 +1097,10 @@ class TestEventCallbacks:
real_batch_reload = _watched_module._batch_reload real_batch_reload = _watched_module._batch_reload
async def racing_batch_reload(session, model, pk_tuples): async def racing_batch_reload(session, model, objs, preloaded):
if any(pk[0] == doomed_id for pk in pk_tuples): if any(getattr(o, "id", None) == doomed_id for o in objs):
await kill_doomed_row_once() await kill_doomed_row_once()
return await real_batch_reload(session, model, pk_tuples) return await real_batch_reload(session, model, objs, preloaded)
# Patch the batched reload EventSession.commit() uses to pick up # Patch the batched reload EventSession.commit() uses to pick up
# server defaults, so this test still exercises the race. # server defaults, so this test still exercises the race.
@@ -1039,7 +1123,7 @@ class TestEventCallbacks:
obj = WatchedModel(status="active", other="x") obj = WatchedModel(status="active", other="x")
mixin_session.add(obj) mixin_session.add(obj)
async def failing_batch_reload(session, model, pk_tuples): async def failing_batch_reload(session, model, objs, preloaded):
raise RuntimeError("reload failed") raise RuntimeError("reload failed")
with ( with (
+5 -5
View File
@@ -13,6 +13,11 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.db import transaction from fastapi_toolsets.db import transaction
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy 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 ( from fastapi_toolsets.pytest import (
create_async_client, create_async_client,
create_db_session, create_db_session,
@@ -20,11 +25,6 @@ from fastapi_toolsets.pytest import (
register_fixtures, register_fixtures,
worker_database_url, worker_database_url,
) )
from fastapi_toolsets.fixtures.utils import (
_get_primary_key,
_relationship_load_options,
_reload_with_relationships,
)
from fastapi_toolsets.pytest.utils import _get_xdist_worker from fastapi_toolsets.pytest.utils import _get_xdist_worker
from .conftest import ( from .conftest import (
+2 -2
View File
@@ -5,11 +5,11 @@ from pydantic import ValidationError
from fastapi_toolsets.schemas import ( from fastapi_toolsets.schemas import (
ApiError, ApiError,
CursorPagination,
CursorPaginatedResponse, CursorPaginatedResponse,
CursorPagination,
ErrorResponse, ErrorResponse,
OffsetPagination,
OffsetPaginatedResponse, OffsetPaginatedResponse,
OffsetPagination,
PaginatedResponse, PaginatedResponse,
PaginationType, PaginationType,
Response, Response,
Generated
+161 -161
View File
@@ -192,86 +192,86 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.15.0" version = "7.15.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, { 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/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, { 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/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, { 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/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, { 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/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, { 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/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, { 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/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, { 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/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, { 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/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, { 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/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, { 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/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, { 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/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, { 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/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, { 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/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, { 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/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, { 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/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, { 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/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, { 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/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, { 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/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, { 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/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, { 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/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, { 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/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, { 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/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, { 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/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, { 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/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, { 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/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, { 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/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, { 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/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, { 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/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, { 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/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, { 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/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, { 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/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, { 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/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, { 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/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, { 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/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, { 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/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, { 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/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, { 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/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, { 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/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, { 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/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, { 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/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, { 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/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, { 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/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, { 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/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, { 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/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, { 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/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, { 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/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, { 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/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, { 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/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, { 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/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, { 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/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, { 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/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, { 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/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, { 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/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, { 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/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, { 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/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, { 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/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, { 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/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, { 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/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, { 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/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, { 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/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, { 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/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, { 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/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, { 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/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, { 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/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, { 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/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, { 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/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, { 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/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, { 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/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, { 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/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, { 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/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, { 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/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, { 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/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, { 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/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, { 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/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, { 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/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, { 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] [package.optional-dependencies]
@@ -299,7 +299,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.139.0" version = "0.141.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -308,14 +308,14 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
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" } sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [ wheels = [
{ 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" }, { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
] ]
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.0.0" version = "5.1.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -814,35 +814,35 @@ wheels = [
[[package]] [[package]]
name = "prek" name = "prek"
version = "0.4.9" version = "0.4.14"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/cf/51/135dc6ba2c021ce32b40700c8c337db72d802893e15291f4b3056076582f/prek-0.4.14.tar.gz", hash = "sha256:f6d0952e31ffd6e508660749dd51b8d8de96e955ed12c40e411f3224f502fed2", size = 537668, upload-time = "2026-08-17T04:27:55.031Z" }
wheels = [ wheels = [
{ 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/9d/75/727724174d419cab6e11e0c22c3fbcdd083312b0a0202dfa6befdb7f1bcb/prek-0.4.14-py3-none-linux_armv6l.whl", hash = "sha256:cf7fe2e07c99948ca3326fbd4254054cf4caa71a69cf9032be09c3938544e1e3", size = 5878406, upload-time = "2026-08-17T04:27:30.135Z" },
{ 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/34/b6/82dfb41347342b4c53c1ddd27cfa81d3e1cf4ad0d5d76493c51e8da58dc9/prek-0.4.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6c8043aa5555c2ada561f4c69429e5117f370ad92f7d708340613b0965e725bd", size = 6216669, upload-time = "2026-08-17T04:27:31.795Z" },
{ 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/5b/68/ade0ba8b8c0044a3f7ca1a5cd7c97bc10656398c67466b04235f0f0b7418/prek-0.4.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:355570f0d8366a56817e55f44edf736ffc9ff2323894e21cd739ab519d343541", size = 5728085, upload-time = "2026-08-17T04:27:33.322Z" },
{ 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/a0/cc/8ddb67fb000d1ad4c95288e6c7e27989d3c172cd88893e3a6f9cdd7199fb/prek-0.4.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:334f48a2b19e63c5ab741e601cb6088caaa11d1b75146900dc298b055452d551", size = 6043650, upload-time = "2026-08-17T04:27:34.699Z" },
{ 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/5b/49/2780489798147fa1e81fa017af384a40229802cf36680cf72c74478d143c/prek-0.4.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5fbb17270df2dcb3c1aa6edfaa68f850d7968862413e58b1572e41c981f644f9", size = 5786094, upload-time = "2026-08-17T04:27:36.282Z" },
{ 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/6a/2a/27a4cdfa767b46663eb32a2b5f78ebaaeefc6e5caa87a21587216a504e31/prek-0.4.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ec46be0b1b45943a0746fdf69feeede274b0230bad32b11e2489bb600846081", size = 6239874, upload-time = "2026-08-17T04:27:37.984Z" },
{ 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/e0/ca/5ae58d95bc9dbad75ea13aea83fe0acfaca37d027c6bd7f9f1d1d97f3666/prek-0.4.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a21eea6ee996dbba351c1af8c6bd1959a61c37949757c0b97aef3edde82c126a", size = 6968009, upload-time = "2026-08-17T04:27:39.48Z" },
{ 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/bb/1c/c6a7800406f987559fc8e7c2cfd257a5a68096e806e953f143b488e00e4f/prek-0.4.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34169cb1c8dfbe4b9cb7af164f5c1c3dff68929c61ca1e1de28d378b32720836", size = 6443370, upload-time = "2026-08-17T04:27:41.181Z" },
{ 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/92/38/4bf84223216ce2d31b500691d1644a9f35d6e468cb8ecb9a7dfbc66bc82c/prek-0.4.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e39772ebf579f957fdbbc66ef6a7f7433bf603128cd60d5c804c70caac5f69c0", size = 6056449, upload-time = "2026-08-17T04:27:42.722Z" },
{ 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/75/0a/f1980039ab7bf8342c4aa4da2a4cafe40e6a840f456d5bbf86ba024fcdca/prek-0.4.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a79e5940354a789e1311172a960c5daa75e3f643a47ee2be85b7d2ca1d7d980a", size = 5839941, upload-time = "2026-08-17T04:27:44.306Z" },
{ 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/f1/e2/67f067dcb912e157bc7f4cd2aeb422a0de65e7744c675d7f439676f6c832/prek-0.4.14-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60b6539c1b28804807849173a6b49d467f6f36ca98bbb4536f5e34114d79942a", size = 5762723, upload-time = "2026-08-17T04:27:45.737Z" },
{ 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/84/39/6669fff5c3336a2b79cf853c86b95547cf9be720a2ee7e4ae5eaa6cb46a1/prek-0.4.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24cfabd9e5b8c5546ecaef562841a41a255a9dec1b7ae7761ce5a7a024ab9dd9", size = 6090040, upload-time = "2026-08-17T04:27:47.185Z" },
{ 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/2a/23/c23210be4c89795a854e76146c2465aaffc3d85ea83df61c1d4d5bb41bca/prek-0.4.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1f08d368da45439f949885bb0b637bc7dfe6910140d5285fa8bae058697ddd06", size = 6570273, upload-time = "2026-08-17T04:27:48.673Z" },
{ 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/00/9c/4f10728ed36295347e84f01bb3e4c9078c9e337edc66215f5e2066b30551/prek-0.4.14-py3-none-win32.whl", hash = "sha256:5bfb30808ce2099c67d2a2d4cd68dc031e3fec1eeb61d73ef51a8f7c04d021cb", size = 5586715, upload-time = "2026-08-17T04:27:50.471Z" },
{ 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/4f/a7/d080a157d4e92927f7618d62d6a23979ef64a10c76a040cb8a8a194c50d7/prek-0.4.14-py3-none-win_amd64.whl", hash = "sha256:29364012d5704475d1092eb8a96ea30b163279096ad5e0c80a620fffa79bc639", size = 5969946, upload-time = "2026-08-17T04:27:51.971Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/37/e1/6fc64bb82e7270f61707e00b6d3154a4592ae0b2d3bd308173aa7aabe0a1/prek-0.4.14-py3-none-win_arm64.whl", hash = "sha256:ff588c02e10c8d05150763607671a22d5585c0ff7036c884d3489c2726eb215c", size = 5729906, upload-time = "2026-08-17T04:27:53.52Z" },
] ]
[[package]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.25.0" version = "0.26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
] ]
[[package]] [[package]]
@@ -973,15 +973,15 @@ wheels = [
[[package]] [[package]]
name = "pymdown-extensions" name = "pymdown-extensions"
version = "10.21.3" version = "11.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown" }, { name = "markdown" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
] ]
[[package]] [[package]]
@@ -1143,27 +1143,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.21" version = "0.16.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
{ url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
{ url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
{ url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
{ url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
{ url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
] ]
[[package]] [[package]]
@@ -1307,32 +1307,32 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.58" version = "0.0.75"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" }, { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" }, { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" },
{ url = "https://files.pythonhosted.org/packages/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" }, { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" },
{ url = "https://files.pythonhosted.org/packages/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" }, { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" },
{ url = "https://files.pythonhosted.org/packages/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" }, { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" },
{ url = "https://files.pythonhosted.org/packages/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" }, { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" },
{ url = "https://files.pythonhosted.org/packages/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" }, { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" },
{ url = "https://files.pythonhosted.org/packages/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" }, { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" },
{ url = "https://files.pythonhosted.org/packages/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" }, { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" }, { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" },
{ url = "https://files.pythonhosted.org/packages/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" },
{ url = "https://files.pythonhosted.org/packages/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" }, { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" },
{ url = "https://files.pythonhosted.org/packages/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" }, { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" },
{ url = "https://files.pythonhosted.org/packages/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" }, { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" }, { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" },
{ url = "https://files.pythonhosted.org/packages/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" }, { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" },
{ url = "https://files.pythonhosted.org/packages/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" }, { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" },
] ]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.26.8" version = "0.27.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -1340,9 +1340,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "shellingham" }, { name = "shellingham" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, { 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]] [[package]]
@@ -1404,7 +1404,7 @@ wheels = [
[[package]] [[package]]
name = "zensical" name = "zensical"
version = "0.0.50" version = "0.0.51"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -1416,18 +1416,18 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "tomli" }, { name = "tomli" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/14/79/f7959b13c766a831f1248779bb718555f70f40c5b8e68db7de1de9936662/zensical-0.0.50.tar.gz", hash = "sha256:7040e52ebe5e6a275e4edeb351bf2bc314d007f3fb5750f178a38d840723e69c", size = 3979246, upload-time = "2026-07-09T13:52:11.908Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/d2/e126d56642a5fd437eeb5a067b7c0d1f766c08e22d6a708bb923986f1d44/zensical-0.0.50-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:26dda9dbacab84db2743726f83202798e1893fbfddecb6a845812d7a331043ad", size = 12817706, upload-time = "2026-07-09T13:51:29.624Z" }, { 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/bd/9b/e1a22fb4ea8fc3e8377d7b394ee06cf0349e7f9a64ecb4c9873dee79b102/zensical-0.0.50-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:376887def6470c57509b48519870f74e2b987e30509cf5c819fcf372771f9403", size = 12691061, upload-time = "2026-07-09T13:51:33.194Z" }, { 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/38/f5/16c087635680efb39e1d7b7aa3c2cca548305aa29ad51dee04f37b32b65f/zensical-0.0.50-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bbf3034a2cb1a9d2a72a5ee7047688717c93da0d8a90d25dd871db8d5f33a6d", size = 13129505, upload-time = "2026-07-09T13:51:36.383Z" }, { 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/65/30/50d02402e53aa01a5c5c116a3522c7bb18d6ae4da964d8b236173b6fa086/zensical-0.0.50-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26fe4167663d544ca385ef420a49d3b738c7606a79d7a62a6bedcbf74cd383e7", size = 13055300, upload-time = "2026-07-09T13:51:39.748Z" }, { 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/cf/0f/45f4f3e56a6483ea9f88478b20d6b434718e7c46579239589202e5135ec1/zensical-0.0.50-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8813d1f8895b19520d4a982d6bfcfd06372d73d96e6a63f0d2cf8f3b6036d5a1", size = 13445447, upload-time = "2026-07-09T13:51:43.106Z" }, { 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/33/9b/1f5065c664afde6a63e0ada8c14eae75f9e6960df597a5ca28c07340f938/zensical-0.0.50-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a64f957f94f7d8e8847a7f1e8205509ba4b188c93c157c5e7be49bcb8556a127", size = 13098775, upload-time = "2026-07-09T13:51:46.437Z" }, { 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/46/65/1e42e18d8f9a0cb9fcc8b871852810c7fe196237b79aaa831e863325091e/zensical-0.0.50-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:781d333bcb42c6a6b92360e05eadc944e2674794e1a95537971de1bf64cb9f89", size = 13305922, upload-time = "2026-07-09T13:51:51.107Z" }, { 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/50/36/9cace194ba13aef3bdde8ca2d327f3a05cdf5c456e94f6d896852e2cbd38/zensical-0.0.50-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5d0f86eec76c23efb04566cd444bf025845ac8a02e75c8a6b4af13a39a2a9955", size = 13325488, upload-time = "2026-07-09T13:51:54.883Z" }, { 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/b1/80/2891a9316e486794dae43151561b49f3313499cfb2c95c7886abad7bcf2d/zensical-0.0.50-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:392a7299a3c4d1ac7ff288bbed181d77a9fd23e6614180476e01630aaee0d67e", size = 13496866, upload-time = "2026-07-09T13:51:58.257Z" }, { 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/7e/09/fbd3af58081a0d399fa913ef1f2fd6a007f2f06f7026c8d6a6096e34b996/zensical-0.0.50-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cac3448d8c8a76dfb43e55908b4ca9ad17596aee89a9287a184c1a0ba7dce2a2", size = 13437565, upload-time = "2026-07-09T13:52:01.766Z" }, { 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/d3/79/709311ff202326260ff223fb6e9ddefa3100474c428e3c70a04036cefe0e/zensical-0.0.50-cp310-abi3-win32.whl", hash = "sha256:32b244f96a930f68e049b2e03d50790c120ef701b6277ddd854905a33040c75f", size = 12371966, upload-time = "2026-07-09T13:52:05.207Z" }, { 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/e3/e1/4babb30544d7465c95a6a93abc0680b1c51752411a225c2b7f2cb44e80c7/zensical-0.0.50-cp310-abi3-win_amd64.whl", hash = "sha256:2f054769bf96ae46353de3eca2463ad4db6df1da9fde515c6d7fc54c3608e615", size = 12604832, upload-time = "2026-07-09T13:52:09.013Z" }, { 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" },
] ]