Compare commits

..
1 Commits
Author SHA1 Message Date
dependabot[bot] d8d71a0566 ⬆ Bump ty from 0.0.64 to 0.0.74
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.64 to 0.0.74.
- [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.74)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:04:04 +00:00
11 changed files with 184 additions and 778 deletions
-30
View File
@@ -62,36 +62,6 @@ 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.2" version = "5.1.0"
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.13.0"] requires = ["uv_build>=0.10,<0.12.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.2" __version__ = "5.1.0"
+64 -170
View File
@@ -14,25 +14,12 @@ 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 ( from sqlalchemy import Date, DateTime, Float, Integer, Numeric, Uuid, and_, func, select
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
@@ -141,32 +128,6 @@ 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.
@@ -202,64 +163,6 @@ 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
@@ -270,17 +173,30 @@ class AsyncCrud(Generic[ModelType]):
return cls.default_load_options return cls.default_load_options
@classmethod @classmethod
async def _reload_with_options( def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
cls: type[Self], session: AsyncSession, instance: DeclarativeBase """Capture PK values off instance — call before commit expires attributes."""
return {
cast(str, col.key): getattr(instance, cast(str, col.key))
for col in cls.model.__mapper__.primary_key
}
@classmethod
async def _reload_with_options_by_pk(
cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
) -> ModelType: ) -> ModelType:
"""Re-query instance by PK with default_load_options applied.""" """Re-query by previously captured PK values, with default_load_options applied."""
mapper = cls.model.__mapper__ # Only called when cls.default_load_options is set (see call sites).
pk_filters = [ pk_filters = [
getattr(cls.model, cast(str, col.key)) getattr(cls.model, key) == value for key, value in pk_values.items()
== getattr(instance, cast(str, col.key))
for col in mapper.primary_key
] ]
return await cls.get(session, filters=pk_filters) q = select(cls.model).where(and_(*pk_filters))
q = q.execution_options(populate_existing=True)
q = q.options(*cast(Sequence[ExecutableOption], cls.default_load_options))
result = await session.execute(q)
item = result.unique().scalar_one_or_none()
if item is None: # pragma: no cover — row was just flushed in this transaction
raise NotFoundError()
return cast(ModelType, item)
@classmethod @classmethod
async def _resolve_m2m( async def _resolve_m2m(
@@ -834,9 +750,14 @@ 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)
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)
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))
@@ -1102,21 +1023,9 @@ 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:
@@ -1213,9 +1122,15 @@ 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
@@ -1459,31 +1374,17 @@ 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)
order_clauses: list[Any] = [] if order_by is None else [order_by] if order_by is not None:
q = q.order_by(*order_clauses) q = q.order_by(order_by)
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)
count_q = select(func.count(func.distinct(cls._pk_attrs()[0]))) pk_col = cls.model.__mapper__.primary_key[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
@@ -1496,11 +1397,16 @@ 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 = count_result.scalar_one() total_count: int = count_result.scalar_one()
has_more = page * items_per_page < total_count has_more = page * items_per_page < total_count
else: else:
# One extra row was fetched to detect a next page without COUNT # Fetch one extra row to detect if a next page exists without COUNT
has_more = fetched > items_per_page q = q.offset(offset).limit(items_per_page + 1)
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]
@@ -1637,30 +1543,18 @@ 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
cursor_clause = ( if direction is _CursorDirection.PREV:
cursor_column.desc() q = q.order_by(cursor_column.desc())
if direction is _CursorDirection.PREV
else cursor_column
)
order_clauses: list[Any] = [cursor_clause]
if order_by is not None:
order_clauses.append(order_by)
q = q.order_by(*order_clauses)
# 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: else:
result = await session.execute(q.limit(items_per_page + 1)) q = q.order_by(cursor_column)
if order_by is not None:
q = q.order_by(order_by)
# 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()) 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]
+55 -74
View File
@@ -2,15 +2,14 @@
import inspect import inspect
import typing import typing
from collections.abc import Callable, Sequence from collections.abc import Callable
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 AsyncCrud, CrudFactory from .crud import CrudFactory
from .types import ModelType, SessionDependency from .types import ModelType, SessionDependency
__all__ = ["BodyDependency", "PathDependency"] __all__ = ["BodyDependency", "PathDependency"]
@@ -25,59 +24,12 @@ 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.
@@ -86,10 +38,6 @@ 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
@@ -107,14 +55,36 @@ def PathDependency(
): ... ): ...
``` ```
""" """
return _fetch_dependency( session_callable = _unwrap_session_dep(session_dep)
model, crud = CrudFactory(model)
field, name = (
session_dep=session_dep, param_name
param_name=param_name or f"{model.__name__.lower()}_{field.key}", if param_name is not None
crud=crud, else f"{model.__name__.lower()}_{field.key}"
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(
@@ -123,8 +93,6 @@ 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.
@@ -133,10 +101,6 @@ 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
@@ -156,11 +120,28 @@ def BodyDependency(
): ... ): ...
``` ```
""" """
return _fetch_dependency( session_callable = _unwrap_session_dep(session_dep)
model, crud = CrudFactory(model)
field, python_type = field.type.python_type
session_dep=session_dep,
param_name=body_field, async def dependency(
crud=crud, session: AsyncSession = Depends(session_callable), **kwargs: Any
load_options=load_options, ) -> ModelType:
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)))
+7 -36
View File
@@ -8,7 +8,6 @@ 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
@@ -190,44 +189,17 @@ 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, session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
model: type,
objs: list[Any],
preloaded: dict[int, set[str]],
) -> None: ) -> None:
"""Re-populate all rows of *model* in one round trip.""" """Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
pk_cols = sa_inspect(model, raiseerr=True).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)
@@ -235,7 +207,6 @@ class EventSession(AsyncSession):
"""AsyncSession subclass that dispatches lifecycle callbacks after commit.""" """AsyncSession subclass that dispatches lifecycle callbacks after commit."""
async def commit(self) -> None: 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, [])
@@ -278,25 +249,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]]]] = []
objs_by_type: dict[type, list[Any]] = {} pk_by_type: dict[type, list[tuple[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)
objs_by_type.setdefault(type(obj), []).append(obj) pk_by_type.setdefault(type(obj), []).append(state.key[1])
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))
objs_by_type.setdefault(type(obj), []).append(obj) pk_by_type.setdefault(type(obj), []).append(state.key[1])
for model, objs in objs_by_type.items(): for model, pk_tuples in pk_by_type.items():
try: try:
await _batch_reload(self, model, objs, preloaded) await _batch_reload(self, model, pk_tuples)
except Exception as exc: except Exception as exc:
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc) _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
-24
View File
@@ -466,30 +466,6 @@ 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
+1 -225
View File
@@ -5,7 +5,6 @@ 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 (
@@ -17,7 +16,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, PydanticBase from fastapi_toolsets.schemas import OffsetPagination, PaginationType
from .conftest import ( from .conftest import (
Article, Article,
@@ -30,19 +29,15 @@ 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,
) )
@@ -402,225 +397,6 @@ 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."""
+1 -79
View File
@@ -7,18 +7,15 @@ 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, UserCreate, UserCrud from .conftest import Role, RoleCreate, RoleCrud, User
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]: async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
@@ -278,78 +275,3 @@ 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)
)
+6 -90
View File
@@ -6,16 +6,9 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from sqlalchemy import ForeignKey, String, select from sqlalchemy import String
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 ( from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
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 (
@@ -114,27 +107,6 @@ 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."""
@@ -383,62 +355,6 @@ 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):
@@ -1097,10 +1013,10 @@ class TestEventCallbacks:
real_batch_reload = _watched_module._batch_reload real_batch_reload = _watched_module._batch_reload
async def racing_batch_reload(session, model, objs, preloaded): async def racing_batch_reload(session, model, pk_tuples):
if any(getattr(o, "id", None) == doomed_id for o in objs): if any(pk[0] == doomed_id for pk in pk_tuples):
await kill_doomed_row_once() await kill_doomed_row_once()
return await real_batch_reload(session, model, objs, preloaded) return await real_batch_reload(session, model, pk_tuples)
# 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.
@@ -1123,7 +1039,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, objs, preloaded): async def failing_batch_reload(session, model, pk_tuples):
raise RuntimeError("reload failed") raise RuntimeError("reload failed")
with ( with (
Generated
+44 -44
View File
@@ -299,7 +299,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.141.1" version = "0.139.0"
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/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
wheels = [ wheels = [
{ 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" }, { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
] ]
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.1.2" version = "5.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -814,35 +814,35 @@ wheels = [
[[package]] [[package]]
name = "prek" name = "prek"
version = "0.4.14" version = "0.4.9"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/ea/df/94ed29398576e03494c5aacda8bfed9536edf348bed29cd09f382f2b9b23/prek-0.4.9.tar.gz", hash = "sha256:f8b86441484a5756f3fdb6f3b201d3d448f8845902a84653d78cbf6f875c424f", size = 492711, upload-time = "2026-07-11T11:04:04.332Z" }
wheels = [ wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" }, { url = "https://files.pythonhosted.org/packages/02/4a/57d04de49f591088901794cc22f36563a102681e3512ae17ee6085cd2f30/prek-0.4.9-py3-none-win_arm64.whl", hash = "sha256:7eab3900d9ea614c8ea0d0d55a8b708f0c88e43c966dc8b13a4e36c1e398dd16", size = 5540477, upload-time = "2026-07-11T11:04:02.815Z" },
] ]
[[package]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.26.0" version = "0.25.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" }
wheels = [ wheels = [
{ 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" }, { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" },
] ]
[[package]] [[package]]
@@ -1307,27 +1307,27 @@ wheels = [
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.75" version = "0.0.74"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" }
wheels = [ wheels = [
{ 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/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" },
{ 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/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" },
{ 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/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" },
{ 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/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" },
{ 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/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" },
{ 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/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" },
{ 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/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" },
{ 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/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" },
{ 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/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" },
{ 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/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" },
{ 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/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" },
{ 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/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" },
{ 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/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" },
{ 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/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" },
{ 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/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" },
{ 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/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" },
] ]
[[package]] [[package]]