mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 11:19:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09b911277 | ||
|
|
f7ecb76e8d
|
||
|
|
ef269833b9
|
||
|
|
610b3e1ab4
|
||
|
|
312723d66b
|
||
|
|
3d426ac567 | ||
|
|
0cc189117d
|
||
|
|
a7b78832fd | ||
|
|
56c19971e2 | ||
|
|
7cd0ca2936 | ||
|
|
713e9a40c8 | ||
|
|
1432a2cc3a | ||
|
|
375d349fc4 | ||
|
|
7506fa3093 | ||
|
|
943115562b
|
||
|
|
716e4f7db7 | ||
|
|
50529b1081
|
||
|
|
8aea7247c9 | ||
|
|
f1e50a947a | ||
|
|
7c73488f7d | ||
|
|
518e22e921 |
@@ -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])
|
||||
```
|
||||
|
||||
`search_fields`, `facet_fields` and `order_fields` follow the same override rule
|
||||
everywhere they are accepted — `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 — 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):
|
||||
|
||||
```python
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "5.1.0"
|
||||
version = "5.1.2"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -91,7 +91,7 @@ docs-src = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10,<0.12.0"]
|
||||
requires = ["uv_build>=0.10,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.ruff.format]
|
||||
|
||||
@@ -24,4 +24,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "5.1.0"
|
||||
__version__ = "5.1.2"
|
||||
|
||||
@@ -14,12 +14,25 @@ from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overl
|
||||
|
||||
from fastapi import Query
|
||||
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.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql import operators
|
||||
from sqlalchemy.sql.base import ExecutableOption
|
||||
from sqlalchemy.sql.elements import UnaryExpression
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import transaction
|
||||
@@ -128,6 +141,32 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
||||
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]):
|
||||
"""Generic async CRUD operations for SQLAlchemy models.
|
||||
|
||||
@@ -163,6 +202,64 @@ class AsyncCrud(Generic[ModelType]):
|
||||
):
|
||||
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
|
||||
def _resolve_load_options(
|
||||
cls, load_options: Sequence[ExecutableOption] | None
|
||||
@@ -173,30 +270,17 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return cls.default_load_options
|
||||
|
||||
@classmethod
|
||||
def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
|
||||
"""Capture PK values off instance — call before commit expires attributes."""
|
||||
return {
|
||||
cast(str, col.key): getattr(instance, cast(str, col.key))
|
||||
for col in cls.model.__mapper__.primary_key
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def _reload_with_options_by_pk(
|
||||
cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
|
||||
async def _reload_with_options(
|
||||
cls: type[Self], session: AsyncSession, instance: DeclarativeBase
|
||||
) -> ModelType:
|
||||
"""Re-query by previously captured PK values, with default_load_options applied."""
|
||||
# Only called when cls.default_load_options is set (see call sites).
|
||||
"""Re-query instance by PK with default_load_options applied."""
|
||||
mapper = cls.model.__mapper__
|
||||
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))
|
||||
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)
|
||||
return await cls.get(session, filters=pk_filters)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_m2m(
|
||||
@@ -302,13 +386,29 @@ class AsyncCrud(Generic[ModelType]):
|
||||
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
|
||||
def _resolve_search_columns(
|
||||
cls: type[Self],
|
||||
search_fields: Sequence[SearchFieldType] | None,
|
||||
) -> list[str] | None:
|
||||
"""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:
|
||||
return None
|
||||
return search_field_keys(fields)
|
||||
@@ -319,7 +419,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order_fields: Sequence[OrderFieldType] | None,
|
||||
) -> list[str] | None:
|
||||
"""Return sort column keys, or None if no order fields configured."""
|
||||
fields = order_fields if order_fields is not None else cls.order_fields
|
||||
fields = cls._resolve_order_fields(order_fields)
|
||||
if not fields:
|
||||
return None
|
||||
return sorted(facet_keys(fields))
|
||||
@@ -398,9 +498,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order_field_map: dict[str, OrderFieldType] | None = None
|
||||
order_valid_keys: list[str] | None = None
|
||||
if order:
|
||||
resolved_order = (
|
||||
order_fields if order_fields is not None else cls.order_fields
|
||||
)
|
||||
resolved_order = cls._resolve_order_fields(order_fields)
|
||||
if resolved_order:
|
||||
keys = facet_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]:
|
||||
result: dict[str, Any] = dict(pagination_fixed)
|
||||
result: dict[str, Any] = dict(fixed)
|
||||
for name in pagination_param_names:
|
||||
result[name] = kwargs[name]
|
||||
|
||||
@@ -750,14 +861,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
session.add(db_model)
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
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)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(result))
|
||||
@@ -1023,9 +1129,21 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
q = _apply_for_update(q, with_for_update)
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
|
||||
if 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:
|
||||
q = q.offset(offset)
|
||||
if limit is not None:
|
||||
@@ -1122,15 +1240,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
await session.refresh(db_model)
|
||||
if cls.default_load_options:
|
||||
db_model = await cls._reload_with_options(session, db_model)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(db_model))
|
||||
return db_model
|
||||
@@ -1374,17 +1486,31 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
order_clauses: list[Any] = [] if order_by is None else [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:
|
||||
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)
|
||||
pk_col = cls.model.__mapper__.primary_key[0]
|
||||
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
||||
count_q = select(func.count(func.distinct(cls._pk_attrs()[0])))
|
||||
count_q = count_q.select_from(cls.model)
|
||||
|
||||
# Apply explicit joins to count query
|
||||
@@ -1397,16 +1523,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
count_q = count_q.where(and_(*filters))
|
||||
|
||||
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
|
||||
else:
|
||||
# Fetch one extra row to detect if a next page exists without COUNT
|
||||
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
|
||||
# One extra row was fetched to detect a next page without COUNT
|
||||
has_more = fetched > items_per_page
|
||||
|
||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||
|
||||
@@ -1543,18 +1664,30 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.options(*resolved)
|
||||
|
||||
# Cursor column is always the primary sort; reverse direction for prev traversal
|
||||
if direction is _CursorDirection.PREV:
|
||||
q = q.order_by(cursor_column.desc())
|
||||
else:
|
||||
q = q.order_by(cursor_column)
|
||||
cursor_clause = (
|
||||
cursor_column.desc()
|
||||
if direction is _CursorDirection.PREV
|
||||
else cursor_column
|
||||
)
|
||||
order_clauses: list[Any] = [cursor_clause]
|
||||
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())
|
||||
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:
|
||||
result = await session.execute(q.limit(items_per_page + 1))
|
||||
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||
has_more = len(raw_items) > items_per_page
|
||||
items_page = raw_items[:items_per_page]
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.params import Depends as DependsClass
|
||||
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
|
||||
|
||||
__all__ = ["BodyDependency", "PathDependency"]
|
||||
@@ -24,12 +25,59 @@ def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
|
||||
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(
|
||||
model: type[ModelType],
|
||||
field: Any,
|
||||
*,
|
||||
session_dep: SessionDependency,
|
||||
param_name: str | None = None,
|
||||
crud: type[AsyncCrud[ModelType]] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
) -> ModelType:
|
||||
"""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)
|
||||
session_dep: Session dependency function (e.g., get_db)
|
||||
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:
|
||||
A Depends() instance that resolves to the model instance
|
||||
@@ -55,36 +107,14 @@ def PathDependency(
|
||||
): ...
|
||||
```
|
||||
"""
|
||||
session_callable = _unwrap_session_dep(session_dep)
|
||||
crud = CrudFactory(model)
|
||||
name = (
|
||||
param_name
|
||||
if param_name is not None
|
||||
else f"{model.__name__.lower()}_{field.key}"
|
||||
return _fetch_dependency(
|
||||
model,
|
||||
field,
|
||||
session_dep=session_dep,
|
||||
param_name=param_name or 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(
|
||||
@@ -93,6 +123,8 @@ def BodyDependency(
|
||||
*,
|
||||
session_dep: SessionDependency,
|
||||
body_field: str,
|
||||
crud: type[AsyncCrud[ModelType]] | None = None,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
) -> ModelType:
|
||||
"""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)
|
||||
session_dep: Session dependency function (e.g., get_db)
|
||||
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:
|
||||
A Depends() instance that resolves to the model instance
|
||||
@@ -120,28 +156,11 @@ def BodyDependency(
|
||||
): ...
|
||||
```
|
||||
"""
|
||||
session_callable = _unwrap_session_dep(session_dep)
|
||||
crud = CrudFactory(model)
|
||||
python_type = field.type.python_type
|
||||
|
||||
async def dependency(
|
||||
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
||||
) -> 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 _fetch_dependency(
|
||||
model,
|
||||
field,
|
||||
session_dep=session_dep,
|
||||
param_name=body_field,
|
||||
crud=crud,
|
||||
load_options=load_options,
|
||||
)
|
||||
|
||||
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
||||
|
||||
@@ -466,6 +466,30 @@ class TestDefaultLoadOptionsIntegration:
|
||||
assert updated.role is not None
|
||||
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
|
||||
async def test_load_options_overrides_default_load_options(
|
||||
self, db_session: AsyncSession
|
||||
|
||||
+280
-1
@@ -5,6 +5,7 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.sql.elements import ColumnElement, UnaryExpression
|
||||
|
||||
from fastapi_toolsets.crud import (
|
||||
@@ -16,7 +17,7 @@ from fastapi_toolsets.crud import (
|
||||
get_searchable_fields,
|
||||
)
|
||||
from fastapi_toolsets.exceptions import InvalidOrderFieldError
|
||||
from fastapi_toolsets.schemas import OffsetPagination, PaginationType
|
||||
from fastapi_toolsets.schemas import OffsetPagination, PaginationType, PydanticBase
|
||||
|
||||
from .conftest import (
|
||||
Article,
|
||||
@@ -29,15 +30,19 @@ from .conftest import (
|
||||
OrderCrud,
|
||||
OrderRead,
|
||||
OrderStatus,
|
||||
Post,
|
||||
PostCrud,
|
||||
Role,
|
||||
RoleCreate,
|
||||
RoleCrud,
|
||||
RoleCursorCrud,
|
||||
RoleRead,
|
||||
Tag,
|
||||
User,
|
||||
UserCreate,
|
||||
UserCrud,
|
||||
UserRead,
|
||||
post_tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -397,6 +402,225 @@ class TestBuildSearchFilters:
|
||||
assert "CAST" in str(filters[0])
|
||||
|
||||
|
||||
class _PostTitle(PydanticBase):
|
||||
"""Minimal read schema for the to-many pagination tests."""
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
|
||||
|
||||
class _TagName(PydanticBase):
|
||||
name: str
|
||||
|
||||
|
||||
class _PostWithTags(PydanticBase):
|
||||
"""Serialising `tags` fails unless the relation was eager-loaded."""
|
||||
|
||||
title: str
|
||||
tags: list[_TagName]
|
||||
|
||||
|
||||
PostTagSearchCrud = CrudFactory(
|
||||
Post,
|
||||
searchable_fields=[Post.title, (Post.tags, Tag.name)],
|
||||
cursor_column=Post.id,
|
||||
)
|
||||
|
||||
_POST_COUNT = 10
|
||||
|
||||
|
||||
async def _seed_posts_with_tags(session) -> None:
|
||||
"""10 posts, each with 3 tags whose names all match the search term."""
|
||||
author = await UserCrud.create(
|
||||
session, UserCreate(username="fanout", email="fanout@test.com")
|
||||
)
|
||||
for i in range(_POST_COUNT):
|
||||
tags = [Tag(name=f"shared-{i}-{j}") for j in range(3)]
|
||||
session.add_all(tags)
|
||||
session.add(Post(title=f"post{i:02d}", author_id=author.id, tags=tags))
|
||||
await session.flush()
|
||||
|
||||
|
||||
class TestPaginateToManyJoin:
|
||||
"""Searching a to-many relationship must not truncate or duplicate pages."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_pages_are_full_and_complete(self, db_session: AsyncSession):
|
||||
"""Every page is full and every post is returned exactly once."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
seen: list[str] = []
|
||||
for page in (1, 2):
|
||||
result = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=page,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
schema=_PostTitle,
|
||||
)
|
||||
assert result.pagination.total_count == _POST_COUNT
|
||||
assert len(result.data) == 5, f"page {page} came back short"
|
||||
seen += [p.title for p in result.data]
|
||||
|
||||
assert len(set(seen)) == _POST_COUNT, "posts duplicated or missing"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_without_total_reports_has_more(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""``has_more`` counts entities, not joined rows."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
first = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=1,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
include_total=False,
|
||||
schema=_PostTitle,
|
||||
)
|
||||
assert len(first.data) == 5
|
||||
assert first.pagination.has_more is True
|
||||
|
||||
last = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=2,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
include_total=False,
|
||||
schema=_PostTitle,
|
||||
)
|
||||
assert len(last.data) == 5
|
||||
assert last.pagination.has_more is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_traverses_every_row(self, db_session: AsyncSession):
|
||||
"""Cursor traversal must not stop early."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
seen: list[str] = []
|
||||
cursor: str | None = None
|
||||
for _ in range(_POST_COUNT):
|
||||
result = await PostTagSearchCrud.cursor_paginate(
|
||||
db_session,
|
||||
cursor=cursor,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
schema=_PostTitle,
|
||||
)
|
||||
seen += [p.title for p in result.data]
|
||||
cursor = result.pagination.next_cursor
|
||||
if cursor is None:
|
||||
break
|
||||
|
||||
assert len(seen) == _POST_COUNT, "traversal stopped early or repeated rows"
|
||||
assert len(set(seen)) == _POST_COUNT
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orders_by_a_to_many_column(self, db_session: AsyncSession):
|
||||
"""Ordering by a related column has to be collapsed to an aggregate."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
result = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=1,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
order_by=Tag.name.asc(),
|
||||
order_joins=[Post.tags],
|
||||
schema=_PostTitle,
|
||||
)
|
||||
|
||||
assert len(result.data) == 5
|
||||
assert result.pagination.total_count == _POST_COUNT
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orders_by_a_bare_to_many_column(self, db_session: AsyncSession):
|
||||
"""An order clause with no explicit direction still needs aggregating."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
result = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=1,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
order_by=Tag.name,
|
||||
order_joins=[Post.tags],
|
||||
schema=_PostTitle,
|
||||
)
|
||||
|
||||
assert len(result.data) == 5
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_page_past_the_end_is_empty(self, db_session: AsyncSession):
|
||||
"""No keys on the page means no second query and an empty result."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
result = await PostTagSearchCrud.offset_paginate(
|
||||
db_session, page=99, items_per_page=5, search="shared", schema=_PostTitle
|
||||
)
|
||||
|
||||
assert result.data == []
|
||||
assert result.pagination.total_count == _POST_COUNT
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_load_options_apply_on_the_fan_out_path(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""The entity query still honours loader options."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
result = await PostTagSearchCrud.offset_paginate(
|
||||
db_session,
|
||||
page=1,
|
||||
items_per_page=5,
|
||||
search="shared",
|
||||
load_options=[selectinload(Post.tags)],
|
||||
schema=_PostWithTags,
|
||||
)
|
||||
|
||||
assert len(result.data) == 5
|
||||
assert all(len(p.tags) == 3 for p in result.data)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_multi_is_not_truncated_by_a_to_many_join(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""`get_multi` takes a raw join, so cardinality cannot be inspected."""
|
||||
await _seed_posts_with_tags(db_session)
|
||||
|
||||
rows = await PostCrud.get_multi(
|
||||
db_session,
|
||||
joins=[(post_tags, post_tags.c.post_id == Post.id)],
|
||||
outer_join=True,
|
||||
limit=5,
|
||||
)
|
||||
|
||||
assert len(rows) == 5
|
||||
assert len({r.id for r in rows}) == 5
|
||||
|
||||
def test_grouped_order_only_aggregates_foreign_columns(self):
|
||||
"""A base-table column is left alone; anything else collapses to min()."""
|
||||
from fastapi_toolsets.crud.factory import _grouped_order
|
||||
|
||||
table = Post.__table__
|
||||
|
||||
assert "min" not in str(_grouped_order(Post.title.desc(), table)).lower()
|
||||
|
||||
asc_on_join = str(_grouped_order(Tag.name.asc(), table))
|
||||
desc_on_join = str(_grouped_order(Tag.name.desc(), table))
|
||||
assert "min" in asc_on_join.lower() and asc_on_join.endswith("ASC")
|
||||
assert "min" in desc_on_join.lower() and desc_on_join.endswith("DESC")
|
||||
|
||||
def test_to_one_join_does_not_take_the_fan_out_path(self):
|
||||
"""A to-one join keeps the single-query path."""
|
||||
from fastapi_toolsets.crud.factory import _fans_out
|
||||
|
||||
assert _fans_out([User.role], None) is False
|
||||
assert _fans_out([Post.tags], None) is True
|
||||
assert _fans_out(None, [Post.tags]) is True
|
||||
|
||||
|
||||
class TestSearchEnumColumn:
|
||||
"""Searching an enum column must reach the database, not just build SQL."""
|
||||
|
||||
@@ -2319,6 +2543,16 @@ class TestOrderParamsViaConsolidated:
|
||||
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:
|
||||
"""Tests for AsyncCrud.offset_paginate_params()."""
|
||||
|
||||
@@ -2388,6 +2622,9 @@ class TestOffsetPaginateParamsSchema:
|
||||
"items_per_page": 10,
|
||||
"include_total": False,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -2431,6 +2668,42 @@ class TestOffsetPaginateParamsSchema:
|
||||
assert "search" 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):
|
||||
"""filter=True with no facet_fields silently skips filter params."""
|
||||
dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False)
|
||||
@@ -2503,6 +2776,9 @@ class TestCursorPaginateParamsSchema:
|
||||
"cursor": None,
|
||||
"items_per_page": 5,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -2613,6 +2889,9 @@ class TestPaginateParamsSchema:
|
||||
"items_per_page": 10,
|
||||
"include_total": True,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -7,15 +7,18 @@ from typing import Annotated, Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi.params import Depends
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
from fastapi_toolsets.dependencies import (
|
||||
BodyDependency,
|
||||
PathDependency,
|
||||
_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]:
|
||||
@@ -275,3 +278,78 @@ class TestBodyDependency:
|
||||
|
||||
assert result.id == role.id
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -299,7 +299,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.139.0"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -308,14 +308,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "5.1.0"
|
||||
version = "5.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -814,35 +814,35 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.9"
|
||||
version = "0.4.14"
|
||||
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 = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/02/632446b72103daf92526203bf83cb76043ebce70588632aa2aea3741da07/prek-0.4.9-py3-none-linux_armv6l.whl", hash = "sha256:7b240ad6f679104309a944c4dd427ccc46d9aaf4f53ee07379c02bf7578c2750", size = 5637604, upload-time = "2026-07-11T11:03:38.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/89daefc85a1db9b8c525731c77121de0a8f57731e81c99d823e919e1f6a5/prek-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5cbb220d6d77cb047747dcabf421375fdfdd958e01a998a854758698d38fb239", size = 5960396, upload-time = "2026-07-11T11:03:40.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/39/0e448da00671e77740be32cca96530ef64bcdbed178acce7f155b87f6057/prek-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd85df4c186becdc47b2e6155de8cace99133c7517387e30f08ca15b93ead11f", size = 5524982, upload-time = "2026-07-11T11:03:42.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e5/1beceff9cfcf02817c06f38e726c9875cd003f62cbfa516f282fb3c3109b/prek-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ba40511145e948d461d6641b556b6ff671b186b0c90743600b9dcb788dd5eb5c", size = 5793691, upload-time = "2026-07-11T11:03:44.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/17/99e4884c45c46be90e81e220d2bdd5020716963707ef6702361cc398dd91/prek-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cc5c06ae448568d076bb5e7ce4d630879d6467b2a89fd79061c349a47826efa", size = 5544549, upload-time = "2026-07-11T11:03:45.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/4b/63f5fe22d867a6e07b12e8a7887a0f3b193c2ef0fa9ed80649f2d257f4ac/prek-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31af616c216ec7e47913802b082ca816d707d24738fa58eae0463ae296cbe71e", size = 5948798, upload-time = "2026-07-11T11:03:47.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/89/90d5005436afb6ab99d3ba6599820fe0f0fcb776f5e9e362b5a19e10cbea/prek-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1ef842d7f19879fac2135c42ba15508f2677346ba800bc8bb82e5f43fe6a6ec", size = 6696938, upload-time = "2026-07-11T11:03:49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/62/068dd25e1106e262b0ce69ffcdc594cf52d85aa13f64628f851e458980d4/prek-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442ecf6a454c692bc8a2d5a16936a0fe8e3419727ff208e15818932acca9923a", size = 6170870, upload-time = "2026-07-11T11:03:50.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/42/bb1f3f3d84af4d12bb675ff071305fa776d3525c10626465d9960ad93c21/prek-0.4.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5b3c590252e3d5724ebed3774695712ff7cb554743b25184808aa5f42b06bd4c", size = 5800355, upload-time = "2026-07-11T11:03:51.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ca/dfc8312b4a7ce8fa100ce37a843279d108eec7e7fe2ddbe069448acd1642/prek-0.4.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dd283b15dec4da29caa3910bd72c8c9d7df93209770503465ad109d6370cb8e", size = 5655126, upload-time = "2026-07-11T11:03:53.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8a/b19413cb64b81c18502ea7bbef32897f9126b8e53b58cd656996573dc53c/prek-0.4.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:cc25e30e1700a5c7dd9bad665c321e5589b51502bb1bbc6ada45e326d08b428b", size = 5517839, upload-time = "2026-07-11T11:03:54.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/af/900df3f7535e87045df331646c6a01bef6e77dba7f2bdb53483f30cbb988/prek-0.4.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4ff5947deeb9a92e6508dfba8b27962dfb927bf60fd36472c9ae862df96fb38c", size = 5802556, upload-time = "2026-07-11T11:03:56.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/7b/80e560cbe396d0f8687012769cb2d7d7f3428dd019549225ebf205dea806/prek-0.4.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8320ca167d41855d9c4fed66df599f31f96307cbb0da1311a9fe465152e20bd5", size = 6285747, upload-time = "2026-07-11T11:03:58.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/d4/01ae3b99d09559a69befd128859d0036c604608dd9c6c99986592dde3c21/prek-0.4.9-py3-none-win32.whl", hash = "sha256:b1e8d3bc88ddce6414853468ed8126f45d4ae20f2f4677801ade20ad67a826fa", size = 5320862, upload-time = "2026-07-11T11:03:59.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/68/d038b14f0220fed197be8bc93229e6ea7ad460803ff8a26e4b14a8c81f66/prek-0.4.9-py3-none-win_amd64.whl", hash = "sha256:ed1b4f87a13d1565e8731c60db7fa058966049cbb4d8872d160add510a286558", size = 5706850, upload-time = "2026-07-11T11:04:01.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4a/57d04de49f591088901794cc22f36563a102681e3512ae17ee6085cd2f30/prek-0.4.9-py3-none-win_arm64.whl", hash = "sha256:7eab3900d9ea614c8ea0d0d55a8b708f0c88e43c966dc8b13a4e36c1e398dd16", size = 5540477, upload-time = "2026-07-11T11:04:02.815Z" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
name = "prometheus-client"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -1307,27 +1307,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.64"
|
||||
version = "0.0.75"
|
||||
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 = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
|
||||
Reference in New Issue
Block a user