diff --git a/docs/migration/v2.md b/docs/migration/v2.md index b1cf39f8..0885b25a 100644 --- a/docs/migration/v2.md +++ b/docs/migration/v2.md @@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed === "Before (`v1`)" ```python - result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead) + result = await UserCrud.paginate( + session=session, page=2, items_per_page=20, schema=UserRead + ) ``` === "Now (`v2`)" ```python - result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead) + result = await UserCrud.offset_paginate( + session=session, page=2, items_per_page=20, schema=UserRead + ) ``` --- @@ -124,8 +128,11 @@ For shared base classes that are not meant to be raised directly, use `abstract= class BillingError(ApiException, abstract=True): """Base for all billing-related errors — not raised directly.""" + class PaymentRequiredError(BillingError): - api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402") + api_error = ApiError( + code=402, msg="Payment Required", desc="...", err_code="BILLING-402" + ) ``` --- diff --git a/docs/migration/v3.md b/docs/migration/v3.md index 02b62368..fab2561d 100644 --- a/docs/migration/v3.md +++ b/docs/migration/v3.md @@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr ```python from fastapi_toolsets.crud import OrderByClause + @router.get("/offset") async def list_articles_offset( session: SessionDep, params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))], filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())], - order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))], + order_by: Annotated[ + OrderByClause | None, + Depends(ArticleCrud.order_params(default_field=Article.created_at)), + ], search: str | None = None, ) -> OffsetPaginatedResponse[ArticleRead]: return await ArticleCrud.offset_paginate( @@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr ), ], ) -> OffsetPaginatedResponse[ArticleRead]: - return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead) + return await ArticleCrud.offset_paginate( + session=session, **params, schema=ArticleRead + ) ``` The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle: @@ -109,6 +115,7 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch` ```python from fastapi_toolsets.models import WatchedFieldsMixin, watch + @watch("status") class Order(Base, UUIDMixin, WatchedFieldsMixin): __tablename__ = "orders" @@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch` ```python from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for + class Order(Base, UUIDMixin): __tablename__ = "orders" __watched_fields__ = ("status",) status: Mapped[str] + @listens_for(Order, [ModelEvent.CREATE]) async def on_order_created(order: Order, event_type: ModelEvent, changes: None): await notify_new_order(order.id) + @listens_for(Order, [ModelEvent.UPDATE]) async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict): if "status" in changes: await notify_status_change(order.id, changes["status"]) + @listens_for(Order, [ModelEvent.DELETE]) async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None): await notify_order_cancelled(order.id) diff --git a/docs/migration/v4.md b/docs/migration/v4.md index aeaf656d..c35cd773 100644 --- a/docs/migration/v4.md +++ b/docs/migration/v4.md @@ -35,6 +35,8 @@ The function creates and manages its own **dedicated session** internally, yield user.balance += 100 # With a custom lock mode - async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session: + async with lock_tables( + session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE + ) as session: await process_order(session, order_id) ``` diff --git a/docs/migration/v5.md b/docs/migration/v5.md index 2d0d01dd..b5807cba 100644 --- a/docs/migration/v5.md +++ b/docs/migration/v5.md @@ -24,9 +24,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst get_db = create_db_dependency(session_maker=SessionLocal) get_db_context = create_db_context(session_maker=SessionLocal) + @app.get("/users") - async def list_users(session: AsyncSession = Depends(get_db)): - ... + async def list_users(session: AsyncSession = Depends(get_db)): ... + async def seed(): async with get_db_context() as session: @@ -40,9 +41,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst db = Database(url="postgresql+asyncpg://...") + @app.get("/users") - async def list_users(session: AsyncSession = Depends(db)): - ... + async def list_users(session: AsyncSession = Depends(db)): ... + async def seed(): async with db.session() as session: @@ -89,7 +91,9 @@ The free `lock_tables(session_maker, tables, ...)` function still exists for cal ```python from fastapi_toolsets.db import lock_tables, LockMode - async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session: + async with lock_tables( + session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE + ) as session: await process_order(session, order_id) ``` @@ -127,6 +131,7 @@ Both also change their first argument: instead of the fixture *function*, pass t ```python from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr + @fixtures.register(depends_on=["roles"]) def users(): admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin") diff --git a/docs/module/cli.md b/docs/module/cli.md index f31da978..9db28da2 100644 --- a/docs/module/cli.md +++ b/docs/module/cli.md @@ -92,6 +92,7 @@ import typer cli = typer.Typer() + @cli.command() def hello(): print("Hello from my app!") diff --git a/docs/module/crud.md b/docs/module/crud.md index 8cf9869d..25e786ab 100644 --- a/docs/module/crud.md +++ b/docs/module/crud.md @@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User) from fastapi_toolsets.crud.factory import AsyncCrud from myapp.models import User + class UserCrud(AsyncCrud[User]): model = User searchable_fields = [User.username, User.email] @@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud T = TypeVar("T", bound=DeclarativeBase) + class AuditedCrud(AsyncCrud[T], Generic[T]): """Base CRUD with custom function""" @@ -101,7 +103,9 @@ user = await UserCrud.first(session=session, filters=[User.email == email]) users = await UserCrud.get_multi(session=session, filters=[User.is_active == True]) # Update -user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]) +user = await UserCrud.update( + session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id] +) # Delete await UserCrud.delete(session=session, filters=[User.id == user_id]) @@ -160,10 +164,14 @@ user = await UserCrud.get(session, [User.id == user_id], with_for_update=True) user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait") # Skip rows already locked by another transaction (e.g. job queues) -rows = await JobCrud.get_multi(session, filters=[Job.status == "pending"], with_for_update="skip_locked") +rows = await JobCrud.get_multi( + session, filters=[Job.status == "pending"], with_for_update="skip_locked" +) # Lock atomically as part of update (prevents race between SELECT and UPDATE) -user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True) +user = await UserCrud.update( + session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True +) ``` !!! warning @@ -193,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi from typing import Annotated from fastapi import Depends + @router.get("") async def get_users( session: SessionDep, @@ -228,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C @router.get("") async def get_users( session: SessionDep, - params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))], + params: Annotated[ + dict, Depends(UserCrud.offset_paginate_params(include_total=False)) + ], ) -> OffsetPaginatedResponse[UserRead]: return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) ``` @@ -303,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at) ```python from fastapi_toolsets.schemas import PaginatedResponse + @router.get("") async def list_users( session: SessionDep, @@ -446,6 +458,7 @@ from typing import Annotated from fastapi import Depends + @router.get("", response_model_exclude_none=True) async def list_users( session: SessionDep, @@ -540,6 +553,7 @@ from typing import Annotated from fastapi import Depends + @router.get("") async def list_users( session: SessionDep, @@ -632,7 +646,9 @@ PostCrud = CrudFactory( m2m_fields={"tag_ids": Post.tags}, ) -post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])) +post = await PostCrud.create( + session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]) +) ``` ## Upsert @@ -659,6 +675,7 @@ class UserRead(PydanticBase): id: UUID username: str + @router.get( "/{uuid}", responses=generate_error_responses(NotFoundError), @@ -670,12 +687,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]: schema=UserRead, ) + @router.get("") async def list_users( session: SessionDep, params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())], ) -> OffsetPaginatedResponse[UserRead]: - return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead) + return await crud.UserCrud.offset_paginate( + session=session, **params, schema=UserRead + ) ``` The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances. diff --git a/docs/module/db.md b/docs/module/db.md index 3ca8ab4d..29971c7c 100644 --- a/docs/module/db.md +++ b/docs/module/db.md @@ -24,9 +24,9 @@ db = Database("postgresql+asyncpg://postgres:postgres@localhost/app") app = FastAPI() db.install(app) # commit middleware + engine disposal on shutdown + @app.get("/users") -async def list_users(session: AsyncSession = Depends(db)): - ... +async def list_users(session: AsyncSession = Depends(db)): ... ``` The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it). @@ -37,9 +37,11 @@ The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pyd from pydantic_settings import BaseSettings from pydantic import PostgresDsn + class Settings(BaseSettings): database_url: PostgresDsn + settings = Settings() db = Database( @@ -72,12 +74,14 @@ Without `install`, the session commits in the dependency teardown, which runs af ```python from contextlib import asynccontextmanager + @asynccontextmanager async def lifespan(app): - await warm_cache() # your startup + await warm_cache() # your startup yield await flush_metrics() # your shutdown + app = FastAPI(lifespan=lifespan) db.install(app) # your shutdown runs first, then the engine is disposed ``` @@ -101,6 +105,7 @@ async def seed(): ```python from fastapi_toolsets.db import transaction + async def create_user_with_role(session): async with transaction(session): ... @@ -207,6 +212,7 @@ For test isolation with automatic cleanup, use [`create_worker_database`](../ref ```python from fastapi_toolsets.db.testing import cleanup_tables + @pytest.fixture(autouse=True) async def clean(db_session): yield diff --git a/docs/module/dependencies.md b/docs/module/dependencies.md index 5f54c673..255b2e7b 100644 --- a/docs/module/dependencies.md +++ b/docs/module/dependencies.md @@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db) SessionDep = Annotated[AsyncSession, Depends(get_db)] UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep) + @router.get("/users/{user_id}") async def get_user(user: User = UserDep): return user @@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id ```python UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id") + @router.get("/users/{id}") async def get_user(user: User = UserDep): return user @@ -43,11 +45,15 @@ async def get_user(user: User = UserDep): from fastapi_toolsets.dependencies import BodyDependency # Plain callable -RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id") +RoleDep = BodyDependency( + model=Role, field=Role.id, session_dep=get_db, body_field="role_id" +) # Annotated SessionDep = Annotated[AsyncSession, Depends(get_db)] -RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id") +RoleDep = BodyDependency( + model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id" +) @router.post("/users") diff --git a/docs/module/exceptions.md b/docs/module/exceptions.md index 0501259d..77d9d259 100644 --- a/docs/module/exceptions.md +++ b/docs/module/exceptions.md @@ -53,7 +53,9 @@ All built-in exceptions accept optional keyword arguments to customise the respo | `data` | Overrides the `data` field | ```python -raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.") +raise NotFoundError( + detail="User 42 not found", desc="No user with that ID exists in the database." +) ``` ## Custom exceptions @@ -64,6 +66,7 @@ Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions from fastapi_toolsets.exceptions import ApiException from fastapi_toolsets.schemas import ApiError + class PaymentRequiredError(ApiException): api_error = ApiError( code=402, @@ -105,11 +108,17 @@ Use `abstract=True` when creating a shared base that is not meant to be raised d class BillingError(ApiException, abstract=True): """Base for all billing-related errors.""" + class PaymentRequiredError(BillingError): - api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402") + api_error = ApiError( + code=402, msg="Payment Required", desc="...", err_code="BILLING-402" + ) + class SubscriptionExpiredError(BillingError): - api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP") + api_error = ApiError( + code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP" + ) ``` ## OpenAPI response documentation diff --git a/docs/module/fixtures.md b/docs/module/fixtures.md index fa6e6cf4..2b1ddf15 100644 --- a/docs/module/fixtures.md +++ b/docs/module/fixtures.md @@ -13,6 +13,7 @@ from fastapi_toolsets.fixtures import FixtureRegistry, Context fixtures = FixtureRegistry() + @fixtures.register def roles(): return [ @@ -20,6 +21,7 @@ def roles(): Role(id=2, name="user"), ] + @fixtures.register(depends_on=["roles"], contexts=[Context.TESTING]) def test_users(): return [ @@ -79,14 +81,17 @@ Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is ```python from enum import Enum + class AppContext(str, Enum): STAGING = "staging" DEMO = "demo" + @fixtures.register(contexts=[AppContext.STAGING]) def staging_data(): return [Config(key="feature_x", enabled=True)] + # loads staging_data plus any Context.BASE fixtures await load_fixtures_by_context(session, fixtures, AppContext.STAGING) ``` @@ -98,7 +103,8 @@ Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registere ```python testing_registry = FixtureRegistry(contexts=[Context.TESTING]) -@testing_registry.register # implicitly contexts=[Context.TESTING] + +@testing_registry.register # implicitly contexts=[Context.TESTING] def test_orders(): return [Order(id=1, total=99)] ``` @@ -112,10 +118,12 @@ The same fixture name may be registered under different (non-overlapping) contex def users(): return [User(id=1, username="admin")] + @fixtures.register(contexts=[Context.TESTING]) def users(): return [User(id=2, username="tester")] + # loads both admin and tester (Context.BASE is included automatically) await load_fixtures_by_context(session, fixtures, Context.TESTING) ``` @@ -171,7 +179,9 @@ Looking the fixture up by name (instead of importing the `roles` function direct ```python @fixtures.register(depends_on=["roles"]) def users(): - return [User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))] + return [ + User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin")) + ] ``` Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered. @@ -189,18 +199,21 @@ from app.models import Base DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db" + @pytest.fixture async def db_session(): - async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session: + async with create_db_session( + database_url=DATABASE_URL, base=Base, cleanup=True + ) as session: yield session + register_fixtures(registry=registry, namespace=globals()) ``` ```python # test_users.py -async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]): - ... +async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]): ... ``` The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances. diff --git a/docs/module/metrics.md b/docs/module/metrics.md index ffabe80d..ed5b6859 100644 --- a/docs/module/metrics.md +++ b/docs/module/metrics.md @@ -47,10 +47,12 @@ If neither of these applies to you, declaring metrics at module level (e.g. `HTT ```python from prometheus_client import Counter, Histogram + @metrics.register def http_requests(): return Counter("http_requests_total", "Total HTTP requests", ["method", "status"]) + @metrics.register def request_duration(): return Histogram("request_duration_seconds", "Request duration") @@ -79,6 +81,7 @@ from prometheus_client import Gauge _queue_depth = Gauge("queue_depth", "Current queue depth") + @metrics.register(collect=True) def collect_queue_depth(): _queue_depth.set(get_current_queue_depth()) diff --git a/docs/module/models.md b/docs/module/models.md index a5c86cbb..d91c8cc0 100644 --- a/docs/module/models.md +++ b/docs/module/models.md @@ -11,6 +11,7 @@ The `models` module provides mixins that each add a single, well-defined column ```python from fastapi_toolsets.models import UUIDMixin, TimestampMixin + class Article(Base, UUIDMixin, TimestampMixin): __tablename__ = "articles" @@ -31,11 +32,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_ran ```python from fastapi_toolsets.models import UUIDMixin + class User(Base, UUIDMixin): __tablename__ = "users" username: Mapped[str] + # id is None before flush user = User(username="alice") session.add(user) @@ -54,11 +57,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7( ```python from fastapi_toolsets.models import UUIDv7Mixin + class Event(Base, UUIDv7Mixin): __tablename__ = "events" name: Mapped[str] + # id is None before flush event = Event(name="user.signup") session.add(event) @@ -73,6 +78,7 @@ Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The c ```python from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin + class Order(Base, UUIDMixin, CreatedAtMixin): __tablename__ = "orders" @@ -86,11 +92,13 @@ Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and a ```python from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin + class Post(Base, UUIDMixin, UpdatedAtMixin): __tablename__ = "posts" title: Mapped[str] + post = Post(title="Hello") await session.flush() await session.refresh(post) @@ -111,6 +119,7 @@ Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastap ```python from fastapi_toolsets.models import UUIDMixin, TimestampMixin + class Article(Base, UUIDMixin, TimestampMixin): __tablename__ = "articles" @@ -166,10 +175,12 @@ class Order(Base, UUIDMixin): __watched_fields__ = ("status",) ... + class UrgentOrder(Order): # inherits __watched_fields__ = ("status",) ... + class PriorityOrder(Order): __watched_fields__ = ("priority",) # overrides parent — UPDATE fires only for priority changes @@ -183,20 +194,24 @@ Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolse ```python from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for + class Order(Base, UUIDMixin): __tablename__ = "orders" __watched_fields__ = ("status",) status: Mapped[str] + @listens_for(Order, [ModelEvent.CREATE]) async def on_order_created(order: Order, event_type: ModelEvent, changes: None): await notify_new_order(order.id) + @listens_for(Order, [ModelEvent.DELETE]) async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None): await notify_order_cancelled(order.id) + @listens_for(Order, [ModelEvent.UPDATE]) async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict): if "status" in changes: @@ -212,8 +227,11 @@ A single handler can listen for multiple events at once. When `event_types` is o async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None): await invalidate_cache(order.id) + @listens_for(Order) # all events -async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None): +async def on_any_order_event( + order: Order, event_type: ModelEvent, changes: dict | None +): await audit_log(order.id, event_type) ``` diff --git a/docs/module/pytest.md b/docs/module/pytest.md index b0c22af0..27e5aa78 100644 --- a/docs/module/pytest.md +++ b/docs/module/pytest.md @@ -21,6 +21,7 @@ Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils ```python from fastapi_toolsets.pytest import create_async_client + @pytest.fixture async def http_client(db_session): async def _override_get_db(): @@ -52,6 +53,7 @@ Use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.ut ```python from fastapi_toolsets.pytest import create_worker_database, create_db_session + @pytest.fixture(scope="session") async def worker_db_url(): async with create_worker_database( @@ -96,11 +98,17 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils ```python from fastapi_toolsets.pytest import worker_database_url -url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test") +url = worker_database_url( + "postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test" +) # → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist # → "postgresql+asyncpg://user:pass@localhost/test" otherwise -url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp") +url = worker_database_url( + "postgresql+asyncpg://user:pass@localhost/myapp", + default_test_db="test", + prefix="myapp", +) # → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist # → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise ``` @@ -112,6 +120,7 @@ url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", defa ```python from fastapi_toolsets.pytest import cleanup_tables + @pytest.fixture(autouse=True) async def clean(db_session): yield diff --git a/docs/module/schemas.md b/docs/module/schemas.md index 68fa810c..90f2ab79 100644 --- a/docs/module/schemas.md +++ b/docs/module/schemas.md @@ -15,6 +15,7 @@ The most common wrapper for a single resource response. ```python from fastapi_toolsets.schemas import Response + @router.get("/users/{id}") async def get_user(user: User = UserDep) -> Response[UserSchema]: return Response(data=user, message="User retrieved") @@ -39,6 +40,7 @@ Use as the return type when the endpoint always uses [`offset_paginate`](crud.md ```python from fastapi_toolsets.schemas import OffsetPaginatedResponse + @router.get("/users") async def list_users( page: int = 1, @@ -74,6 +76,7 @@ Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md ```python from fastapi_toolsets.schemas import CursorPaginatedResponse + @router.get("/events") async def list_events( cursor: str | None = None, @@ -110,6 +113,7 @@ When used as a return annotation, `PaginatedResponse[T]` automatically expands t from fastapi_toolsets.crud import PaginationType from fastapi_toolsets.schemas import PaginatedResponse + @router.get("/users") async def list_users( pagination_type: PaginationType = PaginationType.OFFSET, diff --git a/docs/reference/crud.md b/docs/reference/crud.md index 4b2d0400..f3746f4c 100644 --- a/docs/reference/crud.md +++ b/docs/reference/crud.md @@ -6,7 +6,11 @@ You can import the main symbols from `fastapi_toolsets.crud`: ```python from fastapi_toolsets.crud import CrudFactory, AsyncCrud -from fastapi_toolsets.crud.search import SearchConfig, get_searchable_fields, build_search_filters +from fastapi_toolsets.crud.search import ( + SearchConfig, + get_searchable_fields, + build_search_filters, +) ``` ## ::: fastapi_toolsets.crud.factory.AsyncCrud diff --git a/pyproject.toml b/pyproject.toml index 566dfbfd..e9abf649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,18 @@ docs-src = [ requires = ["uv_build>=0.10,<0.12.0"] build-backend = "uv_build" +[tool.ruff.format] +exclude = ["*.md"] + +[tool.ruff.lint] +extend-select = ["E712"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"] + [tool.pytest.ini_options] testpaths = ["tests"] filterwarnings = [ diff --git a/src/fastapi_toolsets/crud/__init__.py b/src/fastapi_toolsets/crud/__init__.py index 95bf3901..26b9ea3f 100644 --- a/src/fastapi_toolsets/crud/__init__.py +++ b/src/fastapi_toolsets/crud/__init__.py @@ -22,7 +22,6 @@ "AsyncCrud", "CrudFactory", "FacetFieldType", - "get_searchable_fields", "InvalidFacetFilterError", "InvalidSearchColumnError", "JoinType", @@ -34,4 +33,5 @@ "SearchConfig", "SearchFieldType", "UnsupportedFacetTypeError", + "get_searchable_fields", ] diff --git a/src/fastapi_toolsets/crud/factory.py b/src/fastapi_toolsets/crud/factory.py index 8ae61f56..bd572c0e 100644 --- a/src/fastapi_toolsets/crud/factory.py +++ b/src/fastapi_toolsets/crud/factory.py @@ -1580,11 +1580,10 @@ async def cursor_paginate( # prev_cursor: points before the first item in ascending order prev_cursor: str | None = None - if direction is _CursorDirection.NEXT and cursor is not None and items_page: - prev_cursor = _encode_cursor( - getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV - ) - elif direction is _CursorDirection.PREV and has_more and items_page: + if items_page and ( + (direction is _CursorDirection.NEXT and cursor is not None) + or (direction is _CursorDirection.PREV and has_more) + ): prev_cursor = _encode_cursor( getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV ) diff --git a/src/fastapi_toolsets/crud/search.py b/src/fastapi_toolsets/crud/search.py index a7ad80cc..722901bd 100644 --- a/src/fastapi_toolsets/crud/search.py +++ b/src/fastapi_toolsets/crud/search.py @@ -386,7 +386,7 @@ def build_filter_by( enum_class = col_type.enum_class if enum_class is not None: - def _coerce_enum(v: Any) -> Any: + def _coerce_enum(v: Any, enum_class: Any = enum_class) -> Any: if isinstance(v, enum_class): return v return enum_class[v] # lookup by name: "PENDING", "RED" diff --git a/src/fastapi_toolsets/db/core.py b/src/fastapi_toolsets/db/core.py index 69775acd..65e23264 100644 --- a/src/fastapi_toolsets/db/core.py +++ b/src/fastapi_toolsets/db/core.py @@ -212,9 +212,8 @@ async def lifespan(app): @asynccontextmanager async def _composed(app_: Any) -> AsyncGenerator[None, None]: - async with self.lifespan(app_): - async with inner_lifespan(app_): - yield + async with self.lifespan(app_), inner_lifespan(app_): + yield app.router.lifespan_context = _composed diff --git a/src/fastapi_toolsets/db/m2m.py b/src/fastapi_toolsets/db/m2m.py index b4bc979b..6beaa510 100644 --- a/src/fastapi_toolsets/db/m2m.py +++ b/src/fastapi_toolsets/db/m2m.py @@ -1,6 +1,6 @@ """Many-to-Many association-table helpers (direct, without loading collections).""" -from typing import Any, TypeVar, cast +from typing import Any, cast from sqlalchemy import ColumnElement, Table, delete, tuple_ from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -8,8 +8,6 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute from sqlalchemy.orm.relationships import RelationshipProperty -_M = TypeVar("_M", bound=DeclarativeBase) - def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg] """Return the validated M2M RelationshipProperty and its secondary table. diff --git a/src/fastapi_toolsets/dependencies.py b/src/fastapi_toolsets/dependencies.py index bfcbf0b4..32f56920 100644 --- a/src/fastapi_toolsets/dependencies.py +++ b/src/fastapi_toolsets/dependencies.py @@ -60,7 +60,7 @@ async def get( name = ( param_name if param_name is not None - else "{}_{}".format(model.__name__.lower(), field.key) + else f"{model.__name__.lower()}_{field.key}" ) python_type = field.type.python_type @@ -70,22 +70,18 @@ async def dependency( value = kwargs[name] return await crud.get(session, filters=[field == value]) - setattr( - dependency, - "__signature__", - inspect.Signature( - parameters=[ - inspect.Parameter( - name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type - ), - inspect.Parameter( - "session", - inspect.Parameter.KEYWORD_ONLY, - annotation=AsyncSession, - default=Depends(session_callable), - ), - ] - ), + 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))) @@ -134,22 +130,18 @@ async def dependency( value = kwargs[body_field] return await crud.get(session, filters=[field == value]) - setattr( - dependency, - "__signature__", - inspect.Signature( - parameters=[ - inspect.Parameter( - body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type - ), - inspect.Parameter( - "session", - inspect.Parameter.KEYWORD_ONLY, - annotation=AsyncSession, - default=Depends(session_callable), - ), - ] - ), + dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute] + parameters=[ + inspect.Parameter( + body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type + ), + inspect.Parameter( + "session", + inspect.Parameter.KEYWORD_ONLY, + annotation=AsyncSession, + default=Depends(session_callable), + ), + ] ) return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency))) diff --git a/src/fastapi_toolsets/exceptions/__init__.py b/src/fastapi_toolsets/exceptions/__init__.py index bca5d94b..55e69a5a 100644 --- a/src/fastapi_toolsets/exceptions/__init__.py +++ b/src/fastapi_toolsets/exceptions/__init__.py @@ -23,8 +23,6 @@ "ApiException", "ConflictError", "ForbiddenError", - "generate_error_responses", - "init_exceptions_handlers", "InvalidFacetFilterError", "InvalidOrderFieldError", "InvalidSearchColumnError", @@ -34,4 +32,6 @@ "PoolExhaustedError", "UnauthorizedError", "UnsupportedFacetTypeError", + "generate_error_responses", + "init_exceptions_handlers", ] diff --git a/src/fastapi_toolsets/exceptions/handler.py b/src/fastapi_toolsets/exceptions/handler.py index dd7b0a3e..6847e214 100644 --- a/src/fastapi_toolsets/exceptions/handler.py +++ b/src/fastapi_toolsets/exceptions/handler.py @@ -146,35 +146,34 @@ def _patched_openapi( for path_data in openapi_schema.get("paths", {}).values(): for operation in path_data.values(): - if isinstance(operation, dict) and "responses" in operation: - if "422" in operation["responses"]: - operation["responses"]["422"] = { - "description": "Validation Error", - "content": { - "application/json": { - "examples": { - "VAL-422": { - "summary": "Validation Error", - "value": { - "data": { - "errors": [ - { - "field": "field_name", - "message": "value is not valid", - "type": "value_error", - } - ] - }, - "status": ResponseStatus.FAIL.value, - "message": "Validation Error", - "description": "1 validation error(s) detected", - "error_code": "VAL-422", + if isinstance(operation, dict) and "422" in operation.get("responses", {}): + operation["responses"]["422"] = { + "description": "Validation Error", + "content": { + "application/json": { + "examples": { + "VAL-422": { + "summary": "Validation Error", + "value": { + "data": { + "errors": [ + { + "field": "field_name", + "message": "value is not valid", + "type": "value_error", + } + ] }, - } + "status": ResponseStatus.FAIL.value, + "message": "Validation Error", + "description": "1 validation error(s) detected", + "error_code": "VAL-422", + }, } } - }, - } + } + }, + } app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/src/fastapi_toolsets/models/__init__.py b/src/fastapi_toolsets/models/__init__.py index a54459dc..45bbb03e 100644 --- a/src/fastapi_toolsets/models/__init__.py +++ b/src/fastapi_toolsets/models/__init__.py @@ -3,19 +3,19 @@ from .columns import ( CreatedAtMixin, TimestampMixin, + UpdatedAtMixin, UUIDMixin, UUIDv7Mixin, - UpdatedAtMixin, ) from .watched import EventSession, ModelEvent, listens_for __all__ = [ + "CreatedAtMixin", "EventSession", "ModelEvent", + "TimestampMixin", "UUIDMixin", "UUIDv7Mixin", - "CreatedAtMixin", "UpdatedAtMixin", - "TimestampMixin", "listens_for", ] diff --git a/src/fastapi_toolsets/models/watched.py b/src/fastapi_toolsets/models/watched.py index 566f008a..8ca13e5f 100644 --- a/src/fastapi_toolsets/models/watched.py +++ b/src/fastapi_toolsets/models/watched.py @@ -206,7 +206,7 @@ async def _batch_reload( class EventSession(AsyncSession): """AsyncSession subclass that dispatches lifecycle callbacks after commit.""" - async def commit(self) -> None: # noqa: C901 + async def commit(self) -> None: await super().commit() creates: list[Any] = self.info.pop(_SESSION_CREATES, []) diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py index f2522c5d..d0919985 100644 --- a/src/fastapi_toolsets/schemas.py +++ b/src/fastapi_toolsets/schemas.py @@ -2,7 +2,7 @@ import math from enum import Enum -from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union +from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar from pydantic import BaseModel, ConfigDict, Field, computed_field @@ -10,11 +10,11 @@ __all__ = [ "ApiError", - "CursorPagination", "CursorPaginatedResponse", + "CursorPagination", "ErrorResponse", - "OffsetPagination", "OffsetPaginatedResponse", + "OffsetPagination", "PaginatedResponse", "PaginationType", "PydanticBase", @@ -174,7 +174,7 @@ def __class_getitem__( # ty:ignore[invalid-method-override] cached = cls._discriminated_union_cache.get(item) if cached is None: cached = Annotated[ - Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form] + CursorPaginatedResponse[item] | OffsetPaginatedResponse[item], # ty:ignore[invalid-type-form] Field(discriminator="pagination_type"), ] cls._discriminated_union_cache[item] = cached diff --git a/tests/conftest.py b/tests/conftest.py index 28d36330..1d8755c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,28 @@ """Shared pytest fixtures for fastapi-utils tests.""" +import datetime +import decimal import os import uuid from enum import Enum import pytest from pydantic import BaseModel -import datetime -import decimal - from sqlalchemy import ( + JSON, Column, Date, DateTime, - Enum as SAEnum, ForeignKey, Integer, - JSON, Numeric, String, Table, Uuid, ) +from sqlalchemy import ( + Enum as SAEnum, +) from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -38,8 +39,6 @@ class Base(DeclarativeBase): """Base class for test models.""" - pass - class Role(Base): """Test role model.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 26b5f161..313fe405 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -554,7 +554,6 @@ def test_async_command_preserves_docstring(self): @async_command async def async_func() -> None: """This is a docstring.""" - pass assert async_func.__doc__ == """This is a docstring.""" diff --git a/tests/test_crud_search.py b/tests/test_crud_search.py index f3f2f967..365ea564 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -2261,7 +2261,7 @@ def test_returns_page_and_items_per_page_params(self): def test_dependency_name_includes_model_name(self): """Dependency function is named after the model.""" dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False) - assert getattr(dep, "__name__") == "RoleOffsetPaginateParams" + assert dep.__name__ == "RoleOffsetPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] def test_default_page_size_reflected_in_items_per_page_default(self): """default_page_size is used as the default for items_per_page.""" @@ -2391,7 +2391,7 @@ def test_dependency_name_includes_model_name(self): dep = RoleCursorCrud.cursor_paginate_params( search=False, filter=False, order=False ) - assert getattr(dep, "__name__") == "RoleCursorPaginateParams" + assert dep.__name__ == "RoleCursorPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] def test_default_page_size_reflected_in_items_per_page_default(self): """default_page_size is used as the default for items_per_page.""" @@ -2461,7 +2461,7 @@ def test_returns_all_params(self): def test_dependency_name_includes_model_name(self): """Dependency function is named after the model.""" dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False) - assert getattr(dep, "__name__") == "RolePaginateParams" + assert dep.__name__ == "RolePaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] def test_default_pagination_type(self): """default_pagination_type is reflected in pagination_type default.""" diff --git a/tests/test_example_pagination_search.py b/tests/test_example_pagination_search.py index 7daea591..9a003c21 100644 --- a/tests/test_example_pagination_search.py +++ b/tests/test_example_pagination_search.py @@ -56,7 +56,7 @@ async def seed(session: AsyncSession): session.add_all([python, backend]) await session.flush() - now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) + now = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) session.add_all( [ Article( diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 2ae478e8..12f9bc94 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -250,6 +250,7 @@ def test_lock_timeout_error_with_detail(self): def test_pool_exhausted_handled_as_503(self): """init_exceptions_handlers turns PoolExhaustedError into a 503 response.""" from fastapi import FastAPI + from fastapi_toolsets.exceptions import init_exceptions_handlers app = FastAPI() @@ -267,6 +268,7 @@ async def endpoint(): def test_lock_timeout_handled_as_503(self): """init_exceptions_handlers turns LockTimeoutError into a 503 response.""" from fastapi import FastAPI + from fastapi_toolsets.exceptions import init_exceptions_handlers app = FastAPI() diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 61b7a264..ee51438d 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -74,7 +74,7 @@ def test_cannot_subclass_context_with_members(self): """Python prohibits extending an Enum that already has members.""" with pytest.raises(TypeError): - class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class] + class MyContext(Context): # ty: ignore[subclass-of-final-class] STAGING = "staging" def test_custom_enum_values_interchangeable_with_context(self): diff --git a/tests/test_imports.py b/tests/test_imports.py index 4953f244..3a62f5a7 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -197,8 +197,8 @@ def test_import_raises_without_typer(self, expected_match): importlib.import_module("fastapi_toolsets.cli.app") finally: for key in list(sys.modules): - if key.startswith("fastapi_toolsets.cli.app") or key.startswith( - "fastapi_toolsets.cli.config" + if key.startswith( + ("fastapi_toolsets.cli.app", "fastapi_toolsets.cli.config") ): sys.modules.pop(key, None) sys.modules.update(saved) diff --git a/tests/test_pytest.py b/tests/test_pytest.py index 77af6285..4271033c 100644 --- a/tests/test_pytest.py +++ b/tests/test_pytest.py @@ -13,6 +13,11 @@ from fastapi_toolsets.db import transaction from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy +from fastapi_toolsets.fixtures.utils import ( + _get_primary_key, + _relationship_load_options, + _reload_with_relationships, +) from fastapi_toolsets.pytest import ( create_async_client, create_db_session, @@ -20,11 +25,6 @@ register_fixtures, worker_database_url, ) -from fastapi_toolsets.fixtures.utils import ( - _get_primary_key, - _relationship_load_options, - _reload_with_relationships, -) from fastapi_toolsets.pytest.utils import _get_xdist_worker from .conftest import ( diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 616f4c86..63094289 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -5,11 +5,11 @@ from fastapi_toolsets.schemas import ( ApiError, - CursorPagination, CursorPaginatedResponse, + CursorPagination, ErrorResponse, - OffsetPagination, OffsetPaginatedResponse, + OffsetPagination, PaginatedResponse, PaginationType, Response, diff --git a/uv.lock b/uv.lock index b91042fa..c3a0e58f 100644 --- a/uv.lock +++ b/uv.lock @@ -1143,27 +1143,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]]