mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 11:19:56 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c15c95544d | ||
|
|
e09b911277 | ||
|
|
f7ecb76e8d
|
||
|
|
ef269833b9
|
||
|
|
610b3e1ab4
|
||
|
|
312723d66b
|
||
|
|
3d426ac567 | ||
|
|
0cc189117d
|
||
|
|
a7b78832fd | ||
|
|
56c19971e2 | ||
|
|
7cd0ca2936 | ||
|
|
713e9a40c8 | ||
|
|
1432a2cc3a | ||
|
|
375d349fc4 | ||
|
|
7506fa3093 | ||
|
|
943115562b
|
||
|
|
716e4f7db7 | ||
|
|
50529b1081
|
||
|
|
8aea7247c9 | ||
|
|
654347126d
|
||
|
|
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)))
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from sqlalchemy import event, select, tuple_
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
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 ..logger import get_logger
|
||||
@@ -189,17 +190,44 @@ async def _invoke_callback(
|
||||
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(
|
||||
session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
|
||||
session: AsyncSession,
|
||||
model: type,
|
||||
objs: list[Any],
|
||||
preloaded: dict[int, set[str]],
|
||||
) -> None:
|
||||
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
|
||||
"""Re-populate all rows of *model* in one round trip."""
|
||||
pk_cols = sa_inspect(model, raiseerr=True).primary_key
|
||||
pk_tuples = [sa_inspect(obj).key[1] for obj in objs]
|
||||
where = (
|
||||
pk_cols[0].in_([pk[0] for pk in pk_tuples])
|
||||
if len(pk_cols) == 1
|
||||
else tuple_(*pk_cols).in_(pk_tuples)
|
||||
)
|
||||
q = select(model).where(where).execution_options(populate_existing=True)
|
||||
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)
|
||||
|
||||
|
||||
@@ -207,6 +235,7 @@ class EventSession(AsyncSession):
|
||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||
|
||||
async def commit(self) -> None:
|
||||
preloaded = _snapshot_loaded_relationships(self)
|
||||
await super().commit()
|
||||
|
||||
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||
@@ -249,25 +278,25 @@ class EventSession(AsyncSession):
|
||||
# session.get() per object.
|
||||
create_items: list[Any] = []
|
||||
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
|
||||
pk_by_type: dict[type, list[tuple[Any, ...]]] = {}
|
||||
objs_by_type: dict[type, list[Any]] = {}
|
||||
|
||||
for obj in creates:
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
create_items.append(obj)
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
objs_by_type.setdefault(type(obj), []).append(obj)
|
||||
|
||||
for obj, changes in field_changes.values():
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
update_items.append((obj, changes))
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
objs_by_type.setdefault(type(obj), []).append(obj)
|
||||
|
||||
for model, pk_tuples in pk_by_type.items():
|
||||
for model, objs in objs_by_type.items():
|
||||
try:
|
||||
await _batch_reload(self, model, pk_tuples)
|
||||
await _batch_reload(self, model, objs, preloaded)
|
||||
except Exception as exc:
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
+90
-6
@@ -6,9 +6,16 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import ForeignKey, String, select
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
mapped_column,
|
||||
relationship,
|
||||
selectinload,
|
||||
)
|
||||
|
||||
import fastapi_toolsets.models.watched as _watched_module
|
||||
from fastapi_toolsets.models import (
|
||||
@@ -107,6 +114,27 @@ async def _watched_on_update(obj, event_type, changes):
|
||||
_test_events.append({"event": "update", "obj_id": obj.id, "changes": changes})
|
||||
|
||||
|
||||
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):
|
||||
"""Model without __watched_fields__ — watches all mapped fields by default."""
|
||||
|
||||
@@ -355,6 +383,62 @@ async def mixin_session_maker():
|
||||
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:
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_generated_by_db(self, mixin_session):
|
||||
@@ -1013,10 +1097,10 @@ class TestEventCallbacks:
|
||||
|
||||
real_batch_reload = _watched_module._batch_reload
|
||||
|
||||
async def racing_batch_reload(session, model, pk_tuples):
|
||||
if any(pk[0] == doomed_id for pk in pk_tuples):
|
||||
async def racing_batch_reload(session, model, objs, preloaded):
|
||||
if any(getattr(o, "id", None) == doomed_id for o in objs):
|
||||
await kill_doomed_row_once()
|
||||
return await real_batch_reload(session, model, pk_tuples)
|
||||
return await real_batch_reload(session, model, objs, preloaded)
|
||||
|
||||
# Patch the batched reload EventSession.commit() uses to pick up
|
||||
# server defaults, so this test still exercises the race.
|
||||
@@ -1039,7 +1123,7 @@ class TestEventCallbacks:
|
||||
obj = WatchedModel(status="active", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
async def failing_batch_reload(session, model, pk_tuples):
|
||||
async def failing_batch_reload(session, model, objs, preloaded):
|
||||
raise RuntimeError("reload failed")
|
||||
|
||||
with (
|
||||
|
||||
@@ -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.5.0"
|
||||
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/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" }
|
||||
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/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/10/5d99bdfc77b51ab427a139e0b9f2dd5eacc74fe02fd1228c7be1a8ede6ad/prek-0.5.0-py3-none-win32.whl", hash = "sha256:d4970878d5032ad101b4ec5662f3848a70f80c0295ed5a1fea0aed82979c1251", size = 5340535, upload-time = "2026-08-27T03:50:27.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/835019081b3a09acefc6e28a6dd561a45c8270f8a84e85fa00d6f2be0300/prek-0.5.0-py3-none-win_amd64.whl", hash = "sha256:7bf2df923ba48ec72af7dc69033d613f4775eb301cdf975e19e749ecda2a28c4", size = 5725188, upload-time = "2026-08-27T03:50:29.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/dc/a634d4c5951bd899526c6d8f2a4776bc42af1df4b01951e4f90412909cfc/prek-0.5.0-py3-none-win_arm64.whl", hash = "sha256:43e9d2d727435b0be28fe2195a6a22b0e5e0d937270ae0c88edfc42b71db8fab", size = 5492540, upload-time = "2026-08-27T03:50:31.49Z" },
|
||||
]
|
||||
|
||||
[[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.74"
|
||||
version = "0.0.75"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ 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