Compare commits

..
Author SHA1 Message Date
dependabot[bot] 089b2a1437 ⬆ Bump anyio from 4.12.1 to 4.14.2
Bumps [anyio](https://github.com/agronholm/anyio) from 4.12.1 to 4.14.2.
- [Release notes](https://github.com/agronholm/anyio/releases)
- [Commits](https://github.com/agronholm/anyio/compare/4.12.1...4.14.2)

---
updated-dependencies:
- dependency-name: anyio
  dependency-version: 4.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 21:36:18 +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
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
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
10 changed files with 752 additions and 179 deletions
+10
View File
@@ -373,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
+30
View File
@@ -62,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)
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.1.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,7 +91,7 @@ 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] [tool.ruff.format]
+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.1.0" __version__ = "5.1.2"
+202 -69
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
if cls.default_load_options:
await session.flush()
pk_values = cls._capture_pk_values(db_model)
if pk_values is not None:
db_model = await cls._reload_with_options_by_pk(session, pk_values)
else:
await session.refresh(db_model) await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
result = cast(ModelType, db_model) result = cast(ModelType, db_model)
if schema: if schema:
return Response(data=schema.model_validate(result)) return Response(data=schema.model_validate(result))
@@ -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)
pk_values: dict[str, Any] | None = None
if cls.default_load_options:
await session.flush()
pk_values = cls._capture_pk_values(db_model)
if pk_values is not None:
db_model = await cls._reload_with_options_by_pk(session, pk_values)
else:
await session.refresh(db_model) await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
if schema: if schema:
return Response(data=schema.model_validate(db_model)) return Response(data=schema.model_validate(db_model))
return db_model return db_model
@@ -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 # One extra row detects whether another page exists in this direction.
q = q.limit(items_per_page + 1) # Under a to-many join that extra row may be a duplicate of one already
result = await session.execute(q) # 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()) 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]
+74 -55
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,36 +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 f"{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])
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
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(
@@ -93,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.
@@ -101,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
@@ -120,28 +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])
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
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)))
+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
+280 -1
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,
) )
@@ -397,6 +402,225 @@ class TestBuildSearchFilters:
assert "CAST" in str(filters[0]) 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: class TestSearchEnumColumn:
"""Searching an enum column must reach the database, not just build SQL.""" """Searching an enum column must reach the database, not just build SQL."""
@@ -2319,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()."""
@@ -2388,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
@@ -2431,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)
@@ -2503,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
@@ -2613,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
+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)
)
Generated
+47 -47
View File
@@ -22,15 +22,15 @@ wheels = [
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.1" version = "4.14.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]] [[package]]
@@ -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.1.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]]
@@ -1307,27 +1307,27 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.64" version = "0.0.75"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } 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/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, { 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/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, { 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/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, { 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/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, { 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/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, { 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/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, { 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/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, { 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/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, { 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/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, { 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/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, { 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/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, { 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/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, { 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/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, { 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/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, { 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/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, { 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/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, { 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/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, { 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]]