From 3d1c8a52f95062eceb74866f1f70b1660fbb07fa Mon Sep 17 00:00:00 2001 From: Andrii Blacksmith Date: Tue, 9 Jul 2024 12:06:34 +0300 Subject: [PATCH 01/44] add title of content to comment content preview --- app/comments/service.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/comments/service.py b/app/comments/service.py index acfa0033..9e45d92c 100644 --- a/app/comments/service.py +++ b/app/comments/service.py @@ -356,6 +356,11 @@ async def generate_preview( .order_by(desc(Comment.created)) ) + title = ( + comment.content.title_ua + or comment.content.title_en + or comment.content.title_ja + ) slug = comment.content.slug image = None @@ -412,6 +417,7 @@ async def generate_preview( original_comment.preview = { "image": image, "slug": slug, + "title": title, } session.add(original_comment) From 258c73e7eddc2457dd50c1f5b6603efd75b9b62e Mon Sep 17 00:00:00 2001 From: Andrii Blacksmith Date: Tue, 9 Jul 2024 14:18:29 +0300 Subject: [PATCH 02/44] add title of collection to comment content preview --- app/comments/service.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/comments/service.py b/app/comments/service.py index 9e45d92c..9765d6bf 100644 --- a/app/comments/service.py +++ b/app/comments/service.py @@ -356,19 +356,25 @@ async def generate_preview( .order_by(desc(Comment.created)) ) - title = ( - comment.content.title_ua - or comment.content.title_en - or comment.content.title_ja - ) + title = None slug = comment.content.slug image = None if isinstance(comment, AnimeComment): image = comment.content.poster + title = ( + comment.content.title_ua + or comment.content.title_en + or comment.content.title_ja + ) if isinstance(comment, MangaComment) or isinstance(comment, NovelComment): image = comment.content.image + title = ( + comment.content.title_ua + or comment.content.title_en + or comment.content.title_original + ) if isinstance(comment, EditComment): # This is horrible hack, but we need this to prevent SQLAlchemy bug @@ -414,6 +420,8 @@ async def generate_preview( else content.image ) + title = collection_content.collection.title + original_comment.preview = { "image": image, "slug": slug, From 82ea0da4e8cd2521757d96d34820f634be2ac2da Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sat, 13 Jul 2024 00:11:00 +0300 Subject: [PATCH 03/44] Format the imports --- app/edit/router.py | 4 ++-- app/edit/service.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/edit/router.py b/app/edit/router.py index d4498a5a..473876b1 100644 --- a/app/edit/router.py +++ b/app/edit/router.py @@ -1,7 +1,7 @@ -from sqlalchemy.ext.asyncio import AsyncSession from app.manga.schemas import MangaPaginationResponse from app.novel.schemas import NovelPaginationResponse from app.schemas import AnimePaginationResponse +from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends from app.database import get_session from app import constants @@ -42,10 +42,10 @@ ) from .schemas import ( - ContentToDoEnum, EditContentToDoEnum, EditContentTypeEnum, EditListResponse, + ContentToDoEnum, EditSearchArgs, EditResponse, EditArgs, diff --git a/app/edit/service.py b/app/edit/service.py index 202a4dcd..79357cc4 100644 --- a/app/edit/service.py +++ b/app/edit/service.py @@ -1,11 +1,10 @@ +from app.models.list.read import MangaRead, NovelRead from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import with_loader_criteria from sqlalchemy import select, asc, desc, func from sqlalchemy.sql.selectable import Select from sqlalchemy.orm import with_expression from sqlalchemy.orm import joinedload - -from app.models.list.read import MangaRead, NovelRead from .utils import calculate_before from app.utils import utcnow from app import constants @@ -19,9 +18,9 @@ ) from .schemas import ( - ContentToDoEnum, EditContentToDoEnum, EditContentTypeEnum, + ContentToDoEnum, EditSearchArgs, EditArgs, ) From 54aa220dcc84505b8d54037c25f282e1080aa62f Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sun, 14 Jul 2024 20:47:26 +0300 Subject: [PATCH 04/44] Performance: add character content load inside the collection load options to avoid doing a separate database request for each entry --- app/service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/service.py b/app/service.py index acda8740..6874ece2 100644 --- a/app/service.py +++ b/app/service.py @@ -17,6 +17,7 @@ ) from app.models import ( + CharacterCollectionContent, AnimeCollectionContent, MangaCollectionContent, NovelCollectionContent, @@ -427,6 +428,9 @@ def collections_load_options( NovelRead, NovelRead.user_id == request_user.id if request_user else None, ), + joinedload( + Collection.collection.of_type(CharacterCollectionContent) + ).joinedload(CharacterCollectionContent.content), ) ) From d6ae7bc87b57715c771aa6b86da0b8bea159d939 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Mon, 15 Jul 2024 21:18:48 +0300 Subject: [PATCH 05/44] Fix missing import --- app/collections/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/collections/service.py b/app/collections/service.py index 414fb24e..c6207b9d 100644 --- a/app/collections/service.py +++ b/app/collections/service.py @@ -1,4 +1,4 @@ -from sqlalchemy import select, desc, delete, update, and_, func +from sqlalchemy import select, desc, asc, delete, update, and_, func from app.service import content_type_to_content_class from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.selectable import Select From 6ad566662218890e8c132c96206ec978e288ce64 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Fri, 26 Jul 2024 17:06:07 +0300 Subject: [PATCH 06/44] Introduce third-party authorization --- ...09df4f5f5_introduce_third_party_clients.py | 90 ++++++++++++++ app/__init__.py | 3 + app/auth/dependencies.py | 45 ++++++- app/auth/router.py | 43 ++++++- app/auth/schemas.py | 26 +++- app/auth/service.py | 68 ++++++++++- app/client/__init__.py | 1 + app/client/dependencies.py | 44 +++++++ app/client/router.py | 80 +++++++++++++ app/client/schemas.py | 40 +++++++ app/client/service.py | 71 +++++++++++ app/constants.py | 113 +++++++++--------- app/dependencies.py | 76 ++++++++---- app/errors.py | 15 ++- app/models/__init__.py | 4 + app/models/auth/auth_token.py | 10 ++ app/models/auth/auth_token_request.py | 25 ++++ app/models/auth/client.py | 25 ++++ app/read/dependencies.py | 5 +- app/read/router.py | 12 +- app/schemas.py | 6 + app/service.py | 2 +- app/settings/router.py | 46 +++++-- app/sync/__init__.py | 4 + app/sync/token_requests.py | 13 ++ app/upload/dependencies.py | 4 +- app/user/router.py | 6 +- app/utils.py | 7 ++ app/watch/dependencies.py | 9 +- app/watch/router.py | 6 +- sync.py | 2 + tests/auth/test_auth_thirdparty.py | 51 ++++++++ tests/client/test_client_create.py | 51 ++++++++ tests/client/test_client_delete.py | 32 +++++ tests/client/test_client_info.py | 51 ++++++++ tests/client/test_client_update.py | 49 ++++++++ tests/client_requests/__init__.py | 17 +++ tests/client_requests/auth.py | 27 +++++ tests/client_requests/client.py | 50 ++++++++ tests/conftest.py | 16 ++- 40 files changed, 1131 insertions(+), 114 deletions(-) create mode 100644 alembic/versions/2024_07_26_1356-21009df4f5f5_introduce_third_party_clients.py create mode 100644 app/client/__init__.py create mode 100644 app/client/dependencies.py create mode 100644 app/client/router.py create mode 100644 app/client/schemas.py create mode 100644 app/client/service.py create mode 100644 app/models/auth/auth_token_request.py create mode 100644 app/models/auth/client.py create mode 100644 app/sync/token_requests.py create mode 100644 tests/auth/test_auth_thirdparty.py create mode 100644 tests/client/test_client_create.py create mode 100644 tests/client/test_client_delete.py create mode 100644 tests/client/test_client_info.py create mode 100644 tests/client/test_client_update.py create mode 100644 tests/client_requests/client.py diff --git a/alembic/versions/2024_07_26_1356-21009df4f5f5_introduce_third_party_clients.py b/alembic/versions/2024_07_26_1356-21009df4f5f5_introduce_third_party_clients.py new file mode 100644 index 00000000..2997933e --- /dev/null +++ b/alembic/versions/2024_07_26_1356-21009df4f5f5_introduce_third_party_clients.py @@ -0,0 +1,90 @@ +"""Introduce third-party clients + +Revision ID: 21009df4f5f5 +Revises: 4c13fdf8868d +Create Date: 2024-07-26 13:56:21.385422 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "21009df4f5f5" +down_revision = "4c13fdf8868d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "service_clients", + sa.Column("secret", sa.String(length=128), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("endpoint", sa.String(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=True), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["service_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "service_auth_token_requests", + sa.Column("user_id", sa.Uuid(), nullable=True), + sa.Column("expiration", sa.DateTime(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("client_id", sa.Uuid(), nullable=True), + sa.Column( + "scope", postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column("id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint( + ["client_id"], ["service_clients.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["service_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "service_auth_tokens", sa.Column("client_id", sa.Uuid(), nullable=True) + ) + op.add_column( + "service_auth_tokens", + sa.Column( + "scope", + postgresql.JSONB(astext_type=sa.Text()), + server_default="[]", + nullable=False, + ), + ) + op.create_foreign_key( + "service_auth_tokens_client_id_fkey", + "service_auth_tokens", + "service_clients", + ["client_id"], + ["id"], + ondelete="CASCADE", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "service_auth_tokens_client_id_fkey", + "service_auth_tokens", + type_="foreignkey", + ) + op.drop_column("service_auth_tokens", "scope") + op.drop_column("service_auth_tokens", "client_id") + op.drop_table("service_auth_token_requests") + op.drop_table("service_clients") + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py index 4106fd89..8392af77 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,6 +32,7 @@ async def lifespan(app: FastAPI): version="0.4.0", openapi_tags=[ {"name": "Auth"}, + {"name": "Client"}, {"name": "User"}, {"name": "Follow"}, {"name": "Anime"}, @@ -95,6 +96,7 @@ async def lifespan(app: FastAPI): from .people import router as people_router from .follow import router as follow_router from .system import router as system_router + from .client import router as client_router from .anime import router as anime_router from .manga import router as manga_router from .novel import router as novel_router @@ -122,6 +124,7 @@ async def lifespan(app: FastAPI): app.include_router(people_router) app.include_router(follow_router) app.include_router(system_router) + app.include_router(client_router) app.include_router(anime_router) app.include_router(manga_router) app.include_router(novel_router) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 1b91428c..c3f0685f 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -1,6 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession +from app.models import User, UserOAuth, Client from app.dependencies import auth_required -from app.models import User, UserOAuth +from app.client.service import get_client from app.database import get_session from app.schemas import EmailArgs from app.errors import Abort @@ -21,6 +22,7 @@ ) from .service import ( + get_auth_token_request, get_user_by_activation, get_user_by_reset, get_oauth_by_id, @@ -32,6 +34,7 @@ LoginArgs, TokenArgs, CodeArgs, + TokenRequestArgs, ) @@ -197,3 +200,43 @@ async def validate_password_confirm( raise Abort("auth", "reset-expired") return user, confirm.password + + +async def validate_client( + client_reference: str, session: AsyncSession = Depends(get_session) +) -> Client: + if not (client := await get_client(session, client_reference)): + raise Abort("auth", "client-not-found") + + return client + + +def validate_scope(request: TokenRequestArgs) -> list[str]: + for scope in request.scope: + if scope not in constants.ALL_SCOPES: + raise Abort("auth", "invalid-scope") + + if len(request.scope) == 0: + raise Abort("auth", "scope-empty") + + return request.scope + + +async def validate_auth_token_request( + args: TokenArgs, + session: AsyncSession = Depends(get_session), +): + now = utcnow() + + if not ( + request := await get_auth_token_request(session, args.request_reference) + ): + raise Abort("auth", "invalid-token-request") + + if now > request.expiration: + raise Abort("auth", "token-request-expired") + + if request.client.secret != args.client_secret: + raise Abort("auth", "invalid-client-credentials") + + return request diff --git a/app/auth/router.py b/app/auth/router.py index 8df6a844..14f70912 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,7 +1,7 @@ +from app.dependencies import check_captcha, auth_required, auth_token_required +from app.models import User, UserOAuth, AuthToken, Client, AuthTokenRequest from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies import check_captcha from fastapi import APIRouter, Depends -from app.models import User, UserOAuth from app.schemas import UserResponse from app.database import get_session from app import constants @@ -16,6 +16,7 @@ ) from .dependencies import ( + validate_auth_token_request, validate_activation_resend, validate_password_confirm, validate_password_reset, @@ -25,15 +26,18 @@ get_user_oauth, validate_login, get_oauth_data, + validate_client, + validate_scope, ) from .schemas import ( + TokenRequestResponse, ProviderUrlResponse, + AuthInfoResponse, TokenResponse, SignupArgs, ) - router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -186,3 +190,36 @@ async def oauth_token( ) return await service.create_auth_token(session, oauth_user.user) + + +@router.get( + "/info", summary="Get authorization info", response_model=AuthInfoResponse +) +async def auth_info(token: AuthToken = Depends(auth_token_required)): + return token + + +@router.post( + "/token/request/{client_reference}", + summary="Make token request for a third-party client", + response_model=TokenRequestResponse, +) +async def request_token( + client: Client = Depends(validate_client), + scope: list[str] = Depends(validate_scope), + user: User = Depends(auth_required()), + session: AsyncSession = Depends(get_session), +): + return await service.create_auth_token_request(session, user, client, scope) + + +@router.post( + "/token", + summary="Make token for a third-party client", + response_model=TokenResponse, +) +async def third_party_auth_token( + token_request: AuthTokenRequest = Depends(validate_auth_token_request), + session: AsyncSession = Depends(get_session), +): + return await service.create_auth_token_from_request(session, token_request) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index ab3f7eee..4411bbd5 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -1,4 +1,4 @@ -from app.schemas import datetime_pd +from app.schemas import datetime_pd, ClientResponse from pydantic import Field from app.schemas import ( @@ -38,3 +38,27 @@ class TokenResponse(CustomModel): secret: str = Field( examples=["CQE-CTXVFCYoUpxz_6VKrHhzHaUZv68XvxV-3AvQbnA"] ) + + +class AuthInfoResponse(CustomModel): + created: datetime_pd = Field(examples=[1686088809]) + client: ClientResponse | None = Field( + description="Information about logged by third-party client" + ) + scope: list[str] + expiration: datetime_pd = Field(examples=[1686088809]) + + +class TokenRequestResponse(CustomModel): + reference: str + redirect_url: str + expiration: datetime_pd = Field(examples=[1686088809]) + + +class TokenRequestArgs(CustomModel): + scope: list[str] + + +class TokenArgs(CustomModel): + request_reference: str + client_secret: str diff --git a/app/auth/service.py b/app/auth/service.py index 0477a9c0..647956b7 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -1,4 +1,8 @@ -from app.models import User, AuthToken, UserOAuth +import uuid + +from starlette.datastructures import URL + +from app.models import User, AuthToken, UserOAuth, AuthTokenRequest, Client from app.utils import hashpwd, new_token, utcnow from sqlalchemy.ext.asyncio import AsyncSession from app.service import get_user_by_username @@ -198,3 +202,65 @@ async def change_password(session: AsyncSession, user: User, new_password: str): await session.commit() return user + + +async def create_auth_token_request( + session: AsyncSession, user: User, client: Client, scope: list[str] +) -> dict: + + # Remove duplicates in scope (just in case) + scope = list(set(scope)) + + now = utcnow() + + request = AuthTokenRequest( + **{ + "expiration": now + timedelta(minutes=1), + "created": now, + "user": user, + "client": client, + "scope": scope, + } + ) + session.add(request) + await session.commit() + + return { + "reference": request.reference, + "expiration": request.expiration, + "redirect_url": str( + URL(client.endpoint).replace_query_params( + reference=request.reference + ) + ), + } + + +async def get_auth_token_request( + session: AsyncSession, reference: str | uuid.UUID +) -> AuthTokenRequest: + return await session.scalar( + select(AuthTokenRequest) + .filter(AuthTokenRequest.id == reference) + .options( + selectinload(AuthTokenRequest.user), + selectinload(AuthTokenRequest.client), + ) + ) + + +async def create_auth_token_from_request( + session: AsyncSession, request: AuthTokenRequest +): + token = await create_auth_token(session, request.user) + + # Add client and scope to just created token + token.client = request.client + token.scope = request.scope + + # Expire token request + request.expiration = utcnow() - timedelta(minutes=1) + + await session.commit() + + return token diff --git a/app/client/__init__.py b/app/client/__init__.py new file mode 100644 index 00000000..23780433 --- /dev/null +++ b/app/client/__init__.py @@ -0,0 +1 @@ +from .router import router diff --git a/app/client/dependencies.py b/app/client/dependencies.py new file mode 100644 index 00000000..216dd7ee --- /dev/null +++ b/app/client/dependencies.py @@ -0,0 +1,44 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.datastructures import URL +from fastapi import Depends + +from app.dependencies import auth_required +from app.database import get_session +from app.models import User, Client +from .schemas import ClientCreate +from app.errors import Abort +from . import service + + +async def user_client_required( + user: User = Depends(auth_required()), + session: AsyncSession = Depends(get_session), +) -> Client: + if (client := await service.get_user_client(session, user)) is None: + raise Abort("client", "not-found") + + return client + + +async def validate_client_create( + create: ClientCreate, + user: User = Depends(auth_required()), + session: AsyncSession = Depends(get_session), +): + if (await service.get_user_client(session, user)) is not None: + raise Abort("client", "already-exists") + + if URL(create.endpoint): + pass + + return create + + +async def validate_client( + client_reference: str, + session: AsyncSession = Depends(get_session), +) -> Client: + if not (client := await service.get_client(session, client_reference)): + raise Abort("client", "not-found") + + return client diff --git a/app/client/router.py b/app/client/router.py new file mode 100644 index 00000000..44d5c17e --- /dev/null +++ b/app/client/router.py @@ -0,0 +1,80 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends + +from app.dependencies import auth_required +from app.schemas import ClientResponse +from app.database import get_session +from app.models import Client, User +from app.client import service +from app import constants + +from app.client.dependencies import ( + validate_client_create, + user_client_required, + validate_client, +) +from app.client.schemas import ( + ClientFullResponse, + ClientCreate, + ClientUpdate, +) + +router = APIRouter(prefix="/client", tags=["Client"]) + + +@router.get("/", summary="Get user client", response_model=ClientFullResponse) +async def get_user_client(client: Client = Depends(user_client_required)): + return client + + +@router.get( + "/{client_reference}", + summary="Get client by reference", + response_model=ClientResponse, +) +async def get_client_by_reference(client: Client = Depends(validate_client)): + return client + + +@router.post( + "/", summary="Create new user client", response_model=ClientFullResponse +) +async def create_user_client( + create: ClientCreate = Depends(validate_client_create), + session: AsyncSession = Depends(get_session), + user: User = Depends( + auth_required(permissions=[constants.PERMISSION_CLIENT_CREATE]) + ), +): + return await service.create_user_client(session, user, create) + + +@router.put( + "/", + summary="Update user client", + response_model=ClientFullResponse, + dependencies=[ + Depends(auth_required(permissions=[constants.PERMISSION_CLIENT_UPDATE])) + ], +) +async def update_user_client( + update: ClientUpdate, + session: AsyncSession = Depends(get_session), + client: Client = Depends(user_client_required), +): + return await service.update_client(session, client, update) + + +@router.delete( + "/", + summary="Delete user client", + response_model=ClientFullResponse, + dependencies=[ + Depends(auth_required(permissions=[constants.PERMISSION_CLIENT_DELETE])) + ], +) +async def delete_user_client( + session: AsyncSession = Depends(get_session), + client: Client = Depends(user_client_required), +): + return await service.delete_client(session, client) diff --git a/app/client/schemas.py b/app/client/schemas.py new file mode 100644 index 00000000..b01532df --- /dev/null +++ b/app/client/schemas.py @@ -0,0 +1,40 @@ +from pydantic import Field, HttpUrl + +from app.schemas import CustomModel, ClientResponse + + +class ClientFullResponse(ClientResponse): + secret: str + endpoint: str + + +class ClientCreate(CustomModel): + name: str = Field( + examples=["ThirdPartyWatchlistImporter"], description="Client name" + ) + description: str = Field( + examples=["Client that imports watchlist from third-party services"], + description="Short clear description of the client", + ) + endpoint: HttpUrl = Field( + examples=["https://example.com", "http://localhost/auth/confirm"], + description="Endpoint of the client. " + "User will be redirected to that endpoint after successful " + "authorization", + ) + + +class ClientUpdate(CustomModel): + name: str | None = Field( + None, + description="Client name", + ) + description: str | None = Field( + None, + description="Short clear description of the client", + ) + endpoint: HttpUrl | None = Field(None, description="Endpoint of the client") + revoke_secret: bool = Field( + False, + description="Create new client secret and revoke previous", + ) diff --git a/app/client/service.py b/app/client/service.py new file mode 100644 index 00000000..6f4aa743 --- /dev/null +++ b/app/client/service.py @@ -0,0 +1,71 @@ +import secrets +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.client.schemas import ClientCreate, ClientUpdate +from app.models import User, Client +from app.utils import utcnow + + +def _client_secret(): + return secrets.token_urlsafe(96) + + +async def get_client(session: AsyncSession, reference: str | uuid.UUID) -> Client: + return await session.scalar(select(Client).filter(Client.id == reference)) + + +async def get_user_client(session: AsyncSession, user: User) -> Client: + return await session.scalar( + select(Client).filter(Client.user_id == user.id) + ) + + +async def create_user_client( + session: AsyncSession, user: User, create: ClientCreate +) -> Client: + now = utcnow() + + client = Client( + **{ + "secret": _client_secret(), + "name": create.name, + "description": create.description, + "endpoint": str(create.endpoint), + "user_id": user.id, + "created": now, + } + ) + + session.add(client) + await session.commit() + + return client + + +async def update_client( + session: AsyncSession, client: Client, update: ClientUpdate +) -> Client: + if update.name is not None: + client.name = update.name + + if update.description is not None: + client.description = update.description + + if update.endpoint is not None: + client.endpoint = str(update.endpoint) + + if update.revoke_secret is not None: + client.secret = _client_secret() + + await session.commit() + + return client + + +async def delete_client(session: AsyncSession, client: Client) -> Client: + await session.delete(client) + await session.commit() + return client diff --git a/app/constants.py b/app/constants.py index cba4a930..029f109e 100644 --- a/app/constants.py +++ b/app/constants.py @@ -138,6 +138,25 @@ ROLE_NOT_ACTIVATED = "not_activated" ROLE_DELETED = "deleted" +# User access scope +SCOPE_READ_USER_DETAILS = "user:read:details" +SCOPE_UPDATE_USER_DETAILS = "user:update:details" +SCOPE_READ_USER_WATCHLIST = "user:read:watchlist" +SCOPE_UPDATE_USER_WATCHLIST = "user:update:watchlist" +SCOPE_READ_USER_READLIST = "user:read:readlist" +SCOPE_UPDATE_USER_READLIST = "user:update:readlist" +SCOPE_UPLOAD = "upload" + +ALL_SCOPES = [ + SCOPE_READ_USER_DETAILS, + SCOPE_UPDATE_USER_DETAILS, + SCOPE_READ_USER_WATCHLIST, + SCOPE_UPDATE_USER_WATCHLIST, + SCOPE_READ_USER_READLIST, + SCOPE_UPDATE_USER_READLIST, + SCOPE_UPLOAD, +] + # Permissions PERMISSION_EDIT_CREATE = "edit:create" PERMISSION_EDIT_ACCEPT = "edit:accept" @@ -158,65 +177,47 @@ PERMISSION_COLLECTION_UPDATE_MODERATOR = "collection:update_moderator" PERMISSION_COLLECTION_DELETE_MODERATOR = "collection:delete_moderator" PERMISSION_VOTE_SET = "vote:set" +PERMISSION_CLIENT_CREATE = "client:create" +PERMISSION_CLIENT_UPDATE = "client:update" +PERMISSION_CLIENT_DELETE = "client:delete" +PERMISSION_CLIENT_DELETE_ADMIN = "client:delete_admin" + +USER_PERMISSIONS = [ + PERMISSION_EDIT_CREATE, + PERMISSION_EDIT_UPDATE, + PERMISSION_EDIT_CLOSE, + PERMISSION_UPLOAD_AVATAR, + PERMISSION_UPLOAD_COVER, + PERMISSION_COMMENT_WRITE, + PERMISSION_COMMENT_EDIT, + PERMISSION_COMMENT_HIDE, + PERMISSION_VOTE_SET, + PERMISSION_COLLECTION_CREATE, + PERMISSION_COLLECTION_UPDATE, + PERMISSION_COLLECTION_DELETE, + PERMISSION_CLIENT_CREATE, + PERMISSION_CLIENT_UPDATE, + PERMISSION_CLIENT_DELETE, +] + +MODERATOR_PERMISSIONS = [ + *USER_PERMISSIONS, + PERMISSION_COLLECTION_UPDATE_MODERATOR, + PERMISSION_COLLECTION_DELETE_MODERATOR, + PERMISSION_EDIT_UPDATE_MODERATOR, +] + +ADMIN_PERMISSIONS = [ + *MODERATOR_PERMISSIONS, + PERMISSION_COMMENT_HIDE_ADMIN, + PERMISSION_CLIENT_DELETE_ADMIN, +] # Role permissions ROLES = { - ROLE_USER: [ - PERMISSION_EDIT_CREATE, - PERMISSION_EDIT_UPDATE, - PERMISSION_EDIT_CLOSE, - PERMISSION_UPLOAD_AVATAR, - PERMISSION_UPLOAD_COVER, - PERMISSION_COMMENT_WRITE, - PERMISSION_COMMENT_EDIT, - PERMISSION_COMMENT_HIDE, - PERMISSION_VOTE_SET, - PERMISSION_COLLECTION_CREATE, - PERMISSION_COLLECTION_UPDATE, - PERMISSION_COLLECTION_DELETE, - ], - ROLE_MODERATOR: [ - PERMISSION_EDIT_CREATE, - PERMISSION_EDIT_ACCEPT, - PERMISSION_EDIT_REJECT, - PERMISSION_EDIT_UPDATE, - PERMISSION_EDIT_CLOSE, - PERMISSION_EDIT_AUTO, - PERMISSION_EDIT_UPDATE_MODERATOR, - PERMISSION_UPLOAD_AVATAR, - PERMISSION_UPLOAD_COVER, - PERMISSION_COMMENT_WRITE, - PERMISSION_COMMENT_EDIT, - PERMISSION_COMMENT_HIDE, - PERMISSION_COMMENT_HIDE_ADMIN, - PERMISSION_VOTE_SET, - PERMISSION_COLLECTION_CREATE, - PERMISSION_COLLECTION_UPDATE, - PERMISSION_COLLECTION_DELETE, - PERMISSION_COLLECTION_UPDATE_MODERATOR, - PERMISSION_COLLECTION_DELETE_MODERATOR, - ], - ROLE_ADMIN: [ - PERMISSION_EDIT_CREATE, - PERMISSION_EDIT_ACCEPT, - PERMISSION_EDIT_REJECT, - PERMISSION_EDIT_UPDATE, - PERMISSION_EDIT_CLOSE, - PERMISSION_EDIT_AUTO, - PERMISSION_EDIT_UPDATE_MODERATOR, - PERMISSION_UPLOAD_AVATAR, - PERMISSION_UPLOAD_COVER, - PERMISSION_COMMENT_WRITE, - PERMISSION_COMMENT_EDIT, - PERMISSION_COMMENT_HIDE, - PERMISSION_COMMENT_HIDE_ADMIN, - PERMISSION_VOTE_SET, - PERMISSION_COLLECTION_CREATE, - PERMISSION_COLLECTION_UPDATE, - PERMISSION_COLLECTION_DELETE, - PERMISSION_COLLECTION_UPDATE_MODERATOR, - PERMISSION_COLLECTION_DELETE_MODERATOR, - ], + ROLE_USER: USER_PERMISSIONS, + ROLE_MODERATOR: MODERATOR_PERMISSIONS, + ROLE_ADMIN: ADMIN_PERMISSIONS, ROLE_NOT_ACTIVATED: [ PERMISSION_UPLOAD_AVATAR, PERMISSION_UPLOAD_COVER, diff --git a/app/dependencies.py b/app/dependencies.py index fb92002a..ff2195e7 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,8 +1,8 @@ from fastapi import Header, Cookie, Query, Depends from sqlalchemy.ext.asyncio import AsyncSession +from app.models import User, Anime, AuthToken from app.database import get_session from app.utils import get_settings -from app.models import User, Anime from datetime import timedelta from typing import Annotated from app.errors import Abort @@ -64,36 +64,61 @@ async def get_request_auth_token( return header_auth if header_auth else cookie_auth -# Check user auth token -def auth_required(permissions: list = [], optional: bool = False): - async def auth( - auth_token: str = Depends(get_request_auth_token), - session: AsyncSession = Depends(get_session), - ) -> User | None: - error = None +async def _auth_token_or_abort( + session: AsyncSession = Depends(get_session), + token: str | None = Depends(get_request_auth_token), +) -> Abort | AuthToken: + if not token: + return Abort("auth", "missing-token") + + if not (token := await get_auth_token(session, token)): + return Abort("auth", "invalid-token") + + if not token.user: + return Abort("auth", "user-not-found") + + if token.user.banned: + return Abort("auth", "banned") + + return token + - if not auth_token: - error = Abort("auth", "missing-token") +async def auth_token_required( + token: AuthToken | Abort = Depends(_auth_token_or_abort), +) -> AuthToken: + if isinstance(token, Abort): + raise token - if not error and not ( - token := await get_auth_token(session, auth_token) - ): - error = Abort("auth", "invalid-token") + return token - if not error and not token.user: - error = Abort("auth", "user-not-found") - if not error and token.user.banned: - error = Abort("auth", "banned") +# Check user auth token +def auth_required(permissions: list = None, scope: list = None, optional: bool = False): + """ + Authorization dependency with permission check + + If optional set to True and token not provided or invalid - returns None + If optional set to False and token not provided or invalid - raises abort + + If token provided and valid - returns user from token + """ + if not permissions: + permissions = [] - # If optional set to true folowing checks would fail silently by returning None - # I really hate this if statement but idea of creating separade dependency - # for optional auth I hate even more - if error: + if not scope: + scope = [] + + async def auth( + token: AuthToken | Abort = Depends(_auth_token_or_abort), + session: AsyncSession = Depends(get_session), + ) -> User | None: + if isinstance(token, Abort): + # If authorization is optional - ignore abort and return None if optional: return None - else: - raise error + + # If authorization is required - raise abort + raise token now = utcnow() @@ -104,6 +129,9 @@ async def auth( if not utils.check_user_permissions(token.user, permissions): raise Abort("permission", "denied") + if not utils.check_token_scope(token, scope): + raise Abort("scope", "denied") + if token.user.role == constants.ROLE_DELETED: raise Abort("user", "deleted") diff --git a/app/errors.py b/app/errors.py index 9c8c27ba..2206f2a1 100644 --- a/app/errors.py +++ b/app/errors.py @@ -14,9 +14,12 @@ class ErrorResponse(CustomModel): "auth": { "activation-valid": ["Previous activation token still valid", 400], "reset-valid": ["Previous password reset token still valid", 400], + "invalid-client-credentials": ["Invalid client credentials", 400], "email-exists": ["User with that email already exists", 400], "activation-expired": ["Activation token has expired", 400], + "token-request-expired": ["Token request has expired", 400], "activation-invalid": ["Activation token is invalid", 400], + "invalid-token-request": ["Invalid token request", 400], "oauth-code-required": ["OAuth code required", 400], "invalid-provider": ["Invalid OAuth provider", 400], "username-taken": ["Username already taken", 400], @@ -27,13 +30,16 @@ class ErrorResponse(CustomModel): "missing-token": ["Auth token is missing", 400], "invalid-password": ["Invalid password", 400], "username-set": ["Username already set", 400], + "client-not-found": ["Client not found", 404], "token-expired": ["Token has expired", 400], "invalid-code": ["Invalid OAuth code", 400], "oauth-error": ["Error during OAuth", 400], "user-not-found": ["User not found", 404], + "invalid-scope": ["Invalid scope", 400], "email-set": ["Email already set", 400], "not-available": ["Signup not available ", 400], "invalid-username": ["Invalid username", 400], + "scope-empty": ["Scope empty", 400], }, "settings": { "username-cooldown": ["Username can be changed once per hour", 400], @@ -137,7 +143,7 @@ class ErrorResponse(CustomModel): }, "upload": { "rate-limit": ["You have reached upload rate limit, try later", 400], - "not-square": ["Image shoudld be square", 400], + "not-square": ["Image should be square", 400], "bad-resolution": ["Bad resolution", 400], "bad-mime": ["Don't be bad mime", 400], "bad-size": ["Bad file size", 400], @@ -155,10 +161,10 @@ class ErrorResponse(CustomModel): "bad-order-not-consecutive": ["Order must be consecutive", 400], "bad-order-duplicated": ["You can't set duplicated order", 400], "empty-content-type": ["Content type is not specified", 400], - "content-limit": ["Collectio content limit violation", 400], + "content-limit": ["Collection content limit violation", 400], "limit": ["You have reached collections limit", 400], "bad-order-start": ["Order must start from 1", 400], - "unlabled-content": ["Unlabled content", 400], + "unlabled-content": ["Unlabeled content", 400], "bad-labels-order": ["Bad labels order", 400], "author-not-found": ["Author not found", 404], "not-found": ["Collection not found", 404], @@ -184,6 +190,9 @@ class ErrorResponse(CustomModel): "system": { "bad-backup-token": ["Bad backup token", 401], }, + "client": { + "not-found": ["Client not found", 404] + } } diff --git a/app/models/__init__.py b/app/models/__init__.py index cfa4be1b..8d2489c8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,7 @@ +from .auth.auth_token_request import AuthTokenRequest from .auth.email_message import EmailMessage from .auth.auth_token import AuthToken +from .auth.client import Client from .user.history import FavouriteAnimeRemoveHistory @@ -96,8 +98,10 @@ from .base import Base __all__ = [ + "AuthTokenRequest", "EmailMessage", "AuthToken", + "Client", "FavouriteAnimeRemoveHistory", "FavouriteMangaRemoveHistory", "FavouriteNovelRemoveHistory", diff --git a/app/models/auth/auth_token.py b/app/models/auth/auth_token.py index e033c72c..6a81a6f7 100644 --- a/app/models/auth/auth_token.py +++ b/app/models/auth/auth_token.py @@ -1,3 +1,4 @@ +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy import String, ForeignKey from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship @@ -16,3 +17,12 @@ class AuthToken(Base): user_id = mapped_column(ForeignKey("service_users.id")) user: Mapped["User"] = relationship(back_populates="auth_tokens") + + client_id = mapped_column( + ForeignKey("service_clients.id", ondelete="CASCADE"), nullable=True + ) + client: Mapped["Client"] = relationship(back_populates="auth_tokens") + + # Scope required only for third-party clients + # to allow access to requested data + scope: Mapped[list[str]] = mapped_column(JSONB, server_default="[]") diff --git a/app/models/auth/auth_token_request.py b/app/models/auth/auth_token_request.py new file mode 100644 index 00000000..4c98c50e --- /dev/null +++ b/app/models/auth/auth_token_request.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import ForeignKey + +from ..base import Base + + +class AuthTokenRequest(Base): + __tablename__ = "service_auth_token_requests" + + expiration: Mapped[datetime] + created: Mapped[datetime] + + user_id = mapped_column(ForeignKey("service_users.id")) + + user: Mapped["User"] = relationship() + + client_id = mapped_column( + ForeignKey("service_clients.id", ondelete="CASCADE") + ) + client: Mapped["Client"] = relationship() + + scope: Mapped[list[str]] = mapped_column(JSONB) diff --git a/app/models/auth/client.py b/app/models/auth/client.py new file mode 100644 index 00000000..b6d9db1f --- /dev/null +++ b/app/models/auth/client.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey + +from ..base import Base + + +class Client(Base): + __tablename__ = "service_clients" + + secret: Mapped[str] = mapped_column(String(128)) + + name: Mapped[str] + description: Mapped[str] + endpoint: Mapped[str] + + user_id = mapped_column(ForeignKey("service_users.id")) + user: Mapped["User"] = relationship(foreign_keys=user_id) + + auth_tokens: Mapped[list["AuthToken"]] = relationship( + back_populates="client", + ) + + created: Mapped[datetime] diff --git a/app/read/dependencies.py b/app/read/dependencies.py index d42f10a7..4c8fa616 100644 --- a/app/read/dependencies.py +++ b/app/read/dependencies.py @@ -4,6 +4,7 @@ from app.database import get_session from app.errors import Abort from fastapi import Depends +from app import constants from . import service from app.dependencies import ( @@ -31,7 +32,9 @@ async def verify_read_content( async def verify_read( content_type: ReadContentTypeEnum, - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_READLIST]) + ), session: AsyncSession = Depends(get_session), content: Manga | Novel = Depends(verify_read_content), ) -> Read: diff --git a/app/read/router.py b/app/read/router.py index edefc0ef..4a9247bf 100644 --- a/app/read/router.py +++ b/app/read/router.py @@ -52,7 +52,9 @@ async def read_add( content_type: ReadContentTypeEnum, session: AsyncSession = Depends(get_session), content: Manga | Novel = Depends(verify_add_read), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + ), ): return await service.save_read( session, @@ -66,7 +68,9 @@ async def read_add( @router.delete("/{content_type}/{slug}", response_model=SuccessResponse) async def delete_read( session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + ), read: Read = Depends(verify_read), ): await service.delete_read(session, read, user) @@ -81,7 +85,9 @@ async def get_read_following( content_type: ReadContentTypeEnum, session: AsyncSession = Depends(get_session), content: Manga | Novel = Depends(verify_read_content), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_DETAILS]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): diff --git a/app/schemas.py b/app/schemas.py index 32cd6e21..650027c0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -354,6 +354,12 @@ class ReadResponseBase(CustomModel): score: int = Field(examples=[8]) +class ClientResponse(CustomModel): + reference: str + name: str + description: str + + class AnimeResponse(CustomModel, DataTypeMixin): media_type: str | None = Field(examples=["tv"]) title_ua: str | None = Field( diff --git a/app/service.py b/app/service.py index 6874ece2..9322ff23 100644 --- a/app/service.py +++ b/app/service.py @@ -144,7 +144,7 @@ async def get_auth_token( return await session.scalar( select(AuthToken) .filter(AuthToken.secret == secret) - .options(selectinload(AuthToken.user)) + .options(selectinload(AuthToken.user), selectinload(AuthToken.client)) ) diff --git a/app/settings/router.py b/app/settings/router.py index 0a9645c8..400dd4fc 100644 --- a/app/settings/router.py +++ b/app/settings/router.py @@ -46,7 +46,9 @@ async def change_description( args: DescriptionArgs, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): return await service.change_description(session, user, args.description) @@ -59,7 +61,9 @@ async def change_description( async def change_password( args: PasswordArgs, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): return await service.set_password(session, user, args.password) @@ -72,7 +76,9 @@ async def change_password( async def change_username( session: AsyncSession = Depends(get_session), args: UsernameArgs = Depends(validate_set_username), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): return await service.set_username(session, user, args.username) @@ -85,7 +91,9 @@ async def change_username( async def change_email( session: AsyncSession = Depends(get_session), args: EmailArgs = Depends(validate_set_email), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): user = await service.set_email(session, user, args.email) user = await create_activation_token(session, user) @@ -110,7 +118,9 @@ async def import_watch( args: ImportWatchListArgs, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + ), ): # Run watch list import in background # This task may block event loop so we should keep that in mind @@ -134,7 +144,9 @@ async def import_read( args: ImportReadListArgs, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + ), ): # Run watch list import in background # This task may block event loop so we should keep that in mind @@ -157,7 +169,9 @@ async def import_read( async def change_ignored_notifications( args: IgnoredNotificationsArgs, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): return await service.set_ignored_notifications( session, user, args.ignored_notifications @@ -169,7 +183,11 @@ async def change_ignored_notifications( response_model=IgnoredNotificationsResponse, summary="Get ignored notification types", ) -async def get_ignored_notifications(user: User = Depends(auth_required())): +async def get_ignored_notifications( + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_DETAILS]) + ), +): return user @@ -181,7 +199,9 @@ async def get_ignored_notifications(user: User = Depends(auth_required())): async def delete_user_image( image_type: ImageTypeEnum, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + ), ): return await service.delete_user_image(session, user, image_type) @@ -194,7 +214,9 @@ async def delete_user_image( async def delete_user_watch( background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + ), ): # Run watch list import in background # This task may block event loop so we should keep that in mind @@ -217,7 +239,9 @@ async def delete_user_read( content_type: ReadDeleteContenType, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + ), ): # Run watch list import in background # This task may block event loop so we should keep that in mind diff --git a/app/sync/__init__.py b/app/sync/__init__.py index ee834e05..7e22091b 100644 --- a/app/sync/__init__.py +++ b/app/sync/__init__.py @@ -1,3 +1,5 @@ +from .token_requests import delete_expired_token_requests + from .aggregator.franchises import aggregator_franchises from .aggregator.characters import aggregator_characters from .aggregator.info.anime import aggregator_anime_info @@ -34,7 +36,9 @@ from .email import send_emails + __all__ = [ + "delete_expired_token_requests", "aggregator_franchises", "aggregator_anime_info", "aggregator_manga_info", diff --git a/app/sync/token_requests.py b/app/sync/token_requests.py new file mode 100644 index 00000000..0b0d8994 --- /dev/null +++ b/app/sync/token_requests.py @@ -0,0 +1,13 @@ +from sqlalchemy import delete + +from app.models import AuthTokenRequest +from app import sessionmanager +from app.utils import utcnow + + +async def delete_expired_token_requests(): + now = utcnow() + async with sessionmanager.session() as session: + await session.execute( + delete(AuthTokenRequest).filter(AuthTokenRequest.expiration < now) + ) diff --git a/app/upload/dependencies.py b/app/upload/dependencies.py index f2736fa9..95c56fed 100644 --- a/app/upload/dependencies.py +++ b/app/upload/dependencies.py @@ -17,7 +17,9 @@ async def validate_upload_rate_limit( upload_type: UploadTypeEnum, session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPLOAD]) + ), ): upload_permissions = None diff --git a/app/user/router.py b/app/user/router.py index 8e17a754..52dba138 100644 --- a/app/user/router.py +++ b/app/user/router.py @@ -26,7 +26,11 @@ response_model=UserResponse, summary="Current user profile", ) -async def profile(user: User = Depends(auth_required())): +async def profile( + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_DETAILS]) + ), +): return user diff --git a/app/utils.py b/app/utils.py index 91330c49..94bc0f35 100644 --- a/app/utils.py +++ b/app/utils.py @@ -3,6 +3,7 @@ from fastapi.responses import JSONResponse from fastapi import FastAPI, Request from datetime import datetime, UTC +from app.models import AuthToken from functools import lru_cache from urllib.parse import quote from dynaconf import Dynaconf @@ -62,6 +63,12 @@ def check_user_permissions(user: User, permissions: list): return has_permission +def check_token_scope(token: AuthToken, scope: list[str]) -> bool: + if not token.scope: + return True + + return set(token.scope).issuperset(set(scope)) + # Get bcrypt hash of password def hashpwd(password: str) -> str: diff --git a/app/watch/dependencies.py b/app/watch/dependencies.py index e26bd6da..02badf34 100644 --- a/app/watch/dependencies.py +++ b/app/watch/dependencies.py @@ -6,13 +6,16 @@ from app.database import get_session from app.errors import Abort from fastapi import Depends +from app import constants from typing import Tuple from . import service async def verify_watch( anime: Anime = Depends(get_anime), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_WATCHLIST]) + ), session: AsyncSession = Depends(get_session), ) -> AnimeWatch: if not (watch := await get_anime_watch(session, anime, user)): @@ -24,7 +27,9 @@ async def verify_watch( async def verify_add_watch( args: WatchArgs, anime: Anime = Depends(get_anime), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + ), ) -> Tuple[Anime, User, WatchArgs]: # TODO: We probably should add anime.episodes_released here # TODO: Ideally we need to check anime status diff --git a/app/watch/router.py b/app/watch/router.py index 841ae70d..f69f7c67 100644 --- a/app/watch/router.py +++ b/app/watch/router.py @@ -4,6 +4,7 @@ from app.dependencies import auth_required from fastapi import APIRouter, Depends from app.database import get_session +from app import constants from typing import Tuple from . import service @@ -35,7 +36,6 @@ verify_watch, ) - router = APIRouter(prefix="/watch", tags=["Watch"]) @@ -65,7 +65,9 @@ async def delete_watch( @router.get("/{slug}/following", response_model=UserWatchPaginationResponse) async def get_watch_following( session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_USER_DETAILS]) + ), anime: Anime = Depends(get_anime), page: int = Depends(get_page), size: int = Depends(get_size), diff --git a/sync.py b/sync.py index d6ae5711..4bebf302 100644 --- a/sync.py +++ b/sync.py @@ -4,6 +4,7 @@ import asyncio from app.sync import ( + delete_expired_token_requests, update_notifications, update_ranking_all, update_activity, @@ -21,6 +22,7 @@ def init_scheduler(): settings = get_settings() sessionmanager.init(settings.database.endpoint) + scheduler.add_job(delete_expired_token_requests, "interval", seconds=5) scheduler.add_job(update_notifications, "interval", seconds=10) scheduler.add_job(update_ranking_all, "interval", hours=1) scheduler.add_job(update_activity, "interval", seconds=10) diff --git a/tests/auth/test_auth_thirdparty.py b/tests/auth/test_auth_thirdparty.py new file mode 100644 index 00000000..4fd68881 --- /dev/null +++ b/tests/auth/test_auth_thirdparty.py @@ -0,0 +1,51 @@ +from starlette import status + +from app import constants + +from tests.client_requests import ( + request_auth_token_request, + request_client_create, + request_auth_token, + request_auth_info, +) + + +async def test_auth_thirdparty(client, test_token): + name = "thirdparty-client" + description = "Third-party client" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + client_info = response.json() + + client_reference = client_info["reference"] + client_secret = client_info["secret"] + + response = await request_auth_token_request( + client, + test_token, + client_reference, + [constants.SCOPE_READ_USER_DETAILS], + ) + + assert response.status_code == status.HTTP_200_OK + + request_reference = response.json()["reference"] + + response = await request_auth_token( + client, request_reference, client_secret + ) + assert response.status_code == status.HTTP_200_OK + + thirdparty_token = await response.json()["secret"] + + response = await request_auth_info(client, thirdparty_token) + assert response.status_code == status.HTTP_200_OK + + auth_info = response.json() + + assert auth_info["client"]["reference"] == client_reference diff --git a/tests/client/test_client_create.py b/tests/client/test_client_create.py new file mode 100644 index 00000000..4e1f341d --- /dev/null +++ b/tests/client/test_client_create.py @@ -0,0 +1,51 @@ +from starlette import status + +from tests.client_requests import request_client_create + + +async def test_client_create(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + response = await request_client_create( + client, + test_token, + name, + description, + endpoint, + ) + assert response.status_code == status.HTTP_200_OK + + created_client = response.json() + + assert created_client["name"] == name + assert created_client["description"] == description + assert created_client["endpoint"] == endpoint + assert len(created_client["secret"]) == 128 + + +async def test_client_create_invalid_endpoint(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "invalid-endpoint" + response = await request_client_create( + client, test_token, name, description, endpoint + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +async def test_client_create_double(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == "client:already_exists" diff --git a/tests/client/test_client_delete.py b/tests/client/test_client_delete.py new file mode 100644 index 00000000..60c18bc6 --- /dev/null +++ b/tests/client/test_client_delete.py @@ -0,0 +1,32 @@ +from starlette import status + +from tests.client_requests import ( + request_my_client_info, + request_client_create, + request_client_delete, +) + + +async def test_client_delete(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + response = await request_client_delete(client, test_token) + assert response.status_code == status.HTTP_200_OK + + response = await request_my_client_info(client, test_token) + assert response.status_code == status.HTTP_404_NOT_FOUND + + assert response.json()["code"] == "client:not_found" + + +async def test_client_delete_nonexistent(client, test_token): + response = await request_client_delete(client, test_token) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["code"] == "client:not_found" diff --git a/tests/client/test_client_info.py b/tests/client/test_client_info.py new file mode 100644 index 00000000..85010484 --- /dev/null +++ b/tests/client/test_client_info.py @@ -0,0 +1,51 @@ +from starlette import status + +from tests.client_requests import ( + request_my_client_info, + request_client_create, + request_client_info, +) + + +async def test_my_client_info_nonexistent(client, test_token): + response = await request_my_client_info(client, test_token) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_my_client_info_existent(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + response = await request_my_client_info(client, test_token) + assert response.status_code == status.HTTP_200_OK + + client_info = response.json() + assert client_info["name"] == name + assert client_info["description"] == description + assert client_info["endpoint"] == endpoint + + +async def test_client_info_by_reference(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + client_reference = response.json()["reference"] + + response = await request_client_info(client, client_reference) + assert response.status_code == status.HTTP_200_OK + + client_info = response.json() + + assert client_info["name"] == name + assert client_info["description"] == description diff --git a/tests/client/test_client_update.py b/tests/client/test_client_update.py new file mode 100644 index 00000000..0f32b012 --- /dev/null +++ b/tests/client/test_client_update.py @@ -0,0 +1,49 @@ +from starlette import status + +from tests.client_requests import request_client_create, request_client_update + + +async def test_client_update(client, test_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + old_client_info = response.json() + assert old_client_info["name"] == name + assert old_client_info["description"] == description + assert old_client_info["endpoint"] == endpoint + + new_name = "test-client-updated" + new_description = "test client description updated" + new_endpoint = "http://localhost/updated" + + response = await request_client_update( + client, + test_token, + new_name, + new_description, + new_endpoint, + revoke_secret=True, + ) + assert response.status_code == status.HTTP_200_OK + + new_client_info = response.json() + assert new_client_info["name"] == new_name + assert new_client_info["description"] == new_description + assert new_client_info["endpoint"] == new_endpoint + + assert new_client_info["secret"] != old_client_info["secret"] + + +async def test_client_update_non_existent(client, test_token): + response = await request_client_update( + client, test_token, revoke_secret=True + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + assert response.json()["code"] == "client:not_found" diff --git a/tests/client_requests/__init__.py b/tests/client_requests/__init__.py index faeffd6e..65d07c81 100644 --- a/tests/client_requests/__init__.py +++ b/tests/client_requests/__init__.py @@ -1,10 +1,19 @@ +from .auth import request_auth_token_request from .auth import request_activation_resend from .auth import request_password_confirm from .auth import request_password_reset from .auth import request_activation +from .auth import request_auth_token +from .auth import request_auth_info from .auth import request_signup from .auth import request_login +from .client import request_my_client_info +from .client import request_client_create +from .client import request_client_update +from .client import request_client_delete +from .client import request_client_info + from .oauth import request_oauth_post from .oauth import request_oauth_url @@ -106,12 +115,20 @@ from .system import request_backup_images __all__ = [ + "request_auth_token_request", "request_activation_resend", "request_password_confirm", "request_password_reset", "request_activation", + "request_auth_token", + "request_auth_info", "request_signup", "request_login", + "request_my_client_info", + "request_client_create", + "request_client_update", + "request_client_delete", + "request_client_info", "request_oauth_post", "request_oauth_url", "request_profile", diff --git a/tests/client_requests/auth.py b/tests/client_requests/auth.py index 9e909133..f2e0c533 100644 --- a/tests/client_requests/auth.py +++ b/tests/client_requests/auth.py @@ -44,3 +44,30 @@ def request_password_confirm(client, token, new_password): "/auth/password/confirm", json={"token": token, "password": new_password}, ) + + +def request_auth_info(client, token: str): + return client.get( + "/auth/info", + headers={"Auth": token}, + ) + +def request_auth_token_request( + client, + token: str, + client_reference: str, + scope: list[str] +): + return client.post( + f"/auth/token/request/{client_reference}", + json={"scope": scope}, + headers={"Auth": token}, + ) + +def request_auth_token(client, request_reference: str, client_secret: str): + return client.post( + "/auth/token", + json={"request_reference": request_reference, "client_secret": client_secret}, + + + ) diff --git a/tests/client_requests/client.py b/tests/client_requests/client.py new file mode 100644 index 00000000..7e536690 --- /dev/null +++ b/tests/client_requests/client.py @@ -0,0 +1,50 @@ +def request_client_create(client, token: str, name: str, description: str, endpoint: str): + return client.post( + "/client/", + headers={"Auth": token}, + json={ + "name": name, + "description": description, + "endpoint": endpoint, + } + ) + + +def request_my_client_info(client, token: str): + return client.get( + "/client/", + headers={"Auth": token} + ) + +def request_client_info(client, reference: str): + return client.get( + f"/client/{reference}" + ) + +def request_client_update( + client, + token: str, + name: str | None = None, + description: str | None = None, + endpoint: str | None = None, + revoke_secret: bool = False +): + return client.put( + "/client/", + headers={"Auth": token}, + json={ + "name": name, + "description": description, + "endpoint": endpoint, + "revoke_secret": revoke_secret + } + ) + + +def request_client_delete( + client, token: str +): + return client.delete( + "/client/", + headers={"Auth": token} + ) diff --git a/tests/conftest.py b/tests/conftest.py index 19e58895..d9606e0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,10 +89,15 @@ async def test_session(): @pytest.fixture -async def create_test_user(test_session): +async def test_user(test_session): return await helpers.create_user(test_session) +@pytest.fixture +async def create_test_user(test_user): + return test_user + + @pytest.fixture async def create_test_user_oauth(test_session): return await helpers.create_user(test_session, email="testuser@mail.com") @@ -135,14 +140,19 @@ async def create_test_user_with_oauth(test_session): @pytest.fixture -async def get_test_token(test_session): +async def test_token(test_user, test_session): token = await helpers.create_token( - test_session, "user@mail.com", "SECRET_TOKEN" + test_session, test_user.email, "SECRET_TOKEN" ) return token.secret +@pytest.fixture +async def get_test_token(test_token): + return test_token + + @pytest.fixture async def get_dummy_token(test_session): token = await helpers.create_token( From ec201248bc7f0ded38cf5a6b54dc522059bf7d90 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Fri, 26 Jul 2024 21:42:51 +0300 Subject: [PATCH 07/44] Up to 15 clients per user, new fields in ClientResponse Add: "updated" column to Client Add: "created", "updated" and "user" fields to ClientResponse Add: Users now can create up to 15 clients Fix: incorrect type for fields that requires uuid (str -> UUID) Fix: name conflict with added TokenArgs (added TokenArgs -> TokenProceedArgs) Fix: dict cannot be used in await expression --- ...-96c0d6b7aeba_add_client_updated_column.py | 28 +++++++++ app/auth/dependencies.py | 8 ++- app/auth/schemas.py | 5 +- app/client/dependencies.py | 35 +++++++----- app/client/router.py | 44 +++++++++++--- app/client/schemas.py | 7 ++- app/client/service.py | 57 +++++++++++++++++-- app/constants.py | 2 + app/errors.py | 4 +- app/models/auth/client.py | 1 + app/schemas.py | 17 ++++-- app/upload/dependencies.py | 4 +- tests/auth/test_auth_thirdparty.py | 2 +- tests/client/test_client_create.py | 6 +- tests/client/test_client_delete.py | 15 +++-- tests/client/test_client_info.py | 25 +++++--- tests/client/test_client_list.py | 28 +++++++++ tests/client/test_client_update.py | 9 ++- tests/client_requests/__init__.py | 6 +- tests/client_requests/client.py | 18 ++++-- 20 files changed, 252 insertions(+), 69 deletions(-) create mode 100644 alembic/versions/2024_07_26_2028-96c0d6b7aeba_add_client_updated_column.py create mode 100644 tests/client/test_client_list.py diff --git a/alembic/versions/2024_07_26_2028-96c0d6b7aeba_add_client_updated_column.py b/alembic/versions/2024_07_26_2028-96c0d6b7aeba_add_client_updated_column.py new file mode 100644 index 00000000..0ecdd30e --- /dev/null +++ b/alembic/versions/2024_07_26_2028-96c0d6b7aeba_add_client_updated_column.py @@ -0,0 +1,28 @@ +"""Add client updated column + +Revision ID: 96c0d6b7aeba +Revises: 21009df4f5f5 +Create Date: 2024-07-26 20:28:17.602711 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '96c0d6b7aeba' +down_revision = '21009df4f5f5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('service_clients', sa.Column('updated', sa.DateTime(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('service_clients', 'updated') + # ### end Alembic commands ### diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index c3f0685f..c0c36dd9 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -8,6 +8,7 @@ from fastapi import Depends from app import constants from . import oauth +import uuid from app.utils import ( is_protected_username, @@ -30,11 +31,12 @@ from .schemas import ( ComfirmResetArgs, + TokenRequestArgs, + TokenProceedArgs, SignupArgs, LoginArgs, TokenArgs, CodeArgs, - TokenRequestArgs, ) @@ -203,7 +205,7 @@ async def validate_password_confirm( async def validate_client( - client_reference: str, session: AsyncSession = Depends(get_session) + client_reference: uuid.UUID, session: AsyncSession = Depends(get_session) ) -> Client: if not (client := await get_client(session, client_reference)): raise Abort("auth", "client-not-found") @@ -223,7 +225,7 @@ def validate_scope(request: TokenRequestArgs) -> list[str]: async def validate_auth_token_request( - args: TokenArgs, + args: TokenProceedArgs, session: AsyncSession = Depends(get_session), ): now = utcnow() diff --git a/app/auth/schemas.py b/app/auth/schemas.py index 4411bbd5..04cec703 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -1,5 +1,6 @@ from app.schemas import datetime_pd, ClientResponse from pydantic import Field +import uuid from app.schemas import ( UsernameArgs, @@ -59,6 +60,6 @@ class TokenRequestArgs(CustomModel): scope: list[str] -class TokenArgs(CustomModel): - request_reference: str +class TokenProceedArgs(CustomModel): + request_reference: uuid.UUID client_secret: str diff --git a/app/client/dependencies.py b/app/client/dependencies.py index 216dd7ee..bc7d2f7b 100644 --- a/app/client/dependencies.py +++ b/app/client/dependencies.py @@ -1,5 +1,5 @@ +import uuid from sqlalchemy.ext.asyncio import AsyncSession -from starlette.datastructures import URL from fastapi import Depends from app.dependencies import auth_required @@ -7,38 +7,43 @@ from app.models import User, Client from .schemas import ClientCreate from app.errors import Abort +from app import constants from . import service -async def user_client_required( - user: User = Depends(auth_required()), - session: AsyncSession = Depends(get_session), -) -> Client: - if (client := await service.get_user_client(session, user)) is None: - raise Abort("client", "not-found") - - return client - - async def validate_client_create( create: ClientCreate, user: User = Depends(auth_required()), session: AsyncSession = Depends(get_session), ): - if (await service.get_user_client(session, user)) is not None: + if (await service.get_user_client(session, user, create.name)) is not None: raise Abort("client", "already-exists") - if URL(create.endpoint): - pass + if ( + await service.count_user_clients( + session, user, 0, constants.MAX_USER_CLIENTS + ) + ) == constants.MAX_USER_CLIENTS: + raise Abort("client", "max-clients") return create async def validate_client( - client_reference: str, + client_reference: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Client: if not (client := await service.get_client(session, client_reference)): raise Abort("client", "not-found") return client + + +async def validate_user_client( + client: Client = Depends(validate_client), + user: User = Depends(auth_required()), +): + if client.user_id != user.id: + raise Abort("client", "not-owner") + + return client diff --git a/app/client/router.py b/app/client/router.py index 44d5c17e..93bcff49 100644 --- a/app/client/router.py +++ b/app/client/router.py @@ -1,7 +1,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends -from app.dependencies import auth_required +from app.dependencies import auth_required, get_page, get_size +from app.utils import pagination, pagination_dict from app.schemas import ClientResponse from app.database import get_session from app.models import Client, User @@ -10,10 +11,11 @@ from app.client.dependencies import ( validate_client_create, - user_client_required, + validate_user_client, validate_client, ) from app.client.schemas import ( + ClientPaginationResponse, ClientFullResponse, ClientCreate, ClientUpdate, @@ -22,9 +24,24 @@ router = APIRouter(prefix="/client", tags=["Client"]) -@router.get("/", summary="Get user client", response_model=ClientFullResponse) -async def get_user_client(client: Client = Depends(user_client_required)): - return client +@router.get( + "/", summary="List user clients", response_model=ClientPaginationResponse +) +async def list_user_clients( + page: int = Depends(get_page), + size: int = Depends(get_size), + session: AsyncSession = Depends(get_session), + user: User = Depends(auth_required()), +): + limit, offset = pagination(page, size) + + total = await service.count_user_clients(session, user, offset, limit) + clients = await service.list_user_clients(session, user, offset, limit) + + return { + "pagination": pagination_dict(total, page, limit), + "list": clients.all(), + } @router.get( @@ -36,6 +53,15 @@ async def get_client_by_reference(client: Client = Depends(validate_client)): return client +@router.get( + "/{client_reference}/full", + summary="Get user full client by reference", + response_model=ClientFullResponse, +) +async def get_user_client(client: Client = Depends(validate_user_client)): + return client + + @router.post( "/", summary="Create new user client", response_model=ClientFullResponse ) @@ -50,7 +76,7 @@ async def create_user_client( @router.put( - "/", + "/{client_reference}", summary="Update user client", response_model=ClientFullResponse, dependencies=[ @@ -60,13 +86,13 @@ async def create_user_client( async def update_user_client( update: ClientUpdate, session: AsyncSession = Depends(get_session), - client: Client = Depends(user_client_required), + client: Client = Depends(validate_user_client), ): return await service.update_client(session, client, update) @router.delete( - "/", + "/{client_reference}", summary="Delete user client", response_model=ClientFullResponse, dependencies=[ @@ -75,6 +101,6 @@ async def update_user_client( ) async def delete_user_client( session: AsyncSession = Depends(get_session), - client: Client = Depends(user_client_required), + client: Client = Depends(validate_user_client), ): return await service.delete_client(session, client) diff --git a/app/client/schemas.py b/app/client/schemas.py index b01532df..a87af755 100644 --- a/app/client/schemas.py +++ b/app/client/schemas.py @@ -1,6 +1,6 @@ from pydantic import Field, HttpUrl -from app.schemas import CustomModel, ClientResponse +from app.schemas import CustomModel, ClientResponse, PaginationResponse class ClientFullResponse(ClientResponse): @@ -8,6 +8,11 @@ class ClientFullResponse(ClientResponse): endpoint: str +class ClientPaginationResponse(CustomModel): + pagination: PaginationResponse + list: list[ClientResponse] + + class ClientCreate(CustomModel): name: str = Field( examples=["ThirdPartyWatchlistImporter"], description="Client name" diff --git a/app/client/service.py b/app/client/service.py index 6f4aa743..e2a8fc10 100644 --- a/app/client/service.py +++ b/app/client/service.py @@ -2,7 +2,8 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy.orm import joinedload +from sqlalchemy import select, func, ScalarResult from app.client.schemas import ClientCreate, ClientUpdate from app.models import User, Client @@ -13,13 +14,54 @@ def _client_secret(): return secrets.token_urlsafe(96) -async def get_client(session: AsyncSession, reference: str | uuid.UUID) -> Client: - return await session.scalar(select(Client).filter(Client.id == reference)) +async def get_client( + session: AsyncSession, reference: str | uuid.UUID +) -> Client: + return await session.scalar( + select(Client) + .filter(Client.id == reference) + .options(joinedload(Client.user)) + ) + + +async def get_user_client( + session: AsyncSession, user: User, name: str +) -> Client: + return await session.scalar( + select(Client).filter( + Client.user_id == user.id, func.lower(Client.name) == name.lower() + ) + ) + + +async def list_user_clients( + session: AsyncSession, + user: User, + offset: int, + limit: int, +) -> ScalarResult[Client]: + return await session.scalars( + select(Client) + .filter( + Client.user_id == user.id, + ) + .offset(offset) + .limit(limit) + .order_by(Client.created.asc()) + ) -async def get_user_client(session: AsyncSession, user: User) -> Client: +async def count_user_clients( + session: AsyncSession, + user: User, + offset: int, + limit: int, +) -> int: return await session.scalar( - select(Client).filter(Client.user_id == user.id) + select(func.count(Client.id)) + .filter(Client.user_id == user.id) + .offset(offset) + .limit(limit) ) @@ -36,6 +78,7 @@ async def create_user_client( "endpoint": str(create.endpoint), "user_id": user.id, "created": now, + "updated": utcnow(), } ) @@ -48,6 +91,8 @@ async def create_user_client( async def update_client( session: AsyncSession, client: Client, update: ClientUpdate ) -> Client: + now = utcnow() + if update.name is not None: client.name = update.name @@ -60,6 +105,8 @@ async def update_client( if update.revoke_secret is not None: client.secret = _client_secret() + client.updated = now + await session.commit() return client diff --git a/app/constants.py b/app/constants.py index 029f109e..e7ae5529 100644 --- a/app/constants.py +++ b/app/constants.py @@ -99,6 +99,8 @@ SEARCH_RESULT_SIZE = 15 +MAX_USER_CLIENTS = 15 + # Meilisearch index names SEARCH_INDEX_CHARACTERS = "content_characters" SEARCH_INDEX_COMPANIES = "content_companies" diff --git a/app/errors.py b/app/errors.py index 2206f2a1..28755a78 100644 --- a/app/errors.py +++ b/app/errors.py @@ -191,7 +191,9 @@ class ErrorResponse(CustomModel): "bad-backup-token": ["Bad backup token", 401], }, "client": { - "not-found": ["Client not found", 404] + "not-owner": ["User not owner of the client", 400], + "max-clients": ["Maximum clients reached", 400], + "not-found": ["Client not found", 404], } } diff --git a/app/models/auth/client.py b/app/models/auth/client.py index b6d9db1f..44b4dde8 100644 --- a/app/models/auth/client.py +++ b/app/models/auth/client.py @@ -23,3 +23,4 @@ class Client(Base): ) created: Mapped[datetime] + updated: Mapped[datetime] diff --git a/app/schemas.py b/app/schemas.py index 650027c0..9188e956 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -354,12 +354,6 @@ class ReadResponseBase(CustomModel): score: int = Field(examples=[8]) -class ClientResponse(CustomModel): - reference: str - name: str - description: str - - class AnimeResponse(CustomModel, DataTypeMixin): media_type: str | None = Field(examples=["tv"]) title_ua: str | None = Field( @@ -589,3 +583,14 @@ class ContentCharacterResponse(CustomModel): class ContentCharacterPaginationResponse(CustomModel): pagination: PaginationResponse list: list[ContentCharacterResponse] + + +class ClientResponse(CustomModel): + reference: str + name: str + description: str + + user: UserResponse + + created: datetime_pd + updated: datetime_pd diff --git a/app/upload/dependencies.py b/app/upload/dependencies.py index 95c56fed..66499022 100644 --- a/app/upload/dependencies.py +++ b/app/upload/dependencies.py @@ -17,9 +17,7 @@ async def validate_upload_rate_limit( upload_type: UploadTypeEnum, session: AsyncSession = Depends(get_session), - user: User = Depends( - auth_required(scope=[constants.SCOPE_UPLOAD]) - ), + user: User = Depends(auth_required(scope=[constants.SCOPE_UPLOAD])), ): upload_permissions = None diff --git a/tests/auth/test_auth_thirdparty.py b/tests/auth/test_auth_thirdparty.py index 4fd68881..aa78ac00 100644 --- a/tests/auth/test_auth_thirdparty.py +++ b/tests/auth/test_auth_thirdparty.py @@ -41,7 +41,7 @@ async def test_auth_thirdparty(client, test_token): ) assert response.status_code == status.HTTP_200_OK - thirdparty_token = await response.json()["secret"] + thirdparty_token = response.json()["secret"] response = await request_auth_info(client, thirdparty_token) assert response.status_code == status.HTTP_200_OK diff --git a/tests/client/test_client_create.py b/tests/client/test_client_create.py index 4e1f341d..4d3d0f8b 100644 --- a/tests/client/test_client_create.py +++ b/tests/client/test_client_create.py @@ -3,7 +3,7 @@ from tests.client_requests import request_client_create -async def test_client_create(client, test_token): +async def test_client_create(client, test_token, test_user): name = "test-client" description = "test client description" endpoint = "http://localhost/" @@ -19,8 +19,10 @@ async def test_client_create(client, test_token): created_client = response.json() assert created_client["name"] == name - assert created_client["description"] == description assert created_client["endpoint"] == endpoint + assert created_client["description"] == description + assert created_client["user"]["username"] == test_user.username + assert len(created_client["secret"]) == 128 diff --git a/tests/client/test_client_delete.py b/tests/client/test_client_delete.py index 60c18bc6..f2054c59 100644 --- a/tests/client/test_client_delete.py +++ b/tests/client/test_client_delete.py @@ -1,7 +1,9 @@ +import uuid + from starlette import status from tests.client_requests import ( - request_my_client_info, + request_client_full_info, request_client_create, request_client_delete, ) @@ -17,16 +19,21 @@ async def test_client_delete(client, test_token): ) assert response.status_code == status.HTTP_200_OK - response = await request_client_delete(client, test_token) + client_reference = response.json()["reference"] + + response = await request_client_delete(client, test_token, client_reference) assert response.status_code == status.HTTP_200_OK - response = await request_my_client_info(client, test_token) + response = await request_client_full_info( + client, test_token, client_reference + ) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json()["code"] == "client:not_found" async def test_client_delete_nonexistent(client, test_token): - response = await request_client_delete(client, test_token) + reference = str(uuid.uuid4()) + response = await request_client_delete(client, test_token, reference) assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json()["code"] == "client:not_found" diff --git a/tests/client/test_client_info.py b/tests/client/test_client_info.py index 85010484..1b9dbdec 100644 --- a/tests/client/test_client_info.py +++ b/tests/client/test_client_info.py @@ -1,18 +1,22 @@ +import uuid + from starlette import status from tests.client_requests import ( - request_my_client_info, + request_client_full_info, request_client_create, request_client_info, ) -async def test_my_client_info_nonexistent(client, test_token): - response = await request_my_client_info(client, test_token) +async def test_client_full_info_nonexistent(client, test_token): + reference = str(uuid.uuid4()) + + response = await request_client_full_info(client, test_token, reference) assert response.status_code == status.HTTP_404_NOT_FOUND -async def test_my_client_info_existent(client, test_token): +async def test_client_full_info(client, test_token, test_user): name = "test-client" description = "test client description" endpoint = "http://localhost/" @@ -22,16 +26,21 @@ async def test_my_client_info_existent(client, test_token): ) assert response.status_code == status.HTTP_200_OK - response = await request_my_client_info(client, test_token) + client_reference = response.json()["reference"] + + response = await request_client_full_info( + client, test_token, client_reference + ) assert response.status_code == status.HTTP_200_OK client_info = response.json() assert client_info["name"] == name - assert client_info["description"] == description assert client_info["endpoint"] == endpoint + assert client_info["description"] == description + assert client_info["user"]["username"] == test_user.username -async def test_client_info_by_reference(client, test_token): +async def test_client_info_by_reference(client, test_token, test_user): name = "test-client" description = "test client description" endpoint = "http://localhost/" @@ -46,6 +55,6 @@ async def test_client_info_by_reference(client, test_token): assert response.status_code == status.HTTP_200_OK client_info = response.json() - assert client_info["name"] == name assert client_info["description"] == description + assert client_info["user"]["username"] == test_user.username diff --git a/tests/client/test_client_list.py b/tests/client/test_client_list.py new file mode 100644 index 00000000..8625cc26 --- /dev/null +++ b/tests/client/test_client_list.py @@ -0,0 +1,28 @@ +from starlette import status + +from tests.client_requests import request_list_clients, request_client_create + + +async def test_client_create(client, test_token, test_user): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + response = await request_client_create( + client, + test_token, + name, + description, + endpoint, + ) + assert response.status_code == status.HTTP_200_OK + + created_client = response.json() + + response = await request_list_clients(client, test_token) + assert response.status_code == status.HTTP_200_OK + + json = response.json() + + assert json["pagination"]["total"] == len(json["list"]) == 1 + + assert json["list"][0]["reference"] == created_client["reference"] diff --git a/tests/client/test_client_update.py b/tests/client/test_client_update.py index 0f32b012..781dacbd 100644 --- a/tests/client/test_client_update.py +++ b/tests/client/test_client_update.py @@ -1,3 +1,5 @@ +import uuid + from starlette import status from tests.client_requests import request_client_create, request_client_update @@ -25,6 +27,7 @@ async def test_client_update(client, test_token): response = await request_client_update( client, test_token, + old_client_info["reference"], new_name, new_description, new_endpoint, @@ -40,9 +43,11 @@ async def test_client_update(client, test_token): assert new_client_info["secret"] != old_client_info["secret"] -async def test_client_update_non_existent(client, test_token): +async def test_client_update_nonexistent(client, test_token): + reference = str(uuid.uuid4()) + response = await request_client_update( - client, test_token, revoke_secret=True + client, test_token, reference, revoke_secret=True ) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/client_requests/__init__.py b/tests/client_requests/__init__.py index 65d07c81..fb165edc 100644 --- a/tests/client_requests/__init__.py +++ b/tests/client_requests/__init__.py @@ -8,10 +8,11 @@ from .auth import request_signup from .auth import request_login -from .client import request_my_client_info +from .client import request_client_full_info from .client import request_client_create from .client import request_client_update from .client import request_client_delete +from .client import request_list_clients from .client import request_client_info from .oauth import request_oauth_post @@ -124,10 +125,11 @@ "request_auth_info", "request_signup", "request_login", - "request_my_client_info", + "request_client_full_info", "request_client_create", "request_client_update", "request_client_delete", + "request_list_clients", "request_client_info", "request_oauth_post", "request_oauth_url", diff --git a/tests/client_requests/client.py b/tests/client_requests/client.py index 7e536690..bd7971da 100644 --- a/tests/client_requests/client.py +++ b/tests/client_requests/client.py @@ -10,9 +10,9 @@ def request_client_create(client, token: str, name: str, description: str, endpo ) -def request_my_client_info(client, token: str): +def request_client_full_info(client, token: str, client_reference: str): return client.get( - "/client/", + f"/client/{client_reference}/full", headers={"Auth": token} ) @@ -24,13 +24,14 @@ def request_client_info(client, reference: str): def request_client_update( client, token: str, + client_reference: str, name: str | None = None, description: str | None = None, endpoint: str | None = None, revoke_secret: bool = False ): return client.put( - "/client/", + f"/client/{client_reference}", headers={"Auth": token}, json={ "name": name, @@ -42,9 +43,16 @@ def request_client_update( def request_client_delete( - client, token: str + client, token: str, client_reference: str ): return client.delete( - "/client/", + f"/client/{client_reference}", headers={"Auth": token} ) + + +def request_list_clients(client, token: str): + return client.get( + "/client/", + headers={"Auth": token}, + ) From 46efcecce535cc007feb34d1c62aaeef5bce60c9 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Sat, 27 Jul 2024 20:04:31 +0300 Subject: [PATCH 08/44] Third-party login notifications and ability to forbid access by third-party clients to endpoints Add: notification about third-party logins Add: forbid_thirdparty parameter to auth_required --- app/auth/router.py | 9 ++- app/auth/schemas.py | 3 +- app/constants.py | 3 + app/dependencies.py | 5 +- app/sync/notifications/__init__.py | 5 ++ app/sync/notifications/generate/__init__.py | 2 + .../generate/thirdparty_login.py | 34 +++++++++ .../test_notification_thirdparty_login.py | 69 +++++++++++++++++++ 8 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 app/sync/notifications/generate/thirdparty_login.py create mode 100644 tests/sync/notifications/test_notification_thirdparty_login.py diff --git a/app/auth/router.py b/app/auth/router.py index 14f70912..79a28547 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -207,7 +207,7 @@ async def auth_info(token: AuthToken = Depends(auth_token_required)): async def request_token( client: Client = Depends(validate_client), scope: list[str] = Depends(validate_scope), - user: User = Depends(auth_required()), + user: User = Depends(auth_required(forbid_thirdparty=True)), session: AsyncSession = Depends(get_session), ): return await service.create_auth_token_request(session, user, client, scope) @@ -222,4 +222,11 @@ async def third_party_auth_token( token_request: AuthTokenRequest = Depends(validate_auth_token_request), session: AsyncSession = Depends(get_session), ): + await create_log( + session, + constants.LOG_LOGIN_THIRDPARTY, + token_request.user, + token_request.client_id, + {"scope": token_request.scope}, + ) return await service.create_auth_token_from_request(session, token_request) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index 04cec703..12d65e50 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -1,3 +1,4 @@ +from app import constants from app.schemas import datetime_pd, ClientResponse from pydantic import Field import uuid @@ -57,7 +58,7 @@ class TokenRequestResponse(CustomModel): class TokenRequestArgs(CustomModel): - scope: list[str] + scope: list[str] = Field(examples=[constants.ALL_SCOPES]) class TokenProceedArgs(CustomModel): diff --git a/app/constants.py b/app/constants.py index e7ae5529..37f2f394 100644 --- a/app/constants.py +++ b/app/constants.py @@ -248,6 +248,7 @@ LOG_SIGNUP = "signup" LOG_LOGIN = "login" LOG_LOGIN_OAUTH = "login_oauth" +LOG_LOGIN_THIRDPARTY = "login_thirdparty" LOG_ACTIVATION = "activation" LOG_ACTIVATION_RESEND = "activation_resend" LOG_PASSWORD_RESET = "password_reset" @@ -311,6 +312,7 @@ NOTIFICATION_HIKKA_UPDATE = "hikka_update" NOTIFICATION_SCHEDULE_ANIME = "schedule_anime" NOTIFICATION_FOLLOW = "follow" +NOTIFICATION_THIRDPARTY_LOGIN = "thirdparty_login" NOTIFICATION_TYPES = [ NOTIFICATION_COMMENT_REPLY, @@ -325,6 +327,7 @@ NOTIFICATION_HIKKA_UPDATE, NOTIFICATION_SCHEDULE_ANIME, NOTIFICATION_FOLLOW, + NOTIFICATION_THIRDPARTY_LOGIN, ] # Activity intervals diff --git a/app/dependencies.py b/app/dependencies.py index ff2195e7..62ed07e0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -93,7 +93,7 @@ async def auth_token_required( # Check user auth token -def auth_required(permissions: list = None, scope: list = None, optional: bool = False): +def auth_required(permissions: list = None, scope: list = None, forbid_thirdparty: bool = False, optional: bool = False): """ Authorization dependency with permission check @@ -129,6 +129,9 @@ async def auth( if not utils.check_user_permissions(token.user, permissions): raise Abort("permission", "denied") + if forbid_thirdparty and token.client: + raise Abort("permission", "denied") + if not utils.check_token_scope(token, scope): raise Abort("scope", "denied") diff --git a/app/sync/notifications/__init__.py b/app/sync/notifications/__init__.py index 1135735c..b2eb6b10 100644 --- a/app/sync/notifications/__init__.py +++ b/app/sync/notifications/__init__.py @@ -6,6 +6,7 @@ from app import constants from .generate import ( + generate_thirdparty_login, generate_collection_vote, generate_anime_schedule, generate_comment_write, @@ -39,6 +40,7 @@ async def generate_notifications(session: AsyncSession): .filter( Log.log_type.in_( [ + constants.LOG_LOGIN_THIRDPARTY, constants.LOG_SCHEDULE_ANIME, constants.LOG_COMMENT_WRITE, constants.LOG_EDIT_UPDATE, @@ -86,6 +88,9 @@ async def generate_notifications(session: AsyncSession): if log.log_type == constants.LOG_FOLLOW: await generate_follow(session, log) + if log.log_type == constants.LOG_LOGIN_THIRDPARTY: + await generate_thirdparty_login(session, log) + session.add(system_timestamp) await session.commit() diff --git a/app/sync/notifications/generate/__init__.py b/app/sync/notifications/generate/__init__.py index c815d5e9..cc9c5d4c 100644 --- a/app/sync/notifications/generate/__init__.py +++ b/app/sync/notifications/generate/__init__.py @@ -1,3 +1,4 @@ +from .thirdparty_login import generate_thirdparty_login from .collection_vote import generate_collection_vote from .anime_schedule import generate_anime_schedule from .comment_write import generate_comment_write @@ -8,6 +9,7 @@ from .follow import generate_follow __all__ = [ + "generate_thirdparty_login", "generate_collection_vote", "generate_anime_schedule", "generate_comment_write", diff --git a/app/sync/notifications/generate/thirdparty_login.py b/app/sync/notifications/generate/thirdparty_login.py new file mode 100644 index 00000000..94b5647b --- /dev/null +++ b/app/sync/notifications/generate/thirdparty_login.py @@ -0,0 +1,34 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app import constants +from app.client.service import get_client +from app.models import Log +from app.models.system.notification import Notification +from app.utils import utcnow + + +async def generate_thirdparty_login(session: AsyncSession, log: Log): + notification_type = constants.NOTIFICATION_THIRDPARTY_LOGIN + + client = await get_client(session, log.target_id) + + notification = Notification( + **{ + "notification_type": notification_type, + "log_id": log.id, + "seen": False, + "user_id": log.user_id, + "created": log.created, + "updated": log.created, + "data": { + "client": { + "name": client.name, + "description": client.description, + "reference": client.reference, + }, + "scope": log.data["scope"], + }, + } + ) + + session.add(notification) diff --git a/tests/sync/notifications/test_notification_thirdparty_login.py b/tests/sync/notifications/test_notification_thirdparty_login.py new file mode 100644 index 00000000..3856fd42 --- /dev/null +++ b/tests/sync/notifications/test_notification_thirdparty_login.py @@ -0,0 +1,69 @@ +from sqlalchemy import func, select +from starlette import status + +from app import constants +from app.models.system.notification import Notification +from app.sync.notifications import generate_notifications + +from tests.client_requests import ( + request_auth_token_request, + request_client_create, + request_auth_token, +) + + +async def test_notification_thirdparty_login(client, test_token, test_session): + name = "thirdparty-client" + description = "Third-party client" + endpoint = "http://localhost/" + scope = [constants.SCOPE_READ_USER_DETAILS] + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + client_info = response.json() + + client_reference = client_info["reference"] + client_secret = client_info["secret"] + + response = await request_auth_token_request( + client, + test_token, + client_reference, + scope, + ) + + assert response.status_code == status.HTTP_200_OK + + request_reference = response.json()["reference"] + + response = await request_auth_token( + client, request_reference, client_secret + ) + assert response.status_code == status.HTTP_200_OK + + await generate_notifications(test_session) + + notifications_count = await test_session.scalar( + select(func.count(Notification.id)).filter( + Notification.notification_type + == constants.NOTIFICATION_THIRDPARTY_LOGIN + ) + ) + + assert notifications_count == 1 + + notification = await test_session.scalar( + select(Notification).filter( + Notification.notification_type + == constants.NOTIFICATION_THIRDPARTY_LOGIN + ) + ) + + assert notification.data["client"]["name"] == name + assert notification.data["client"]["description"] == description + assert notification.data["client"]["reference"] == client_info["reference"] + + assert notification.data["scope"] == scope From 698e2692d0782f0150c2f1b024b3d69dc204ea25 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Sat, 27 Jul 2024 20:38:08 +0300 Subject: [PATCH 09/44] Add: scope aliases --- app/auth/schemas.py | 4 +++- app/constants.py | 46 +++++++++++++++++++++++++++++---------------- app/dependencies.py | 2 ++ app/utils.py | 18 +++++++++++++++++- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index 12d65e50..d56c9830 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -58,7 +58,9 @@ class TokenRequestResponse(CustomModel): class TokenRequestArgs(CustomModel): - scope: list[str] = Field(examples=[constants.ALL_SCOPES]) + scope: list[str] = Field( + examples=[constants.ALL_SCOPES + list(constants.SCOPE_ALIASES)] + ) class TokenProceedArgs(CustomModel): diff --git a/app/constants.py b/app/constants.py index 37f2f394..3215eda3 100644 --- a/app/constants.py +++ b/app/constants.py @@ -131,22 +131,13 @@ CONTENT_COLLECTION = "collection" CONTENT_COMMENT = "comment" -# Roles -# TODO: move to separate file (?) -ROLE_USER = "user" -ROLE_MODERATOR = "moderator" -ROLE_ADMIN = "admin" -ROLE_BANNED = "banned" -ROLE_NOT_ACTIVATED = "not_activated" -ROLE_DELETED = "deleted" - -# User access scope -SCOPE_READ_USER_DETAILS = "user:read:details" -SCOPE_UPDATE_USER_DETAILS = "user:update:details" -SCOPE_READ_USER_WATCHLIST = "user:read:watchlist" -SCOPE_UPDATE_USER_WATCHLIST = "user:update:watchlist" -SCOPE_READ_USER_READLIST = "user:read:readlist" -SCOPE_UPDATE_USER_READLIST = "user:update:readlist" +# Client access scopes +SCOPE_READ_USER_DETAILS = "read:user:details" +SCOPE_UPDATE_USER_DETAILS = "update:user:details" +SCOPE_READ_USER_WATCHLIST = "read:user:watchlist" +SCOPE_UPDATE_USER_WATCHLIST = "update:user:watchlist" +SCOPE_READ_USER_READLIST = "read:user:readlist" +SCOPE_UPDATE_USER_READLIST = "update:user:readlist" SCOPE_UPLOAD = "upload" ALL_SCOPES = [ @@ -159,6 +150,29 @@ SCOPE_UPLOAD, ] +# Not real scopes - will be replaced with simple versions on each scope check +SCOPE_USER_DETAILS = "user:details" +SCOPE_USER_WATCHLIST = "user:watchlist" +SCOPE_USER_READLIST = "user:readlist" +SCOPE_ALL = "all" + +# This aliases will be resolved at token scope checking +SCOPE_ALIASES = { + SCOPE_USER_DETAILS: [SCOPE_READ_USER_DETAILS, SCOPE_UPDATE_USER_DETAILS], + SCOPE_USER_WATCHLIST: [SCOPE_READ_USER_WATCHLIST, SCOPE_UPDATE_USER_WATCHLIST], + SCOPE_USER_READLIST: [SCOPE_READ_USER_READLIST, SCOPE_UPDATE_USER_READLIST], + SCOPE_ALL: ALL_SCOPES +} + +# Roles +# TODO: move to separate file (?) +ROLE_USER = "user" +ROLE_MODERATOR = "moderator" +ROLE_ADMIN = "admin" +ROLE_BANNED = "banned" +ROLE_NOT_ACTIVATED = "not_activated" +ROLE_DELETED = "deleted" + # Permissions PERMISSION_EDIT_CREATE = "edit:create" PERMISSION_EDIT_ACCEPT = "edit:accept" diff --git a/app/dependencies.py b/app/dependencies.py index 62ed07e0..d79d17d3 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -108,6 +108,8 @@ def auth_required(permissions: list = None, scope: list = None, forbid_thirdpart if not scope: scope = [] + scope = utils.resolve_aliased_scopes(scope) + async def auth( token: AuthToken | Abort = Depends(_auth_token_or_abort), session: AsyncSession = Depends(get_session), diff --git a/app/utils.py b/app/utils.py index 94bc0f35..f21b69d0 100644 --- a/app/utils.py +++ b/app/utils.py @@ -64,10 +64,26 @@ def check_user_permissions(user: User, permissions: list): return has_permission def check_token_scope(token: AuthToken, scope: list[str]) -> bool: + token_scope = set(resolve_aliased_scopes(token.scope)) + + scope = set(scope) + if not token.scope: return True - return set(token.scope).issuperset(set(scope)) + return token_scope.issuperset(scope) + + +def resolve_aliased_scopes(scopes: list[str]) -> list[str]: + simplified_scopes = [] + + for scope in scopes: + if scope in constants.SCOPE_ALIASES: + simplified_scopes.extend(constants.SCOPE_ALIASES[scope]) + else: + simplified_scopes.append(scope) + + return simplified_scopes # Get bcrypt hash of password From 0a55ca69c2879ced92db5f8b4c44e508ebaeaa06 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Sun, 28 Jul 2024 09:51:10 +0300 Subject: [PATCH 10/44] Scopes for all endpoints that require authorization and rename scope aliases to scope groups --- app/auth/schemas.py | 2 +- app/characters/router.py | 12 ++- app/client/router.py | 24 ++++- app/collections/router.py | 29 +++++- app/comments/dependencies.py | 12 ++- app/comments/router.py | 13 ++- app/constants.py | 185 +++++++++++++++++++++++++++++++---- app/dependencies.py | 2 +- app/edit/dependencies.py | 5 +- app/edit/router.py | 30 +++++- app/favourite/router.py | 27 ++++- app/follow/router.py | 14 ++- app/history/router.py | 7 +- app/notifications/router.py | 12 ++- app/novel/router.py | 5 +- app/people/router.py | 16 ++- app/read/dependencies.py | 4 +- app/read/router.py | 4 +- app/schedule/router.py | 5 +- app/settings/router.py | 16 +-- app/utils.py | 22 +++-- app/vote/router.py | 11 ++- app/watch/dependencies.py | 8 +- app/watch/router.py | 24 +++-- 24 files changed, 395 insertions(+), 94 deletions(-) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index d56c9830..eb43bcb3 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -59,7 +59,7 @@ class TokenRequestResponse(CustomModel): class TokenRequestArgs(CustomModel): scope: list[str] = Field( - examples=[constants.ALL_SCOPES + list(constants.SCOPE_ALIASES)] + examples=[constants.ALL_SCOPES + list(constants.SCOPE_GROUPS)] ) diff --git a/app/characters/router.py b/app/characters/router.py index 8d4e4b38..bc83cdce 100644 --- a/app/characters/router.py +++ b/app/characters/router.py @@ -82,7 +82,9 @@ async def character_anime( async def character_manga( session: AsyncSession = Depends(get_session), character: Character = Depends(get_character), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_READLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -102,7 +104,9 @@ async def character_manga( async def character_novel( session: AsyncSession = Depends(get_session), character: Character = Depends(get_character), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_READLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -122,7 +126,9 @@ async def character_novel( async def character_voices( session: AsyncSession = Depends(get_session), character: Character = Depends(get_character), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_WATCHLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): diff --git a/app/client/router.py b/app/client/router.py index 93bcff49..69ace74f 100644 --- a/app/client/router.py +++ b/app/client/router.py @@ -31,7 +31,9 @@ async def list_user_clients( page: int = Depends(get_page), size: int = Depends(get_size), session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_CLIENT_LIST]) + ), ): limit, offset = pagination(page, size) @@ -57,6 +59,7 @@ async def get_client_by_reference(client: Client = Depends(validate_client)): "/{client_reference}/full", summary="Get user full client by reference", response_model=ClientFullResponse, + dependencies=[Depends(auth_required(scope=[constants.SCOPE_READ_CLIENT]))], ) async def get_user_client(client: Client = Depends(validate_user_client)): return client @@ -69,7 +72,10 @@ async def create_user_client( create: ClientCreate = Depends(validate_client_create), session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_CLIENT_CREATE]) + auth_required( + permissions=[constants.PERMISSION_CLIENT_CREATE], + scope=[constants.SCOPE_CREATE_CLIENT], + ) ), ): return await service.create_user_client(session, user, create) @@ -80,7 +86,12 @@ async def create_user_client( summary="Update user client", response_model=ClientFullResponse, dependencies=[ - Depends(auth_required(permissions=[constants.PERMISSION_CLIENT_UPDATE])) + Depends( + auth_required( + permissions=[constants.PERMISSION_CLIENT_UPDATE], + scope=[constants.SCOPE_UPDATE_CLIENT], + ) + ) ], ) async def update_user_client( @@ -96,7 +107,12 @@ async def update_user_client( summary="Delete user client", response_model=ClientFullResponse, dependencies=[ - Depends(auth_required(permissions=[constants.PERMISSION_CLIENT_DELETE])) + Depends( + auth_required( + permissions=[constants.PERMISSION_CLIENT_DELETE], + scope=[constants.SCOPE_DELETE_CLIENT], + ) + ) ], ) async def delete_user_client( diff --git a/app/collections/router.py b/app/collections/router.py index 1966d000..d7c6e7e2 100644 --- a/app/collections/router.py +++ b/app/collections/router.py @@ -39,7 +39,9 @@ @router.post("", response_model=CollectionsListResponse) async def get_collections( args: CollectionsListArgs = Depends(validate_collections_list_args), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_COLLECTIONS]) + ), session: AsyncSession = Depends(get_session), page: int = Depends(get_page), size: int = Depends(get_size), @@ -61,7 +63,10 @@ async def create_collection( session: AsyncSession = Depends(get_session), args: CollectionArgs = Depends(validate_collection_create), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_COLLECTION_CREATE]) + auth_required( + permissions=[constants.PERMISSION_COLLECTION_CREATE], + scope=[constants.SCOPE_CREATE_COLLECTION], + ) ), ): collection = await service.create_collection(session, args, user) @@ -74,7 +79,10 @@ async def update_collection( collection: Collection = Depends(validate_collection), session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_COLLECTION_UPDATE]) + auth_required( + permissions=[constants.PERMISSION_COLLECTION_UPDATE], + scope=[constants.SCOPE_UPDATE_COLLECTION], + ) ), ): collection = await service.update_collection( @@ -89,7 +97,10 @@ async def delete_collection( collection: Collection = Depends(validate_collection_delete), session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_COLLECTION_DELETE]) + auth_required( + permissions=[constants.PERMISSION_COLLECTION_DELETE], + scope=[constants.SCOPE_DELETE_COLLECTION], + ) ), ): await service.delete_collection(session, collection, user) @@ -98,7 +109,15 @@ async def delete_collection( @router.get("/{reference}", response_model=CollectionResponse) async def get_collection( - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required( + optional=True, + scope=[ + constants.SCOPE_READ_WATCHLIST, + constants.SCOPE_READ_READLIST, + ], + ) + ), collection: Collection = Depends(validate_collection), session: AsyncSession = Depends(get_session), ): diff --git a/app/comments/dependencies.py b/app/comments/dependencies.py index 19838d93..c6ffd52a 100644 --- a/app/comments/dependencies.py +++ b/app/comments/dependencies.py @@ -59,7 +59,10 @@ async def validate_parent( async def validate_rate_limit( session: AsyncSession = Depends(get_session), author: User = Depends( - auth_required(permissions=[constants.PERMISSION_COMMENT_WRITE]) + auth_required( + permissions=[constants.PERMISSION_COMMENT_WRITE], + scope=[constants.SCOPE_CREATE_COMMENT], + ) ), ): comments_limit = 100 @@ -98,7 +101,10 @@ async def validate_comment_not_hidden( async def validate_comment_edit( comment: Comment = Depends(validate_comment_not_hidden), author: User = Depends( - auth_required(permissions=[constants.PERMISSION_COMMENT_EDIT]) + auth_required( + permissions=[constants.PERMISSION_COMMENT_EDIT], + scope=[constants.SCOPE_UPDATE_COMMENT], + ) ), ): if comment.author != author: @@ -112,7 +118,7 @@ async def validate_comment_edit( async def validate_hide( comment: Comment = Depends(validate_comment), - user: User = Depends(auth_required()), + user: User = Depends(auth_required(scope=constants.SCOPE_DELETE_COMMENT)), ): if comment.hidden: raise Abort("comment", "already-hidden") diff --git a/app/comments/router.py b/app/comments/router.py index dc21f105..ad36cf4b 100644 --- a/app/comments/router.py +++ b/app/comments/router.py @@ -5,6 +5,7 @@ from app.models import Comment, User from app.utils import path_to_uuid from .utils import build_comments +from app import constants from . import service from .dependencies import ( @@ -54,7 +55,9 @@ async def latest_comments(session: AsyncSession = Depends(get_session)): @router.get("/list", response_model=CommentListResponse) @router.get("/list/new", response_model=CommentListResponse) async def comments_list( - request_user: User = Depends(auth_required(optional=True)), + request_user: User = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_COMMENT_SCORE]) + ), session: AsyncSession = Depends(get_session), page: int = Depends(get_page), size: int = Depends(get_size), @@ -94,7 +97,9 @@ async def write_comment( async def get_contents_list( session: AsyncSession = Depends(get_session), content_id: str = Depends(validate_content_slug), - request_user: User = Depends(auth_required(optional=True)), + request_user: User = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_COMMENT_SCORE]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -143,7 +148,9 @@ async def hide_comment( @router.get("/thread/{comment_reference}", response_model=CommentResponse) async def thread( base_comment: Comment = Depends(validate_comment_not_hidden), - request_user: User = Depends(auth_required(optional=True)), + request_user: User = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_COMMENT_SCORE]) + ), session: AsyncSession = Depends(get_session), ): sub_comments = await service.get_sub_comments( diff --git a/app/constants.py b/app/constants.py index 3215eda3..ea3d4a60 100644 --- a/app/constants.py +++ b/app/constants.py @@ -132,36 +132,183 @@ CONTENT_COMMENT = "comment" # Client access scopes -SCOPE_READ_USER_DETAILS = "read:user:details" -SCOPE_UPDATE_USER_DETAILS = "update:user:details" -SCOPE_READ_USER_WATCHLIST = "read:user:watchlist" -SCOPE_UPDATE_USER_WATCHLIST = "update:user:watchlist" -SCOPE_READ_USER_READLIST = "read:user:readlist" -SCOPE_UPDATE_USER_READLIST = "update:user:readlist" +SCOPE_READ_USER_DETAILS = "read:user-details" +SCOPE_UPDATE_USER_EMAIL = "update:user-details:email" +SCOPE_DELETE_USER_IMAGE = "delete:user-details:image" +SCOPE_DELETE_USER_POSTER = "delete:user-details:poster" +SCOPE_UPDATE_USER_USERNAME = "update:user-details:username" +SCOPE_UPDATE_USER_PASSWORD = "update:user-details:password" +SCOPE_UPDATE_USER_DESCRIPTION = "update:user-details:description" + +SCOPE_READ_WATCHLIST = "read:watchlist" +SCOPE_UPDATE_WATCHLIST = "update:watchlist" + +SCOPE_READ_READLIST = "read:readlist" +SCOPE_UPDATE_READLIST = "update:readlist" + +SCOPE_READ_CLIENT_LIST = "read:client:list" +SCOPE_CREATE_CLIENT = "create:client" +SCOPE_DELETE_CLIENT = "delete:client" +SCOPE_UPDATE_CLIENT = "update:client" +SCOPE_READ_CLIENT = "read:client" + +SCOPE_READ_COLLECTIONS = "read:collection" +SCOPE_CREATE_COLLECTION = "create:collection" +SCOPE_UPDATE_COLLECTION = "update:collection" +SCOPE_DELETE_COLLECTION = "delete:collection" + +SCOPE_READ_COMMENT_SCORE = "read:comment:score" +SCOPE_CREATE_COMMENT = "create:comment" +SCOPE_UPDATE_COMMENT = "update:comment" +SCOPE_DELETE_COMMENT = "delete:comment" + +SCOPE_CREATE_EDIT = "create:edit" +SCOPE_UPDATE_EDIT = "update:edit" +SCOPE_CLOSE_EDIT = "close:edit" +SCOPE_ACCEPT_EDIT = "accept:edit" +SCOPE_DENY_EDIT = "deny:edit" + +SCOPE_READ_FAVOURITE = "read:favourite" +SCOPE_CREATE_FAVOURITE = "create:favourite" +SCOPE_DELETE_FAVOURITE = "delete:favourite" +SCOPE_READ_FAVOURITE_LIST = "read:favourite:list" + +SCOPE_READ_FOLLOW = "read:follow" +SCOPE_FOLLOW = "follow" +SCOPE_UNFOLLOW = "unfollow" + +SCOPE_READ_HISTORY = "read:history" + +SCOPE_READ_NOTIFICATION = "read:notification" +SCOPE_SEEN_NOTIFICATION = "seen:notification" + +SCOPE_READ_VOTE = "read:vote" +SCOPE_SET_VOTE = "set:vote" + SCOPE_UPLOAD = "upload" ALL_SCOPES = [ SCOPE_READ_USER_DETAILS, - SCOPE_UPDATE_USER_DETAILS, - SCOPE_READ_USER_WATCHLIST, - SCOPE_UPDATE_USER_WATCHLIST, - SCOPE_READ_USER_READLIST, - SCOPE_UPDATE_USER_READLIST, + SCOPE_UPDATE_USER_EMAIL, + SCOPE_DELETE_USER_IMAGE, + SCOPE_DELETE_USER_POSTER, + SCOPE_UPDATE_USER_PASSWORD, + SCOPE_UPDATE_USER_USERNAME, + SCOPE_UPDATE_USER_DESCRIPTION, + SCOPE_READ_WATCHLIST, + SCOPE_UPDATE_WATCHLIST, + SCOPE_READ_READLIST, + SCOPE_UPDATE_READLIST, + SCOPE_READ_CLIENT_LIST, + SCOPE_CREATE_CLIENT, + SCOPE_READ_CLIENT, + SCOPE_UPDATE_CLIENT, + SCOPE_DELETE_CLIENT, + SCOPE_READ_COLLECTIONS, + SCOPE_CREATE_COLLECTION, + SCOPE_UPDATE_COLLECTION, + SCOPE_DELETE_COLLECTION, + SCOPE_READ_COMMENT_SCORE, + SCOPE_CREATE_COMMENT, + SCOPE_DELETE_COMMENT, + SCOPE_UPDATE_COMMENT, + SCOPE_CREATE_EDIT, + SCOPE_UPDATE_EDIT, + SCOPE_CLOSE_EDIT, + SCOPE_ACCEPT_EDIT, + SCOPE_DENY_EDIT, + SCOPE_READ_FAVOURITE, + SCOPE_CREATE_FAVOURITE, + SCOPE_DELETE_FAVOURITE, + SCOPE_READ_FAVOURITE_LIST, + SCOPE_READ_FOLLOW, + SCOPE_FOLLOW, + SCOPE_UNFOLLOW, + SCOPE_READ_HISTORY, + SCOPE_READ_NOTIFICATION, + SCOPE_SEEN_NOTIFICATION, + SCOPE_SET_VOTE, + SCOPE_READ_VOTE, SCOPE_UPLOAD, ] # Not real scopes - will be replaced with simple versions on each scope check -SCOPE_USER_DETAILS = "user:details" -SCOPE_USER_WATCHLIST = "user:watchlist" -SCOPE_USER_READLIST = "user:readlist" +SCOPE_UPDATE_USER_DETAILS = "update:user-details" +SCOPE_USER_DETAILS = "user-details" +SCOPE_WATCHLIST = "watchlist" +SCOPE_READLIST = "readlist" +SCOPE_CLIENT = "client" +SCOPE_COLLECTION = "collection" +SCOPE_COMMENT = "comment" +SCOPE_EDIT = "edit" +SCOPE_FAVOURITE = "favourite" +SCOPE_FOLLOW_FULL = "follow-full" +SCOPE_NOTIFICATION = "notification" +SCOPE_VOTE = "vote" SCOPE_ALL = "all" -# This aliases will be resolved at token scope checking -SCOPE_ALIASES = { +# This scope groups will be resolved at token scope checking +SCOPE_GROUPS = { + SCOPE_UPDATE_USER_DETAILS: [ + SCOPE_UPDATE_USER_EMAIL, + SCOPE_UPDATE_USER_PASSWORD, + SCOPE_UPDATE_USER_USERNAME, + SCOPE_UPDATE_USER_DESCRIPTION, + SCOPE_DELETE_USER_IMAGE, + SCOPE_DELETE_USER_POSTER, + ], SCOPE_USER_DETAILS: [SCOPE_READ_USER_DETAILS, SCOPE_UPDATE_USER_DETAILS], - SCOPE_USER_WATCHLIST: [SCOPE_READ_USER_WATCHLIST, SCOPE_UPDATE_USER_WATCHLIST], - SCOPE_USER_READLIST: [SCOPE_READ_USER_READLIST, SCOPE_UPDATE_USER_READLIST], - SCOPE_ALL: ALL_SCOPES + SCOPE_WATCHLIST: [ + SCOPE_READ_WATCHLIST, + SCOPE_UPDATE_WATCHLIST, + ], + SCOPE_READLIST: [SCOPE_READ_READLIST, SCOPE_UPDATE_READLIST], + SCOPE_CLIENT: [ + SCOPE_READ_CLIENT_LIST, + SCOPE_CREATE_CLIENT, + SCOPE_READ_CLIENT, + SCOPE_UPDATE_CLIENT, + SCOPE_DELETE_CLIENT, + ], + SCOPE_COLLECTION: [ + SCOPE_CREATE_COLLECTION, + SCOPE_READ_COLLECTIONS, + SCOPE_UPDATE_COLLECTION, + SCOPE_DELETE_COLLECTION, + ], + SCOPE_COMMENT: [ + SCOPE_READ_COMMENT_SCORE, + SCOPE_UPDATE_COMMENT, + SCOPE_DELETE_COMMENT, + SCOPE_CREATE_COMMENT, + ], + SCOPE_EDIT: [ + SCOPE_CREATE_EDIT, + SCOPE_UPDATE_EDIT, + SCOPE_CLOSE_EDIT, + SCOPE_ACCEPT_EDIT, + SCOPE_DENY_EDIT, + ], + SCOPE_FAVOURITE: [ + SCOPE_READ_FAVOURITE, + SCOPE_CREATE_FAVOURITE, + SCOPE_DELETE_FAVOURITE, + SCOPE_READ_FAVOURITE_LIST, + ], + SCOPE_FOLLOW_FULL: [ + SCOPE_READ_FOLLOW, + SCOPE_FOLLOW, + SCOPE_UNFOLLOW, + ], + SCOPE_NOTIFICATION: [ + SCOPE_READ_NOTIFICATION, + SCOPE_SEEN_NOTIFICATION + ], + SCOPE_VOTE: [ + SCOPE_READ_VOTE, + SCOPE_SET_VOTE, + ], + SCOPE_ALL: ALL_SCOPES, } # Roles diff --git a/app/dependencies.py b/app/dependencies.py index d79d17d3..f6337d1b 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -108,7 +108,7 @@ def auth_required(permissions: list = None, scope: list = None, forbid_thirdpart if not scope: scope = [] - scope = utils.resolve_aliased_scopes(scope) + scope = utils.resolve_scope_groups(scope) async def auth( token: AuthToken | Abort = Depends(_auth_token_or_abort), diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index 655342f4..f12e2a07 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -94,7 +94,10 @@ async def validate_edit_update( async def validate_edit_close( edit: Edit = Depends(validate_edit_id_pending), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_EDIT_CLOSE]) + auth_required( + permissions=[constants.PERMISSION_EDIT_CLOSE], + scope=[constants.SCOPE_CLOSE_EDIT], + ) ), ): """Check if user which is trying to close edit it the author""" diff --git a/app/edit/router.py b/app/edit/router.py index 473876b1..78972955 100644 --- a/app/edit/router.py +++ b/app/edit/router.py @@ -86,7 +86,10 @@ async def create_edit( ), args: EditArgs = Depends(validate_edit_create), author: User = Depends( - auth_required(permissions=[constants.PERMISSION_EDIT_CREATE]) + auth_required( + permissions=[constants.PERMISSION_EDIT_CREATE], + scope=[constants.SCOPE_CREATE_EDIT], + ) ), _: bool = Depends(check_captcha), ): @@ -101,7 +104,10 @@ async def update_edit( args: EditArgs = Depends(validate_edit_update_args), edit: Edit = Depends(validate_edit_update), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_EDIT_UPDATE]) + auth_required( + permissions=[constants.PERMISSION_EDIT_UPDATE], + scope=[constants.SCOPE_UPDATE_EDIT], + ) ), _: bool = Depends(check_captcha), ): @@ -121,7 +127,10 @@ async def accept_edit( session: AsyncSession = Depends(get_session), edit: Edit = Depends(validate_edit_accept), moderator: User = Depends( - auth_required(permissions=[constants.PERMISSION_EDIT_ACCEPT]) + auth_required( + permissions=[constants.PERMISSION_EDIT_ACCEPT], + scope=[constants.SCOPE_ACCEPT_EDIT], + ) ), ): return await service.accept_pending_edit(session, edit, moderator) @@ -132,7 +141,10 @@ async def deny_edit( session: AsyncSession = Depends(get_session), edit: Edit = Depends(validate_edit_id_pending), moderator: User = Depends( - auth_required(permissions=[constants.PERMISSION_EDIT_ACCEPT]) + auth_required( + permissions=[constants.PERMISSION_EDIT_ACCEPT], + scope=[constants.SCOPE_DENY_EDIT], + ) ), ): return await service.deny_pending_edit(session, edit, moderator) @@ -148,7 +160,15 @@ async def get_content_edit_todo( content_type: EditContentToDoEnum, todo_type: ContentToDoEnum, session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required( + optional=True, + scope=[ + constants.SCOPE_READ_READLIST, + constants.SCOPE_READ_WATCHLIST, + ], + ) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): diff --git a/app/favourite/router.py b/app/favourite/router.py index f40c2c93..9bf74dd7 100644 --- a/app/favourite/router.py +++ b/app/favourite/router.py @@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends from app.database import get_session +from app import constants from . import service from app.models import ( @@ -40,7 +41,13 @@ router = APIRouter(prefix="/favourite", tags=["Favourite"]) -@router.get("/{content_type}/{slug}", response_model=FavouriteResponse) +@router.get( + "/{content_type}/{slug}", + response_model=FavouriteResponse, + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_READ_FAVOURITE])) + ], +) async def get_favourite( favourite: Favourite = Depends(validate_get_favourite), ): @@ -54,12 +61,20 @@ async def favourite_add( content: Collection | Character | Anime | Manga | Novel = Depends( validate_add_favourite ), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_CREATE_FAVOURITE]) + ), ): return await service.create_favourite(session, content_type, content, user) -@router.delete("/{content_type}/{slug}", response_model=SuccessResponse) +@router.delete( + "/{content_type}/{slug}", + response_model=SuccessResponse, + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_DELETE_FAVOURITE])) + ], +) async def favourite_delete( session: AsyncSession = Depends(get_session), favourite: Favourite = Depends(validate_get_favourite), @@ -75,7 +90,11 @@ async def favourite_delete( async def favourite_list( content_type: FavouriteContentTypeEnum, session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required( + optional=True, scope=[constants.SCOPE_READ_FAVOURITE_LIST] + ) + ), user: User = Depends(get_user), page: int = Depends(get_page), size: int = Depends(get_size), diff --git a/app/follow/router.py b/app/follow/router.py index 0f0d0e3d..53995fcd 100644 --- a/app/follow/router.py +++ b/app/follow/router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from app.database import get_session from app.models import User +from app import constants from typing import Tuple from . import service @@ -33,6 +34,9 @@ "/{username}", response_model=FollowResponse, summary="Check follow", + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_READ_FAVOURITE])) + ], ) async def check( session: AsyncSession = Depends(get_session), @@ -45,6 +49,7 @@ async def check( "/{username}", response_model=FollowResponse, summary="Follow", + dependencies=[Depends(auth_required(scope=[constants.SCOPE_FOLLOW]))], ) async def follow( session: AsyncSession = Depends(get_session), @@ -57,6 +62,7 @@ async def follow( "/{username}", response_model=FollowResponse, summary="Unfollow", + dependencies=[Depends(auth_required(scope=[constants.SCOPE_UNFOLLOW]))], ) async def unfollow( session: AsyncSession = Depends(get_session), @@ -87,7 +93,9 @@ async def follow_stats( ) async def following_list( session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_FOLLOW]) + ), user: User = Depends(validate_username), page: int = Depends(get_page), size: int = Depends(get_size), @@ -111,7 +119,9 @@ async def following_list( ) async def followers_list( session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_FOLLOW]) + ), user: User = Depends(validate_username), page: int = Depends(get_page), size: int = Depends(get_size), diff --git a/app/history/router.py b/app/history/router.py index 73705564..76cc1abc 100644 --- a/app/history/router.py +++ b/app/history/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends from app.database import get_session from app.models import User +from app import constants from . import service @@ -32,7 +33,11 @@ ) async def following_history( session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required( + scope=[constants.SCOPE_READ_HISTORY, constants.SCOPE_READ_FOLLOW] + ) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): diff --git a/app/notifications/router.py b/app/notifications/router.py index 1a73f849..90df5447 100644 --- a/app/notifications/router.py +++ b/app/notifications/router.py @@ -3,6 +3,7 @@ from app.models import Notification, User from fastapi import APIRouter, Depends from app.database import get_session +from app import constants from . import service from .schemas import ( @@ -33,7 +34,9 @@ ) async def notifications( session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_NOTIFICATION]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -56,7 +59,9 @@ async def notifications( ) async def unseen_notifications_count( session: AsyncSession = Depends(get_session), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_READ_NOTIFICATION]) + ), ): return {"unseen": await service.get_unseen_count(session, user)} @@ -65,6 +70,9 @@ async def unseen_notifications_count( "/{notification_reference}/seen", response_model=NotificationResponse, summary="Mark notification as seen", + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_SEEN_NOTIFICATION])), + ], ) async def notification_seen( notification: Notification = Depends(validate_notification), diff --git a/app/novel/router.py b/app/novel/router.py index 9f1ba545..d5aad3d3 100644 --- a/app/novel/router.py +++ b/app/novel/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends from app.database import get_session from app.models import User, Novel +from app import constants from . import service from .dependencies import ( @@ -42,7 +43,9 @@ ) async def search_novel( session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_READLIST]) + ), search: NovelSearchArgs = Depends(validate_search_novel), page: int = Depends(get_page), size: int = Depends(get_size), diff --git a/app/people/router.py b/app/people/router.py index 685f380e..1db26ec4 100644 --- a/app/people/router.py +++ b/app/people/router.py @@ -66,7 +66,9 @@ async def search_people( async def person_anime( session: AsyncSession = Depends(get_session), person: Person = Depends(get_person), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_WATCHLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -86,7 +88,9 @@ async def person_anime( async def person_manga( session: AsyncSession = Depends(get_session), person: Person = Depends(get_person), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_READLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -106,7 +110,9 @@ async def person_manga( async def person_novel( session: AsyncSession = Depends(get_session), person: Person = Depends(get_person), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_READLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): @@ -128,7 +134,9 @@ async def person_novel( async def person_voices( session: AsyncSession = Depends(get_session), person: Person = Depends(get_person), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_WATCHLIST]) + ), page: int = Depends(get_page), size: int = Depends(get_size), ): diff --git a/app/read/dependencies.py b/app/read/dependencies.py index 4c8fa616..91ac301a 100644 --- a/app/read/dependencies.py +++ b/app/read/dependencies.py @@ -32,9 +32,7 @@ async def verify_read_content( async def verify_read( content_type: ReadContentTypeEnum, - user: User = Depends( - auth_required(scope=[constants.SCOPE_READ_USER_READLIST]) - ), + user: User = Depends(auth_required(scope=[constants.SCOPE_READ_READLIST])), session: AsyncSession = Depends(get_session), content: Manga | Novel = Depends(verify_read_content), ) -> Read: diff --git a/app/read/router.py b/app/read/router.py index 4a9247bf..93894810 100644 --- a/app/read/router.py +++ b/app/read/router.py @@ -53,7 +53,7 @@ async def read_add( session: AsyncSession = Depends(get_session), content: Manga | Novel = Depends(verify_add_read), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_READLIST]) ), ): return await service.save_read( @@ -69,7 +69,7 @@ async def read_add( async def delete_read( session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_READLIST]) ), read: Read = Depends(verify_read), ): diff --git a/app/schedule/router.py b/app/schedule/router.py index 4c30ce08..6a6b2980 100644 --- a/app/schedule/router.py +++ b/app/schedule/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends from app.database import get_session from app.models import User +from app import constants from . import service from app.dependencies import ( @@ -25,7 +26,9 @@ @router.post("/anime", response_model=AnimeScheduleResponsePaginationResponse) async def anime_schedule( session: AsyncSession = Depends(get_session), - request_user: User | None = Depends(auth_required(optional=True)), + request_user: User | None = Depends( + auth_required(optional=True, scope=[constants.SCOPE_READ_WATCHLIST]) + ), args: AnimeScheduleArgs = Depends(validate_schedule_args), page: int = Depends(get_page), size: int = Depends(get_size), diff --git a/app/settings/router.py b/app/settings/router.py index 400dd4fc..53142176 100644 --- a/app/settings/router.py +++ b/app/settings/router.py @@ -47,7 +47,7 @@ async def change_description( args: DescriptionArgs, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + auth_required(scope=[constants.SCOPE_UPDATE_USER_DESCRIPTION]) ), ): return await service.change_description(session, user, args.description) @@ -62,7 +62,7 @@ async def change_password( args: PasswordArgs, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + auth_required(scope=[constants.SCOPE_UPDATE_USER_PASSWORD]) ), ): return await service.set_password(session, user, args.password) @@ -77,7 +77,7 @@ async def change_username( session: AsyncSession = Depends(get_session), args: UsernameArgs = Depends(validate_set_username), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + auth_required(scope=[constants.SCOPE_UPDATE_USER_USERNAME]) ), ): return await service.set_username(session, user, args.username) @@ -92,7 +92,7 @@ async def change_email( session: AsyncSession = Depends(get_session), args: EmailArgs = Depends(validate_set_email), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_DETAILS]) + auth_required(scope=[constants.SCOPE_UPDATE_USER_EMAIL]) ), ): user = await service.set_email(session, user, args.email) @@ -119,7 +119,7 @@ async def import_watch( background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_WATCHLIST]) ), ): # Run watch list import in background @@ -145,7 +145,7 @@ async def import_read( background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_READLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_READLIST]) ), ): # Run watch list import in background @@ -215,7 +215,7 @@ async def delete_user_watch( background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_WATCHLIST]) ), ): # Run watch list import in background @@ -240,7 +240,7 @@ async def delete_user_read( background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) + auth_required(scope=[constants.SCOPE_UPDATE_WATCHLIST]) ), ): # Run watch list import in background diff --git a/app/utils.py b/app/utils.py index f21b69d0..bce9728f 100644 --- a/app/utils.py +++ b/app/utils.py @@ -64,7 +64,7 @@ def check_user_permissions(user: User, permissions: list): return has_permission def check_token_scope(token: AuthToken, scope: list[str]) -> bool: - token_scope = set(resolve_aliased_scopes(token.scope)) + token_scope = set(resolve_scope_groups(token.scope)) scope = set(scope) @@ -74,16 +74,24 @@ def check_token_scope(token: AuthToken, scope: list[str]) -> bool: return token_scope.issuperset(scope) -def resolve_aliased_scopes(scopes: list[str]) -> list[str]: - simplified_scopes = [] +def resolve_scope_groups(scopes: list[str]) -> list[str]: + plain_scopes = [] for scope in scopes: - if scope in constants.SCOPE_ALIASES: - simplified_scopes.extend(constants.SCOPE_ALIASES[scope]) + if scope in constants.SCOPE_GROUPS: + group = constants.SCOPE_GROUPS[scope] + + # In case of referencing other groups in this + # we need resolve them too + group = resolve_scope_groups(group) + + plain_scopes.extend( + group + ) else: - simplified_scopes.append(scope) + plain_scopes.append(scope) - return simplified_scopes + return plain_scopes # Get bcrypt hash of password diff --git a/app/vote/router.py b/app/vote/router.py index 3ca5c0a8..d4b6ce1d 100644 --- a/app/vote/router.py +++ b/app/vote/router.py @@ -22,7 +22,11 @@ router = APIRouter(prefix="/vote", tags=["Vote"]) -@router.get("/{content_type}/{slug}", response_model=VoteResponse) +@router.get( + "/{content_type}/{slug}", + response_model=VoteResponse, + dependencies=[Depends(auth_required(scope=[constants.SCOPE_READ_VOTE]))], +) async def get_vote(vote: Vote = Depends(validate_get_vote)): return vote @@ -34,7 +38,10 @@ async def set_vote( content: Collection | Comment = Depends(validate_content), session: AsyncSession = Depends(get_session), user: User = Depends( - auth_required(permissions=[constants.PERMISSION_VOTE_SET]) + auth_required( + permissions=[constants.PERMISSION_VOTE_SET], + scope=[constants.SCOPE_SET_VOTE], + ) ), ): return await service.set_vote( diff --git a/app/watch/dependencies.py b/app/watch/dependencies.py index 02badf34..2ac2d120 100644 --- a/app/watch/dependencies.py +++ b/app/watch/dependencies.py @@ -13,9 +13,7 @@ async def verify_watch( anime: Anime = Depends(get_anime), - user: User = Depends( - auth_required(scope=[constants.SCOPE_READ_USER_WATCHLIST]) - ), + user: User = Depends(auth_required()), session: AsyncSession = Depends(get_session), ) -> AnimeWatch: if not (watch := await get_anime_watch(session, anime, user)): @@ -27,9 +25,7 @@ async def verify_watch( async def verify_add_watch( args: WatchArgs, anime: Anime = Depends(get_anime), - user: User = Depends( - auth_required(scope=[constants.SCOPE_UPDATE_USER_WATCHLIST]) - ), + user: User = Depends(auth_required()), ) -> Tuple[Anime, User, WatchArgs]: # TODO: We probably should add anime.episodes_released here # TODO: Ideally we need to check anime status diff --git a/app/watch/router.py b/app/watch/router.py index f69f7c67..27ff3bc3 100644 --- a/app/watch/router.py +++ b/app/watch/router.py @@ -39,12 +39,24 @@ router = APIRouter(prefix="/watch", tags=["Watch"]) -@router.get("/{slug}", response_model=WatchResponse) +@router.get( + "/{slug}", + response_model=WatchResponse, + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_READ_WATCHLIST])) + ], +) async def watch_get(watch: AnimeWatch = Depends(verify_watch)): return watch -@router.put("/{slug}", response_model=WatchResponse) +@router.put( + "/{slug}", + response_model=WatchResponse, + dependencies=[ + Depends(auth_required(scope=[constants.SCOPE_UPDATE_WATCHLIST])) + ], +) async def watch_add( session: AsyncSession = Depends(get_session), data: Tuple[Anime, User, WatchArgs] = Depends(verify_add_watch), @@ -56,7 +68,9 @@ async def watch_add( async def delete_watch( session: AsyncSession = Depends(get_session), watch: AnimeWatch = Depends(verify_watch), - user: User = Depends(auth_required()), + user: User = Depends( + auth_required(scope=[constants.SCOPE_UPDATE_WATCHLIST]) + ), ): await service.delete_watch(session, watch, user) return {"success": True} @@ -65,9 +79,7 @@ async def delete_watch( @router.get("/{slug}/following", response_model=UserWatchPaginationResponse) async def get_watch_following( session: AsyncSession = Depends(get_session), - user: User = Depends( - auth_required(scope=[constants.SCOPE_READ_USER_DETAILS]) - ), + user: User = Depends(auth_required(scope=[constants.SCOPE_READ_FOLLOW])), anime: Anime = Depends(get_anime), page: int = Depends(get_page), size: int = Depends(get_size), From 5a3514e9522b902979763341607e03e254123a06 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 28 Jul 2024 13:01:12 +0300 Subject: [PATCH 11/44] add Dockerfile --- Dockerfile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..eff1b505 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim-buster + +RUN pip install poetry==1.8.3 + +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache + +WORKDIR /project + +COPY pyproject.toml poetry.lock ./ +RUN touch README.md + +RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR + +COPY sync.py . +COPY app ./app + +CMD poetry run uvicorn app:create_app --host 0.0.0.0 --port 8000 From cb0179da044bfea58a641b8ee33700e8b2e5c6ac Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 28 Jul 2024 13:01:41 +0300 Subject: [PATCH 12/44] add docker ci/cd workflow --- .github/workflows/docker-publish.yml | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..0e45e292 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,86 @@ +name: Publish Docker Image + +on: + push: + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + name: Build and push Docker image + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@v3.5.0 + with: + cosign-release: 'v2.3.0' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.5.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v6.5.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} From 2b91563482a614a5fde4e952da9587a166920875 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 28 Jul 2024 13:21:20 +0300 Subject: [PATCH 13/44] change app server to gunicorn --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index eff1b505..06499ca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,4 @@ RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR COPY sync.py . COPY app ./app -CMD poetry run uvicorn app:create_app --host 0.0.0.0 --port 8000 +CMD poetry run gunicorn app:create_app --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 From 987571985b1a557c1ee566d5a11ea96ced563edd Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 28 Jul 2024 13:28:49 +0300 Subject: [PATCH 14/44] add gunicorn app as factory --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 06499ca0..118a6e34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,4 @@ RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR COPY sync.py . COPY app ./app -CMD poetry run gunicorn app:create_app --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +CMD poetry run gunicorn "app:create_app()" --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 From bccd2976d7b0d40b9d8c70ebdcf614af98110d55 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 28 Jul 2024 21:05:10 +0300 Subject: [PATCH 15/44] fix migration, add ltree extension --- alembic/versions/old/208536a34d07_added_comment_model.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/alembic/versions/old/208536a34d07_added_comment_model.py b/alembic/versions/old/208536a34d07_added_comment_model.py index 07253442..4bdda9b4 100644 --- a/alembic/versions/old/208536a34d07_added_comment_model.py +++ b/alembic/versions/old/208536a34d07_added_comment_model.py @@ -8,6 +8,7 @@ from alembic import op import sqlalchemy as sa import sqlalchemy_utils +from sqlalchemy.sql import text # revision identifiers, used by Alembic. @@ -19,6 +20,10 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### + + conn = op.get_bind() + conn.execute(text("CREATE EXTENSION IF NOT EXISTS ltree;")) + op.create_table( "service_comments", sa.Column( @@ -55,4 +60,7 @@ def downgrade() -> None: postgresql_using="gist", ) op.drop_table("service_comments") + + conn = op.get_bind() + conn.execute(text("DROP EXTENSION IF EXISTS ltree;")) # ### end Alembic commands ### From 57ba93f0474a56d7344feffd676f3b019fc7a818 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Mon, 29 Jul 2024 12:09:12 +0300 Subject: [PATCH 16/44] Don't validate captcha in /edit endpoints if authorized through third-party client --- app/dependencies.py | 16 +++++++++++++++- app/edit/dependencies.py | 21 +++++++++++++++++++-- app/edit/router.py | 2 +- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index f6337d1b..bf159c0d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -92,8 +92,22 @@ async def auth_token_required( return token +async def auth_token_optional( + token: AuthToken | Abort = Depends(_auth_token_or_abort), +) -> AuthToken | None: + if isinstance(token, Abort): + return None + + return token + + # Check user auth token -def auth_required(permissions: list = None, scope: list = None, forbid_thirdparty: bool = False, optional: bool = False): +def auth_required( + permissions: list = None, + scope: list = None, + forbid_thirdparty: bool = False, + optional: bool = False, +): """ Authorization dependency with permission check diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index f12e2a07..f2cd9e5c 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -1,13 +1,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.utils import check_user_permissions -from app.dependencies import auth_required from app.database import get_session +from fastapi import Depends, Header +from app.models import AuthToken from app.errors import Abort -from fastapi import Depends from app import constants from . import service from . import utils +from app.dependencies import ( + check_captcha as _check_captcha, + auth_token_optional, + auth_required, +) + from app.service import ( get_user_by_username, get_content_by_slug, @@ -179,3 +185,14 @@ async def validate_edit_create( raise Abort("permission", "denied") return args + + +async def check_captcha( + captcha: str | None = Header(None, alias="captcha"), + auth_token: AuthToken | None = Depends(auth_token_optional), +): + # If authorized through third-party client - disable captcha validation + if auth_token is not None and auth_token.client is not None: + return True + + return _check_captcha(captcha) diff --git a/app/edit/router.py b/app/edit/router.py index 78972955..14cbfcc5 100644 --- a/app/edit/router.py +++ b/app/edit/router.py @@ -19,7 +19,6 @@ from app.dependencies import ( auth_required, - check_captcha, get_page, get_size, ) @@ -39,6 +38,7 @@ validate_edit_close, validate_content, validate_edit_id, + check_captcha, ) from .schemas import ( From 296f98be3d673188616e6c4d0cc7edb680b13f82 Mon Sep 17 00:00:00 2001 From: volbil Date: Mon, 29 Jul 2024 17:21:21 +0300 Subject: [PATCH 17/44] Auto accept edits after update --- app/aggregator/info/manga.py | 8 ++-- app/constants.py | 1 + app/edit/dependencies.py | 6 +++ app/edit/service.py | 14 +++++-- tests/aggregator/test_import_manga_info.py | 2 + tests/edit/test_edit_create_auto.py | 11 +---- tests/edit/test_edit_update_auto.py | 48 ++++++++++++++++++++++ tests/manga/test_manga_info.py | 2 +- 8 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 tests/edit/test_edit_update_auto.py diff --git a/app/aggregator/info/manga.py b/app/aggregator/info/manga.py index 1965b04f..ec6432e7 100644 --- a/app/aggregator/info/manga.py +++ b/app/aggregator/info/manga.py @@ -34,9 +34,11 @@ async def process_genres(session, manga, data): def process_translated_ua(data): - return ( - len(data["honey"]) > 0 or len(data["zenko"]) > 0 or len(data["miu"]) > 0 - ) + honey_count = len(data["honey"]) if "honey" in data else 0 + zenko_count = len(data["zenko"]) if "zenko" in data else 0 + miu_count = len(data["miu"]) if "miu" in data else 0 + + return honey_count > 0 or zenko_count > 0 or miu_count > 0 def process_external(data): diff --git a/app/constants.py b/app/constants.py index cba4a930..5243c3b9 100644 --- a/app/constants.py +++ b/app/constants.py @@ -254,6 +254,7 @@ LOG_EDIT_CLOSE = "edit_close" LOG_EDIT_ACCEPT = "edit_accept" LOG_EDIT_ACCEPT_AUTO = "edit_accept_auto" +LOG_EDIT_UPDATE_ACCEPT_AUTO = "edit_update_accept_auto" LOG_EDIT_DENY = "edit_deny" LOG_WATCH_CREATE = "watch_create" LOG_WATCH_UPDATE = "watch_update" diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index 655342f4..cd3978fc 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -148,6 +148,7 @@ async def validate_edit_create_args( async def validate_edit_update_args( args: EditArgs, edit: Edit = Depends(validate_edit_update), + author: User = Depends(auth_required()), ) -> EditArgs: """Validate update edit args""" @@ -158,6 +159,11 @@ async def validate_edit_update_args( if len(args.after) == 0: raise Abort("edit", "empty-edit") + if args.auto and not check_user_permissions( + author, [constants.PERMISSION_EDIT_AUTO] + ): + raise Abort("permission", "denied") + return args diff --git a/app/edit/service.py b/app/edit/service.py index 79357cc4..83331a28 100644 --- a/app/edit/service.py +++ b/app/edit/service.py @@ -252,6 +252,12 @@ async def update_pending_edit( }, ) + # If user marked edit as auto accept we should do that + if args.auto: + await accept_pending_edit( + session, edit, user, constants.LOG_EDIT_UPDATE_ACCEPT_AUTO + ) + return edit @@ -283,7 +289,7 @@ async def accept_pending_edit( session: AsyncSession, edit: Edit, moderator: User, - auto: bool = False, + log_type: str = constants.LOG_EDIT_ACCEPT, ) -> Edit: """Accept pending edit""" @@ -321,7 +327,7 @@ async def accept_pending_edit( await create_log( session, - constants.LOG_EDIT_ACCEPT_AUTO if auto else constants.LOG_EDIT_ACCEPT, + log_type, moderator, edit.id, ) @@ -366,7 +372,9 @@ async def create_pending_edit( # If user marked edit as auto accept we should do that if args.auto: await session.refresh(edit) - await accept_pending_edit(session, edit, author, True) + await accept_pending_edit( + session, edit, author, constants.LOG_EDIT_ACCEPT_AUTO + ) else: await create_log( diff --git a/tests/aggregator/test_import_manga_info.py b/tests/aggregator/test_import_manga_info.py index 4128bc9e..62d1afa4 100644 --- a/tests/aggregator/test_import_manga_info.py +++ b/tests/aggregator/test_import_manga_info.py @@ -54,6 +54,7 @@ async def test_import_manga_info( "external": [], "synonyms": [], "synopsis_en": None, + "translated_ua": False, } assert edit.after == { @@ -103,4 +104,5 @@ async def test_import_manga_info( "bodies with its power. However, their quest for the fated stone " "also leads them to unravel far darker secrets than they could ever " "imagine.\n\n[Written by MAL Rewrite]", + "translated_ua": True, } diff --git a/tests/edit/test_edit_create_auto.py b/tests/edit/test_edit_create_auto.py index 229c75a8..e1b852c8 100644 --- a/tests/edit/test_edit_create_auto.py +++ b/tests/edit/test_edit_create_auto.py @@ -1,16 +1,7 @@ from client_requests import request_create_edit -from sqlalchemy import select, desc, func -from app.models import Log from fastapi import status from app import constants -from app.models import ( - CharacterEdit, - PersonEdit, - AnimeEdit, - Edit, -) - async def test_edit_create_auto( client, @@ -40,7 +31,7 @@ async def test_edit_create_auto( # Check status and data assert response.status_code == status.HTTP_200_OK - # assert response.json()["status"] == constants.EDIT_ACCEPTED + assert response.json()["status"] == constants.EDIT_ACCEPTED async def test_edit_create_auto_bad_permission( diff --git a/tests/edit/test_edit_update_auto.py b/tests/edit/test_edit_update_auto.py new file mode 100644 index 00000000..5d304b60 --- /dev/null +++ b/tests/edit/test_edit_update_auto.py @@ -0,0 +1,48 @@ +from client_requests import request_create_edit +from client_requests import request_update_edit +from fastapi import status +from app import constants +import asyncio + + +async def test_edit_update_auto( + client, + aggregator_anime, + aggregator_anime_info, + create_test_user_moderator, + get_test_token, + test_session, +): + # Create edit for anime + response = await request_create_edit( + client, + get_test_token, + "anime", + "bocchi-the-rock-9e172d", + { + "description": "Brief description", + "after": {"title_en": "Bocchi The Rock!"}, + }, + ) + + # Check status + assert response.status_code == status.HTTP_200_OK + assert response.json()["created"] == response.json()["updated"] + + # Simulate delay between create/update + await asyncio.sleep(1) + + # Update created edit + response = await request_update_edit( + client, + get_test_token, + 18, + { + "description": "Brief description 2", + "after": {"title_en": "Bocchi The Rock!"}, + "auto": True, + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == constants.EDIT_ACCEPTED diff --git a/tests/manga/test_manga_info.py b/tests/manga/test_manga_info.py index 82fc7e2f..7f07ab74 100644 --- a/tests/manga/test_manga_info.py +++ b/tests/manga/test_manga_info.py @@ -24,7 +24,7 @@ async def test_manga_info( assert len(response.json()["external"]) == 4 assert len(response.json()["genres"]) == 6 - assert response.json()["translated_ua"] is False + assert response.json()["translated_ua"] is True assert response.json()["stats"] == { "dropped": 6393, "on_hold": 16941, From 0340190a04c8bb2d667d4a5841ffaee4d8bea108 Mon Sep 17 00:00:00 2001 From: volbil Date: Mon, 29 Jul 2024 17:23:44 +0300 Subject: [PATCH 18/44] Updated certifi version --- poetry.lock | 10 +++++----- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index e6cb3a91..f2c6dc92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aioboto3" @@ -429,13 +429,13 @@ pydantic = ["pydantic (>=1.8.2)"] [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -2646,4 +2646,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "84de1fc95b9f179dc8b6a1f341a191bce1494c5603341bc7f0c66ba086fc1186" +content-hash = "744906f5b992165d0fc5e2c03e1db4c9dd16ecbc8e923dbc7b0bed98d599b47f" diff --git a/pyproject.toml b/pyproject.toml index bf261e51..94de829b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ urllib3 = "2.2.2" gunicorn = "^22.0.0" prometheus-fastapi-instrumentator = "^7.0.0" pyinstrument = "^4.6.2" +certifi = "2024.07.04" [build-system] requires = ["poetry-core"] From 27bd2b03d3bc830a46a0dc48a1d898d26829d4ae Mon Sep 17 00:00:00 2001 From: volbil Date: Mon, 29 Jul 2024 17:30:21 +0300 Subject: [PATCH 19/44] Misc debug for aggregator sync --- aggregator.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/aggregator.py b/aggregator.py index da3d5949..90150358 100644 --- a/aggregator.py +++ b/aggregator.py @@ -28,24 +28,41 @@ async def import_aggregator(): sessionmanager.init(settings.database.endpoint) + print("Genres") await aggregator_genres() + print("Roles") await aggregator_roles() + print("Characters") await aggregator_characters() + print("Companies") await aggregator_companies() + print("Magazines") await aggregator_magazines() + print("People") await aggregator_people() + print("Anime") await aggregator_anime() + print("Manga") await aggregator_manga() + print("Novel") await aggregator_novel() + print("Anime info") await aggregator_anime_info() + print("Manga info") await aggregator_manga_info() + print("Novel info") await aggregator_novel_info() + print("Franchises") await aggregator_franchises() + print("Schedule") await update_schedule_build() + print("Search") await update_search() + print("Content") await update_content() # TODO: improve performance + print("Weights") await update_weights() await sessionmanager.close() From 4ec035e13e1ed3755c36e3d7afc1f182a8c37d06 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Wed, 7 Aug 2024 00:50:56 +0300 Subject: [PATCH 20/44] Fix: Check expression of the "revoke_secret" field in PUT /client/{client_reference} endpoint --- app/client/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/service.py b/app/client/service.py index e2a8fc10..a90ef7b3 100644 --- a/app/client/service.py +++ b/app/client/service.py @@ -102,7 +102,7 @@ async def update_client( if update.endpoint is not None: client.endpoint = str(update.endpoint) - if update.revoke_secret is not None: + if update.revoke_secret: client.secret = _client_secret() client.updated = now From 45205390e7fa915e1190ed2b74b8dcd7babcd3f4 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Wed, 7 Aug 2024 01:18:17 +0300 Subject: [PATCH 21/44] Client verification Add: "verified" field to Client Add: POST /client/{client_reference}/verify endpoint --- ...-a4d5b6174fb1_add_client_verified_field.py | 34 +++++++++++++++++++ app/client/dependencies.py | 13 +++++-- app/client/router.py | 21 ++++++++++++ app/client/service.py | 6 ++++ app/constants.py | 5 +++ app/errors.py | 1 + app/models/auth/client.py | 1 + app/schemas.py | 1 + tests/client/test_client_verify.py | 29 ++++++++++++++++ tests/client_requests/__init__.py | 2 ++ tests/client_requests/client.py | 7 ++++ tests/conftest.py | 19 +++++++++++ 12 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/2024_08_07_0116-a4d5b6174fb1_add_client_verified_field.py create mode 100644 tests/client/test_client_verify.py diff --git a/alembic/versions/2024_08_07_0116-a4d5b6174fb1_add_client_verified_field.py b/alembic/versions/2024_08_07_0116-a4d5b6174fb1_add_client_verified_field.py new file mode 100644 index 00000000..95449450 --- /dev/null +++ b/alembic/versions/2024_08_07_0116-a4d5b6174fb1_add_client_verified_field.py @@ -0,0 +1,34 @@ +"""Add client "verified" field + +Revision ID: a4d5b6174fb1 +Revises: 96c0d6b7aeba +Create Date: 2024-08-07 01:16:44.543914 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a4d5b6174fb1" +down_revision = "96c0d6b7aeba" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "service_clients", + sa.Column( + "verified", sa.Boolean(), nullable=False, server_default="false" + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("service_clients", "verified") + # ### end Alembic commands ### diff --git a/app/client/dependencies.py b/app/client/dependencies.py index bc7d2f7b..2e86d415 100644 --- a/app/client/dependencies.py +++ b/app/client/dependencies.py @@ -15,7 +15,7 @@ async def validate_client_create( create: ClientCreate, user: User = Depends(auth_required()), session: AsyncSession = Depends(get_session), -): +) -> ClientCreate: if (await service.get_user_client(session, user, create.name)) is not None: raise Abort("client", "already-exists") @@ -42,8 +42,17 @@ async def validate_client( async def validate_user_client( client: Client = Depends(validate_client), user: User = Depends(auth_required()), -): +) -> Client: if client.user_id != user.id: raise Abort("client", "not-owner") return client + + +async def validate_unverified_client( + client: Client = Depends(validate_client), +) -> Client: + if client.verified: + raise Abort("client", "already-verified") + + return client diff --git a/app/client/router.py b/app/client/router.py index 69ace74f..39961d9f 100644 --- a/app/client/router.py +++ b/app/client/router.py @@ -13,6 +13,7 @@ validate_client_create, validate_user_client, validate_client, + validate_unverified_client, ) from app.client.schemas import ( ClientPaginationResponse, @@ -120,3 +121,23 @@ async def delete_user_client( client: Client = Depends(validate_user_client), ): return await service.delete_client(session, client) + + +@router.post( + "/{client_reference}/verify", + summary="Verify third-party client", + response_model=ClientResponse, + dependencies=[ + Depends( + auth_required( + permissions=[constants.PERMISSION_CLIENT_VERIFY], + scope=[constants.SCOPE_VERIFY_CLIENT], + ) + ) + ], +) +async def verify_third_party_client( + session: AsyncSession = Depends(get_session), + client: Client = Depends(validate_unverified_client), +): + return await service.verify_client(session, client) diff --git a/app/client/service.py b/app/client/service.py index a90ef7b3..082e5388 100644 --- a/app/client/service.py +++ b/app/client/service.py @@ -116,3 +116,9 @@ async def delete_client(session: AsyncSession, client: Client) -> Client: await session.delete(client) await session.commit() return client + + +async def verify_client(session: AsyncSession, client: Client) -> Client: + client.verified = True + await session.commit() + return client diff --git a/app/constants.py b/app/constants.py index ea3d4a60..5adf47a4 100644 --- a/app/constants.py +++ b/app/constants.py @@ -150,6 +150,7 @@ SCOPE_CREATE_CLIENT = "create:client" SCOPE_DELETE_CLIENT = "delete:client" SCOPE_UPDATE_CLIENT = "update:client" +SCOPE_VERIFY_CLIENT = "verify:client" SCOPE_READ_CLIENT = "read:client" SCOPE_READ_COLLECTIONS = "read:collection" @@ -203,6 +204,7 @@ SCOPE_CREATE_CLIENT, SCOPE_READ_CLIENT, SCOPE_UPDATE_CLIENT, + SCOPE_VERIFY_CLIENT, SCOPE_DELETE_CLIENT, SCOPE_READ_COLLECTIONS, SCOPE_CREATE_COLLECTION, @@ -268,6 +270,7 @@ SCOPE_CREATE_CLIENT, SCOPE_READ_CLIENT, SCOPE_UPDATE_CLIENT, + SCOPE_VERIFY_CLIENT, SCOPE_DELETE_CLIENT, ], SCOPE_COLLECTION: [ @@ -343,6 +346,7 @@ PERMISSION_CLIENT_CREATE = "client:create" PERMISSION_CLIENT_UPDATE = "client:update" PERMISSION_CLIENT_DELETE = "client:delete" +PERMISSION_CLIENT_VERIFY = "client:verify" PERMISSION_CLIENT_DELETE_ADMIN = "client:delete_admin" USER_PERMISSIONS = [ @@ -368,6 +372,7 @@ PERMISSION_COLLECTION_UPDATE_MODERATOR, PERMISSION_COLLECTION_DELETE_MODERATOR, PERMISSION_EDIT_UPDATE_MODERATOR, + PERMISSION_CLIENT_VERIFY, ] ADMIN_PERMISSIONS = [ diff --git a/app/errors.py b/app/errors.py index 28755a78..a44bdeab 100644 --- a/app/errors.py +++ b/app/errors.py @@ -191,6 +191,7 @@ class ErrorResponse(CustomModel): "bad-backup-token": ["Bad backup token", 401], }, "client": { + "already-verified": ["Client is already verified", 400], "not-owner": ["User not owner of the client", 400], "max-clients": ["Maximum clients reached", 400], "not-found": ["Client not found", 404], diff --git a/app/models/auth/client.py b/app/models/auth/client.py index 44b4dde8..52d9edc8 100644 --- a/app/models/auth/client.py +++ b/app/models/auth/client.py @@ -14,6 +14,7 @@ class Client(Base): name: Mapped[str] description: Mapped[str] endpoint: Mapped[str] + verified: Mapped[bool] = mapped_column(default=False) user_id = mapped_column(ForeignKey("service_users.id")) user: Mapped["User"] = relationship(foreign_keys=user_id) diff --git a/app/schemas.py b/app/schemas.py index 9188e956..12aa7803 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -589,6 +589,7 @@ class ClientResponse(CustomModel): reference: str name: str description: str + verified: bool user: UserResponse diff --git a/tests/client/test_client_verify.py b/tests/client/test_client_verify.py new file mode 100644 index 00000000..775cf165 --- /dev/null +++ b/tests/client/test_client_verify.py @@ -0,0 +1,29 @@ +from tests.client_requests import request_client_create, request_client_verify +from starlette import status + + +async def test_client_verify(client, test_token, test_user, moderator_token): + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + response = await request_client_create( + client, + test_token, + name, + description, + endpoint, + ) + assert response.status_code == status.HTTP_200_OK + + created_client = response.json() + + assert created_client["verified"] == False + + client_reference = created_client["reference"] + + response = await request_client_verify( + client, moderator_token, client_reference + ) + assert response.status_code == status.HTTP_200_OK + + assert response.json()["verified"] == True diff --git a/tests/client_requests/__init__.py b/tests/client_requests/__init__.py index fb165edc..4df1f5c8 100644 --- a/tests/client_requests/__init__.py +++ b/tests/client_requests/__init__.py @@ -11,6 +11,7 @@ from .client import request_client_full_info from .client import request_client_create from .client import request_client_update +from .client import request_client_verify from .client import request_client_delete from .client import request_list_clients from .client import request_client_info @@ -128,6 +129,7 @@ "request_client_full_info", "request_client_create", "request_client_update", + "request_client_verify", "request_client_delete", "request_list_clients", "request_client_info", diff --git a/tests/client_requests/client.py b/tests/client_requests/client.py index bd7971da..8ebcec6d 100644 --- a/tests/client_requests/client.py +++ b/tests/client_requests/client.py @@ -56,3 +56,10 @@ def request_list_clients(client, token: str): "/client/", headers={"Auth": token}, ) + + +def request_client_verify(client, token: str, client_reference: str): + return client.post( + f"/client/{client_reference}/verify", + headers={"Auth": token}, + ) diff --git a/tests/conftest.py b/tests/conftest.py index d9606e0e..b492497a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,6 +103,25 @@ async def create_test_user_oauth(test_session): return await helpers.create_user(test_session, email="testuser@mail.com") +@pytest.fixture +async def moderator_user(test_session): + return await helpers.create_user( + test_session, + username="moderator", + email="moderator@mail.com", + role=constants.ROLE_MODERATOR, + ) + + +@pytest.fixture +async def moderator_token(test_session, moderator_user): + return ( + await helpers.create_token( + test_session, moderator_user.email, "moderator-token" + ) + ).secret + + @pytest.fixture async def create_test_user_moderator(test_session): return await helpers.create_user( From b320561367c6f4f442453def0389ba69b897c049 Mon Sep 17 00:00:00 2001 From: Darky2020 <59534288+Darky2020@users.noreply.github.com> Date: Sat, 10 Aug 2024 21:06:56 +0300 Subject: [PATCH 22/44] Add aggregator.py to the docker image --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 118a6e34..2ad8dd5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN touch README.md RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR COPY sync.py . +COPY aggregator.py . COPY app ./app CMD poetry run gunicorn "app:create_app()" --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 From 7a7e98b4bcdf605d13c9c321e926b1257258d132 Mon Sep 17 00:00:00 2001 From: Yaroslav <43380144+MrIkso@users.noreply.github.com> Date: Sun, 11 Aug 2024 18:12:48 +0300 Subject: [PATCH 23/44] added workflow for run test in actions --- .github/workflows/run-test.yml | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/run-test.yml diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml new file mode 100644 index 00000000..4dfb5455 --- /dev/null +++ b/.github/workflows/run-test.yml @@ -0,0 +1,51 @@ +name: Run Test + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + tests: + name: Test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:latest + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: password + POSTGRES_USER: user + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout git repository + uses: actions/checkout@v4 + + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: '3.12' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction --no-root + + - name: Copy settings.toml for configuration test database + run: cp docs/settings.example.toml settings.toml + + - name: Run tests + run: poetry run pytest + From 64935f9d5af73ddeb5aa1ba496c5eaf05a023175 Mon Sep 17 00:00:00 2001 From: Yaroslav <43380144+MrIkso@users.noreply.github.com> Date: Sun, 11 Aug 2024 18:24:40 +0300 Subject: [PATCH 24/44] update setup-python action to v5 --- .github/workflows/run-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 4dfb5455..87df8b8d 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@v4 - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.12' From e84a350bde8ed2b2825778b5b6d2eeee4510e7d0 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sun, 11 Aug 2024 23:08:36 +0300 Subject: [PATCH 25/44] Small thirdparty auth fixes --- app/auth/dependencies.py | 2 +- app/client/dependencies.py | 2 +- app/constants.py | 7 ++----- app/dependencies.py | 2 +- app/errors.py | 8 ++++---- app/utils.py | 7 +++---- sync.py | 2 +- 7 files changed, 13 insertions(+), 17 deletions(-) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index c0c36dd9..677a23d2 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -215,7 +215,7 @@ async def validate_client( def validate_scope(request: TokenRequestArgs) -> list[str]: for scope in request.scope: - if scope not in constants.ALL_SCOPES: + if scope not in constants.ALL_SCOPES + list(constants.SCOPE_GROUPS): raise Abort("auth", "invalid-scope") if len(request.scope) == 0: diff --git a/app/client/dependencies.py b/app/client/dependencies.py index 2e86d415..8ec63c18 100644 --- a/app/client/dependencies.py +++ b/app/client/dependencies.py @@ -23,7 +23,7 @@ async def validate_client_create( await service.count_user_clients( session, user, 0, constants.MAX_USER_CLIENTS ) - ) == constants.MAX_USER_CLIENTS: + ) >= constants.MAX_USER_CLIENTS: raise Abort("client", "max-clients") return create diff --git a/app/constants.py b/app/constants.py index 5adf47a4..29faf159 100644 --- a/app/constants.py +++ b/app/constants.py @@ -99,7 +99,7 @@ SEARCH_RESULT_SIZE = 15 -MAX_USER_CLIENTS = 15 +MAX_USER_CLIENTS = 10 # Meilisearch index names SEARCH_INDEX_CHARACTERS = "content_characters" @@ -303,10 +303,7 @@ SCOPE_FOLLOW, SCOPE_UNFOLLOW, ], - SCOPE_NOTIFICATION: [ - SCOPE_READ_NOTIFICATION, - SCOPE_SEEN_NOTIFICATION - ], + SCOPE_NOTIFICATION: [SCOPE_READ_NOTIFICATION, SCOPE_SEEN_NOTIFICATION], SCOPE_VOTE: [ SCOPE_READ_VOTE, SCOPE_SET_VOTE, diff --git a/app/dependencies.py b/app/dependencies.py index bf159c0d..234897f2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -149,7 +149,7 @@ async def auth( raise Abort("permission", "denied") if not utils.check_token_scope(token, scope): - raise Abort("scope", "denied") + raise Abort("permission", "denied") if token.user.role == constants.ROLE_DELETED: raise Abort("user", "deleted") diff --git a/app/errors.py b/app/errors.py index a44bdeab..2d1be4f3 100644 --- a/app/errors.py +++ b/app/errors.py @@ -37,7 +37,7 @@ class ErrorResponse(CustomModel): "user-not-found": ["User not found", 404], "invalid-scope": ["Invalid scope", 400], "email-set": ["Email already set", 400], - "not-available": ["Signup not available ", 400], + "not-available": ["Signup not available", 400], "invalid-username": ["Invalid username", 400], "scope-empty": ["Scope empty", 400], }, @@ -82,7 +82,7 @@ class ErrorResponse(CustomModel): "empty-edit": ["Empty edit", 400], }, "comment": { - "rate-limit": ["You have reached comment rate limit, try later", 400], + "rate-limit": ["You have reached comment rate limit, try later", 429], "not-editable": ["This comment can't be edited anymore", 400], "parent-not-found": ["Parent comment not found", 404], "already-hidden": ["Comment is already hidden", 400], @@ -142,7 +142,7 @@ class ErrorResponse(CustomModel): "not-found": ["Person not found", 404], }, "upload": { - "rate-limit": ["You have reached upload rate limit, try later", 400], + "rate-limit": ["You have reached upload rate limit, try later", 429], "not-square": ["Image should be square", 400], "bad-resolution": ["Bad resolution", 400], "bad-mime": ["Don't be bad mime", 400], @@ -195,7 +195,7 @@ class ErrorResponse(CustomModel): "not-owner": ["User not owner of the client", 400], "max-clients": ["Maximum clients reached", 400], "not-found": ["Client not found", 404], - } + }, } diff --git a/app/utils.py b/app/utils.py index bce9728f..e7a1f694 100644 --- a/app/utils.py +++ b/app/utils.py @@ -63,12 +63,13 @@ def check_user_permissions(user: User, permissions: list): return has_permission + def check_token_scope(token: AuthToken, scope: list[str]) -> bool: token_scope = set(resolve_scope_groups(token.scope)) scope = set(scope) - if not token.scope: + if not token.scope and not token.client: return True return token_scope.issuperset(scope) @@ -85,9 +86,7 @@ def resolve_scope_groups(scopes: list[str]) -> list[str]: # we need resolve them too group = resolve_scope_groups(group) - plain_scopes.extend( - group - ) + plain_scopes.extend(group) else: plain_scopes.append(scope) diff --git a/sync.py b/sync.py index 4bebf302..7ecd6925 100644 --- a/sync.py +++ b/sync.py @@ -22,7 +22,7 @@ def init_scheduler(): settings = get_settings() sessionmanager.init(settings.database.endpoint) - scheduler.add_job(delete_expired_token_requests, "interval", seconds=5) + scheduler.add_job(delete_expired_token_requests, "interval", seconds=30) scheduler.add_job(update_notifications, "interval", seconds=10) scheduler.add_job(update_ranking_all, "interval", hours=1) scheduler.add_job(update_activity, "interval", seconds=10) From 8b2c77d90c2492c55d6294c6b4c7d2c312a2dd4c Mon Sep 17 00:00:00 2001 From: kuyugama Date: Mon, 12 Aug 2024 22:50:35 +0300 Subject: [PATCH 26/44] Get and revoke third-party tokens Add: GET /auth/token/thirdparty to list third-party tokens Add: DELETE /auth/token/{token_reference} to revoke token by reference Rename: GET /auth/info to GET /auth/token/info Add: Last usage time field "used" to AuthToken --- ...38d06d1364e_add_used_field_to_authtoken.py | 32 +++++++++++ app/auth/dependencies.py | 19 +++++++ app/auth/router.py | 55 ++++++++++++++++++- app/auth/schemas.py | 13 ++++- app/auth/service.py | 54 +++++++++++++++++- app/dependencies.py | 14 +++-- app/errors.py | 1 + app/models/auth/auth_token.py | 2 + tests/auth/test_auth_thirdparty.py | 8 +-- tests/auth/test_list_thirdparty_tokens.py | 30 ++++++++++ tests/auth/test_revoke_token.py | 47 ++++++++++++++++ tests/client_requests/__init__.py | 8 ++- tests/client_requests/auth.py | 17 +++++- tests/conftest.py | 21 +++++++ tests/helpers.py | 37 ++++++++++++- 15 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 alembic/versions/2024_08_12_1036-b38d06d1364e_add_used_field_to_authtoken.py create mode 100644 tests/auth/test_list_thirdparty_tokens.py create mode 100644 tests/auth/test_revoke_token.py diff --git a/alembic/versions/2024_08_12_1036-b38d06d1364e_add_used_field_to_authtoken.py b/alembic/versions/2024_08_12_1036-b38d06d1364e_add_used_field_to_authtoken.py new file mode 100644 index 00000000..d3dba16d --- /dev/null +++ b/alembic/versions/2024_08_12_1036-b38d06d1364e_add_used_field_to_authtoken.py @@ -0,0 +1,32 @@ +"""Add "used" (last usage time) field to AuthToken + +Revision ID: b38d06d1364e +Revises: a4d5b6174fb1 +Create Date: 2024-08-12 10:36:22.543968 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b38d06d1364e" +down_revision = "a4d5b6174fb1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "service_auth_tokens", + sa.Column("used", sa.DateTime(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("service_auth_tokens", "used") + # ### end Alembic commands ### diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 677a23d2..6b3764e2 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -27,6 +27,7 @@ get_user_by_activation, get_user_by_reset, get_oauth_by_id, + get_auth_token, ) from .schemas import ( @@ -242,3 +243,21 @@ async def validate_auth_token_request( raise Abort("auth", "invalid-client-credentials") return request + + +async def validate_auth_token( + token_reference: uuid.UUID, + session: AsyncSession = Depends(get_session), + user: User = Depends(auth_required()), +): + now = utcnow() + if not (token := await get_auth_token(session, token_reference)): + raise Abort("auth", "invalid-token") + + if now > token.expiration: + raise Abort("auth", "token-expired") + + if token.user_id != user.id: + raise Abort("auth", "not-token-owner") + + return token diff --git a/app/auth/router.py b/app/auth/router.py index 79a28547..305bcc76 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,5 +1,5 @@ -from app.dependencies import check_captcha, auth_required, auth_token_required from app.models import User, UserOAuth, AuthToken, Client, AuthTokenRequest +from app.utils import pagination, pagination_dict, utcnow from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends from app.schemas import UserResponse @@ -9,6 +9,13 @@ from . import service from . import oauth +from app.dependencies import ( + auth_token_required, + check_captcha, + auth_required, + get_page, + get_size, +) from app.service import ( create_activation_token, create_email, @@ -20,6 +27,7 @@ validate_activation_resend, validate_password_confirm, validate_password_reset, + validate_auth_token, validate_activation, validate_provider, validate_signup, @@ -31,9 +39,10 @@ ) from .schemas import ( + AuthTokenInfoPaginationResponse, + AuthTokenInfoResponse, TokenRequestResponse, ProviderUrlResponse, - AuthInfoResponse, TokenResponse, SignupArgs, ) @@ -193,7 +202,9 @@ async def oauth_token( @router.get( - "/info", summary="Get authorization info", response_model=AuthInfoResponse + "/token/info", + summary="Get token info", + response_model=AuthTokenInfoResponse, ) async def auth_info(token: AuthToken = Depends(auth_token_required)): return token @@ -230,3 +241,41 @@ async def third_party_auth_token( {"scope": token_request.scope}, ) return await service.create_auth_token_from_request(session, token_request) + + +@router.get( + "/token/thirdparty", + summary="List third-party auth tokens", + response_model=AuthTokenInfoPaginationResponse, +) +async def third_party_auth_tokens( + session: AsyncSession = Depends(get_session), + user: User = Depends(auth_required(forbid_thirdparty=True)), + page: int = Depends(get_page), + size: int = Depends(get_size), +): + limit, offset = pagination(page, size) + now = utcnow() + + total = await service.count_user_thirdparty_auth_tokens(session, user, now) + tokens = await service.list_user_thirdparty_auth_tokens( + session, user, offset, limit, now + ) + + return { + "pagination": pagination_dict(total, page, limit), + "list": tokens.all(), + } + + +@router.delete( + "/token/{token_reference}", + summary="Revoke auth token", + response_model=AuthTokenInfoResponse, + dependencies=[Depends(auth_required(forbid_thirdparty=True))], +) +async def revoke_token( + token: AuthToken = Depends(validate_auth_token), + session: AsyncSession = Depends(get_session), +): + return await service.revoke_auth_token(session, token) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index eb43bcb3..f5942a5c 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -1,6 +1,6 @@ -from app import constants -from app.schemas import datetime_pd, ClientResponse +from app.schemas import datetime_pd, ClientResponse, PaginationResponse from pydantic import Field +from app import constants import uuid from app.schemas import ( @@ -42,13 +42,20 @@ class TokenResponse(CustomModel): ) -class AuthInfoResponse(CustomModel): +class AuthTokenInfoResponse(CustomModel): + reference: str = Field(examples=["c773d0bf-1c42-4c18-aec8-1bdd8cb0a434"]) created: datetime_pd = Field(examples=[1686088809]) client: ClientResponse | None = Field( description="Information about logged by third-party client" ) scope: list[str] expiration: datetime_pd = Field(examples=[1686088809]) + used: datetime_pd | None = Field(examples=[1686088809, None]) + + +class AuthTokenInfoPaginationResponse(CustomModel): + list: list[AuthTokenInfoResponse] + pagination: PaginationResponse class TokenRequestResponse(CustomModel): diff --git a/app/auth/service.py b/app/auth/service.py index 647956b7..34e8680e 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -3,13 +3,13 @@ from starlette.datastructures import URL from app.models import User, AuthToken, UserOAuth, AuthTokenRequest, Client +from sqlalchemy import select, func, ScalarResult from app.utils import hashpwd, new_token, utcnow from sqlalchemy.ext.asyncio import AsyncSession from app.service import get_user_by_username +from datetime import timedelta, datetime from sqlalchemy.orm import selectinload from .schemas import SignupArgs -from datetime import timedelta -from sqlalchemy import select from app import constants import secrets @@ -155,6 +155,7 @@ async def create_auth_token(session: AsyncSession, user: User) -> AuthToken: "expiration": now + timedelta(minutes=30), "secret": new_token(), "created": now, + "used": now, "user": user, } ) @@ -264,3 +265,52 @@ async def create_auth_token_from_request( await session.commit() return token + + +async def count_user_thirdparty_auth_tokens( + session: AsyncSession, user: User, now: datetime +) -> int: + return await session.scalar( + select(func.count(AuthToken.id)).filter( + AuthToken.user_id == user.id, + AuthToken.client_id.is_not(None), + AuthToken.expiration >= now, + ) + ) + + +async def list_user_thirdparty_auth_tokens( + session: AsyncSession, + user: User, + offset: int, + limit: int, + now: datetime, +) -> ScalarResult[AuthToken]: + return await session.scalars( + select(AuthToken) + .options(selectinload(AuthToken.client)) + .filter( + AuthToken.user_id == user.id, + AuthToken.client_id.is_not(None), + AuthToken.expiration >= now, + ) + .offset(offset) + .limit(limit) + ) + + +async def get_auth_token( + session: AsyncSession, reference: str | uuid.UUID +) -> AuthToken: + return await session.scalar( + select(AuthToken) + .filter(AuthToken.id == reference) + .options(selectinload(AuthToken.client), selectinload(AuthToken.user)) + ) + + +async def revoke_auth_token(session: AsyncSession, token: AuthToken): + await session.delete(token) + await session.commit() + + return token diff --git a/app/dependencies.py b/app/dependencies.py index 234897f2..64ed2497 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -68,6 +68,8 @@ async def _auth_token_or_abort( session: AsyncSession = Depends(get_session), token: str | None = Depends(get_request_auth_token), ) -> Abort | AuthToken: + now = utcnow() + if not token: return Abort("auth", "missing-token") @@ -80,8 +82,13 @@ async def _auth_token_or_abort( if token.user.banned: return Abort("auth", "banned") - return token + if now > token.expiration: + return Abort("auth", "token-expired") + + token.used = now + await session.commit() + return token async def auth_token_required( token: AuthToken | Abort = Depends(_auth_token_or_abort), @@ -93,7 +100,7 @@ async def auth_token_required( async def auth_token_optional( - token: AuthToken | Abort = Depends(_auth_token_or_abort), + token: AuthToken | Abort = Depends(_auth_token_or_abort) ) -> AuthToken | None: if isinstance(token, Abort): return None @@ -138,9 +145,6 @@ async def auth( now = utcnow() - if now > token.expiration: - raise Abort("auth", "token-expired") - # Check requested permissions here if not utils.check_user_permissions(token.user, permissions): raise Abort("permission", "denied") diff --git a/app/errors.py b/app/errors.py index 2d1be4f3..d4f8843a 100644 --- a/app/errors.py +++ b/app/errors.py @@ -20,6 +20,7 @@ class ErrorResponse(CustomModel): "token-request-expired": ["Token request has expired", 400], "activation-invalid": ["Activation token is invalid", 400], "invalid-token-request": ["Invalid token request", 400], + "not-token-owner": ["User is not token owner", 400], "oauth-code-required": ["OAuth code required", 400], "invalid-provider": ["Invalid OAuth provider", 400], "username-taken": ["Username already taken", 400], diff --git a/app/models/auth/auth_token.py b/app/models/auth/auth_token.py index 6a81a6f7..834647d3 100644 --- a/app/models/auth/auth_token.py +++ b/app/models/auth/auth_token.py @@ -12,7 +12,9 @@ class AuthToken(Base): secret: Mapped[str] = mapped_column(String(64), unique=True, index=True) expiration: Mapped[datetime] + created: Mapped[datetime] + used: Mapped[datetime] = mapped_column(nullable=True) user_id = mapped_column(ForeignKey("service_users.id")) diff --git a/tests/auth/test_auth_thirdparty.py b/tests/auth/test_auth_thirdparty.py index aa78ac00..5d307b26 100644 --- a/tests/auth/test_auth_thirdparty.py +++ b/tests/auth/test_auth_thirdparty.py @@ -4,9 +4,9 @@ from tests.client_requests import ( request_auth_token_request, + request_auth_token_info, request_client_create, request_auth_token, - request_auth_info, ) @@ -43,9 +43,9 @@ async def test_auth_thirdparty(client, test_token): thirdparty_token = response.json()["secret"] - response = await request_auth_info(client, thirdparty_token) + response = await request_auth_token_info(client, thirdparty_token) assert response.status_code == status.HTTP_200_OK - auth_info = response.json() + token_info = response.json() - assert auth_info["client"]["reference"] == client_reference + assert token_info["client"]["reference"] == client_reference diff --git a/tests/auth/test_list_thirdparty_tokens.py b/tests/auth/test_list_thirdparty_tokens.py new file mode 100644 index 00000000..ade323b4 --- /dev/null +++ b/tests/auth/test_list_thirdparty_tokens.py @@ -0,0 +1,30 @@ +from starlette import status + +from tests.client_requests import ( + request_list_thirdparty_tokens, + request_auth_token_info, +) + + +async def test_list_no_thirdparty_tokens(client, test_token): + response = await request_list_thirdparty_tokens(client, test_token) + assert response.status_code == status.HTTP_200_OK + + assert response.json()["list"] == [] + assert response.json()["pagination"] == {"total": 0, "page": 1, "pages": 0} + + +async def test_list_thirdparty_tokens( + client, test_token, test_thirdparty_token +): + response = await request_auth_token_info(client, test_thirdparty_token) + assert response.status_code == status.HTTP_200_OK + + token_info = response.json() + + response = await request_list_thirdparty_tokens(client, test_token) + assert response.status_code == status.HTTP_200_OK + + assert response.json()["pagination"] == {"total": 1, "page": 1, "pages": 1} + + assert response.json()["list"] == [token_info] diff --git a/tests/auth/test_revoke_token.py b/tests/auth/test_revoke_token.py new file mode 100644 index 00000000..6c022480 --- /dev/null +++ b/tests/auth/test_revoke_token.py @@ -0,0 +1,47 @@ +from starlette import status + +from tests.client_requests import ( + request_auth_token_info, + request_revoke_token, +) + + +async def test_revoke_token(client, test_token): + response = await request_auth_token_info(client, test_token) + assert response.status_code == status.HTTP_200_OK + + token_reference = response.json()["reference"] + + response = await request_revoke_token(client, test_token, token_reference) + assert response.status_code == status.HTTP_200_OK + + response = await request_auth_token_info(client, test_token) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +async def test_revoke_thirdparty_token( + client, test_token, test_thirdparty_client, test_thirdparty_token +): + + response = await request_auth_token_info(client, test_thirdparty_token) + assert response.status_code == status.HTTP_200_OK + + token_info = response.json() + + assert token_info["client"]["reference"] == test_thirdparty_client.reference + + response = await request_revoke_token( + client, test_thirdparty_token, token_info["reference"] + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["code"] == "permission:denied" + + response = await request_revoke_token( + client, test_token, token_info["reference"] + ) + assert response.status_code == status.HTTP_200_OK + + response = await request_auth_token_info(client, test_thirdparty_token) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json()["code"] == "auth:invalid_token" diff --git a/tests/client_requests/__init__.py b/tests/client_requests/__init__.py index 4df1f5c8..6226f420 100644 --- a/tests/client_requests/__init__.py +++ b/tests/client_requests/__init__.py @@ -1,10 +1,12 @@ +from .auth import request_list_thirdparty_tokens from .auth import request_auth_token_request from .auth import request_activation_resend from .auth import request_password_confirm +from .auth import request_auth_token_info from .auth import request_password_reset +from .auth import request_revoke_token from .auth import request_activation from .auth import request_auth_token -from .auth import request_auth_info from .auth import request_signup from .auth import request_login @@ -117,13 +119,15 @@ from .system import request_backup_images __all__ = [ + "request_list_thirdparty_tokens", "request_auth_token_request", "request_activation_resend", "request_password_confirm", + "request_auth_token_info", "request_password_reset", + "request_revoke_token", "request_activation", "request_auth_token", - "request_auth_info", "request_signup", "request_login", "request_client_full_info", diff --git a/tests/client_requests/auth.py b/tests/client_requests/auth.py index f2e0c533..b3d7b6b4 100644 --- a/tests/client_requests/auth.py +++ b/tests/client_requests/auth.py @@ -46,9 +46,9 @@ def request_password_confirm(client, token, new_password): ) -def request_auth_info(client, token: str): +def request_auth_token_info(client, token: str): return client.get( - "/auth/info", + "/auth/token/info", headers={"Auth": token}, ) @@ -71,3 +71,16 @@ def request_auth_token(client, request_reference: str, client_secret: str): ) + + +def request_list_thirdparty_tokens(client, token: str): + return client.get( + "/auth/token/thirdparty", + headers={"Auth": token}, + ) + +def request_revoke_token(client, token: str, token_reference: str): + return client.delete( + f"/auth/token/{token_reference}", + headers={"Auth": token}, + ) diff --git a/tests/conftest.py b/tests/conftest.py index b492497a..0f732d52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -370,3 +370,24 @@ async def aggregator_anime_franchises(test_session): data = await helpers.load_json("tests/data/anime_franchises.json") await aggregator.save_franchises_list(test_session, data["list"]) + + +@pytest.fixture +async def test_thirdparty_client(test_session, test_user): + return await helpers.create_client( + test_session, test_user, "test-thirdparty-client" + ) + + +@pytest.fixture +async def test_thirdparty_token( + test_session, test_user, test_thirdparty_client +): + return ( + await helpers.create_token( + test_session, + test_user.email, + "thirdparty-token-secret", + test_thirdparty_client, + ) + ).secret diff --git a/tests/helpers.py b/tests/helpers.py index 1cc63358..7d5ec734 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,6 @@ -from app.models import User, UserOAuth, AuthToken +from app.models import User, UserOAuth, AuthToken, Client from app.utils import new_token, hashpwd, utcnow +from sqlalchemy.ext.asyncio import AsyncSession from datetime import timedelta from sqlalchemy import select from app import constants @@ -62,7 +63,9 @@ async def create_oauth(test_session, user_id): return oauth -async def create_token(test_session, email, token_secret): +async def create_token( + test_session, email, token_secret, client: Client = None +): now = utcnow() user = await test_session.scalar(select(User).filter(User.email == email)) @@ -73,6 +76,7 @@ async def create_token(test_session, email, token_secret): "secret": token_secret, "created": now, "user": user, + "client": client, } ) @@ -80,3 +84,32 @@ async def create_token(test_session, email, token_secret): await test_session.commit() return token + + +async def create_client( + session: AsyncSession, + user: User, + secret: str, + name: str = "TestClient", + description: str = "Test client", + endpoint: str = "http://localhost/", + verified: bool = False, +): + now = utcnow() + client = Client( + **{ + "secret": secret, + "name": name, + "description": description, + "endpoint": endpoint, + "verified": verified, + "user": user, + "created": now, + "updated": now, + } + ) + + session.add(client) + await session.commit() + + return client From 76c3c88979a9842cc7b1311d9b0f38691970e691 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Mon, 12 Aug 2024 23:30:09 +0300 Subject: [PATCH 27/44] Fix: _check_captcha was not awaited --- app/edit/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index f2cd9e5c..d5b85898 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -195,4 +195,4 @@ async def check_captcha( if auth_token is not None and auth_token.client is not None: return True - return _check_captcha(captcha) + return await _check_captcha(captcha) From 2ec7b490e885bdaff079c439c68800ddac05c69b Mon Sep 17 00:00:00 2001 From: kuyugama Date: Mon, 12 Aug 2024 23:41:10 +0300 Subject: [PATCH 28/44] Fix moderator permissions --- app/constants.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/constants.py b/app/constants.py index 29faf159..3a6af065 100644 --- a/app/constants.py +++ b/app/constants.py @@ -366,6 +366,10 @@ MODERATOR_PERMISSIONS = [ *USER_PERMISSIONS, + PERMISSION_EDIT_ACCEPT, + PERMISSION_EDIT_REJECT, + PERMISSION_EDIT_AUTO, + PERMISSION_COMMENT_HIDE_ADMIN, PERMISSION_COLLECTION_UPDATE_MODERATOR, PERMISSION_COLLECTION_DELETE_MODERATOR, PERMISSION_EDIT_UPDATE_MODERATOR, @@ -374,7 +378,6 @@ ADMIN_PERMISSIONS = [ *MODERATOR_PERMISSIONS, - PERMISSION_COMMENT_HIDE_ADMIN, PERMISSION_CLIENT_DELETE_ADMIN, ] From cfadfedccc1d0390f540f67852213e243942671d Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sat, 17 Aug 2024 19:10:09 +0300 Subject: [PATCH 29/44] Add edit rate limits --- app/comments/service.py | 7 +++-- app/comments/utils.py | 10 ------- app/edit/dependencies.py | 51 ++++++++++++++++++++++++++++++++++ app/edit/router.py | 16 +++-------- app/edit/service.py | 33 ++++++++++++++++++++++ app/errors.py | 1 + app/utils.py | 14 +++++++++- tests/conftest.py | 15 ++++++++-- tests/edit/test_edit_create.py | 38 ++++++++++++++++++++++++- tests/edit/test_edit_update.py | 49 ++++++++++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 30 deletions(-) diff --git a/app/comments/service.py b/app/comments/service.py index 9765d6bf..e1db1fb6 100644 --- a/app/comments/service.py +++ b/app/comments/service.py @@ -1,13 +1,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, asc, func -from .utils import uuid_to_path, round_hour from sqlalchemy.orm import with_expression from sqlalchemy.orm import immediateload +from app.utils import round_datettime from sqlalchemy.orm import joinedload from .schemas import ContentTypeEnum from sqlalchemy_utils import Ltree -from uuid import UUID, uuid4 +from .utils import uuid_to_path from app.utils import utcnow +from uuid import UUID, uuid4 from app import constants from app import utils import copy @@ -216,7 +217,7 @@ async def count_comments_limit(session: AsyncSession, author: User) -> int: return await session.scalar( select(func.count(Comment.id)).filter( Comment.author == author, - Comment.created > round_hour(utcnow()), + Comment.created > round_datettime(utcnow(), hours=1), Comment.deleted == False, # noqa: E712 ) ) diff --git a/app/comments/utils.py b/app/comments/utils.py index 80f2216a..8638a913 100644 --- a/app/comments/utils.py +++ b/app/comments/utils.py @@ -1,6 +1,5 @@ from app.utils import path_to_uuid from .schemas import CommentNode -from datetime import timedelta # Convert uuid reference to comment path @@ -50,12 +49,3 @@ def calculate_total_replies(node): calculate_total_replies(tree) return tree - - -def round_hour(date): - return date - timedelta( - hours=date.hour % 1, - minutes=date.minute, - seconds=date.second, - microseconds=date.microsecond, - ) diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index dda33b67..96b6d3ea 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -202,3 +202,54 @@ async def check_captcha( return True return await _check_captcha(captcha) + + +# Todo: perhaps the log based rate limiting logic could be abstracted in the future? +async def validate_edit_create_rate_limit( + session: AsyncSession = Depends(get_session), + user: User = Depends( + auth_required( + permissions=[constants.PERMISSION_EDIT_CREATE], + scope=[constants.SCOPE_CREATE_EDIT], + ) + ), +): + count = await service.count_created_edit_limit(session, user) + create_edit_limit = 25 + + if ( + user.role + not in [ + constants.ROLE_ADMIN, + constants.ROLE_MODERATOR, + ] + and count >= create_edit_limit + ): + raise Abort("edit", "rate-limit") + + return user + + +async def validate_edit_update_rate_limit( + session: AsyncSession = Depends(get_session), + user: User = Depends( + auth_required( + permissions=[constants.PERMISSION_EDIT_UPDATE], + scope=[constants.SCOPE_UPDATE_EDIT], + ) + ), +): + count = await service.count_update_edit_limit(session, user) + update_edit_limit = 25 + + if ( + user.role + not in [ + constants.ROLE_ADMIN, + constants.ROLE_MODERATOR, + ] + and count >= update_edit_limit + ): + raise Abort("edit", "rate-limit") + + return user diff --git a/app/edit/router.py b/app/edit/router.py index 14cbfcc5..2afb813e 100644 --- a/app/edit/router.py +++ b/app/edit/router.py @@ -29,6 +29,8 @@ ) from .dependencies import ( + validate_edit_update_rate_limit, + validate_edit_create_rate_limit, validate_edit_search_args, validate_edit_update_args, validate_edit_id_pending, @@ -85,12 +87,7 @@ async def create_edit( validate_content ), args: EditArgs = Depends(validate_edit_create), - author: User = Depends( - auth_required( - permissions=[constants.PERMISSION_EDIT_CREATE], - scope=[constants.SCOPE_CREATE_EDIT], - ) - ), + author: User = Depends(validate_edit_create_rate_limit), _: bool = Depends(check_captcha), ): return await service.create_pending_edit( @@ -103,12 +100,7 @@ async def update_edit( session: AsyncSession = Depends(get_session), args: EditArgs = Depends(validate_edit_update_args), edit: Edit = Depends(validate_edit_update), - user: User = Depends( - auth_required( - permissions=[constants.PERMISSION_EDIT_UPDATE], - scope=[constants.SCOPE_UPDATE_EDIT], - ) - ), + user: User = Depends(validate_edit_update_rate_limit), _: bool = Depends(check_captcha), ): return await service.update_pending_edit(session, edit, user, args) diff --git a/app/edit/service.py b/app/edit/service.py index 83331a28..a0e47797 100644 --- a/app/edit/service.py +++ b/app/edit/service.py @@ -4,9 +4,12 @@ from sqlalchemy import select, asc, desc, func from sqlalchemy.sql.selectable import Select from sqlalchemy.orm import with_expression +from app.utils import round_datettime from sqlalchemy.orm import joinedload from .utils import calculate_before +from app.errors import Abort from app.utils import utcnow +from app.models import Log from app import constants import copy @@ -494,3 +497,33 @@ async def content_todo( .limit(limit) .offset(offset) ) + + +async def count_created_edit_limit(session: AsyncSession, user: User) -> int: + return await session.scalar( + select(func.count()) + .filter( + Log.log_type.in_( + [ + constants.LOG_EDIT_CREATE, + ] + ) + ) + .filter(Log.created > round_datettime(utcnow(), minutes=5)) + .filter(Log.user == user) + ) + + +async def count_update_edit_limit(session: AsyncSession, user: User) -> int: + return await session.scalar( + select(func.count()) + .filter( + Log.log_type.in_( + [ + constants.LOG_EDIT_UPDATE, + ] + ) + ) + .filter(Log.created > round_datettime(utcnow(), minutes=5)) + .filter(Log.user == user) + ) diff --git a/app/errors.py b/app/errors.py index d4f8843a..5c342a1c 100644 --- a/app/errors.py +++ b/app/errors.py @@ -70,6 +70,7 @@ class ErrorResponse(CustomModel): "not-found": ["Manga not found", 404], }, "edit": { + "rate-limit": ["You have reached the edit rate limit, try later", 429], "missing-content-type": ["You must specify content type", 400], "not-pending": ["Only pending edit can be changed", 400], "moderator-not-found": ["Moderator not found", 404], diff --git a/app/utils.py b/app/utils.py index e7a1f694..d8f2ef1c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,13 +1,13 @@ from starlette.middleware.base import BaseHTTPMiddleware from dateutil.relativedelta import relativedelta from fastapi.responses import JSONResponse +from datetime import timezone, timedelta from fastapi import FastAPI, Request from datetime import datetime, UTC from app.models import AuthToken from functools import lru_cache from urllib.parse import quote from dynaconf import Dynaconf -from datetime import timezone from app.models import User from app import constants from uuid import UUID @@ -52,6 +52,18 @@ def utcfromtimestamp(timestamp: int): return datetime.fromtimestamp(timestamp, UTC).replace(tzinfo=None) +# Helper function to round a datetime object to the nearest hour/minute/second +def round_datettime( + date: datetime, hours: int = 1, minutes: int = 1, seconds: int = 1 +): + return date - timedelta( + hours=date.hour % hours, + minutes=date.minute % minutes, + seconds=date.second % seconds, + microseconds=date.microsecond, + ) + + # Simple check for permissions # TODO: move to separate file with role logic def check_user_permissions(user: User, permissions: list): diff --git a/tests/conftest.py b/tests/conftest.py index 0f732d52..ffa17c21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,12 +11,13 @@ from app.utils import get_settings from contextlib import ExitStack from sqlalchemy import make_url -from app import create_app +from datetime import datetime from app import aggregator -from unittest import mock +from app import create_app from app import constants -import helpers +from unittest import mock import asyncio +import helpers import pytest @@ -208,6 +209,14 @@ def mock_s3_upload_file(): yield mocked +# Fix utcnow() datetime for tests that rely on it not changing within the duration of the test +@pytest.fixture(autouse=False) +def mock_utcnow(): + with mock.patch("app.utils.utcnow") as mocked: + mocked.return_value = datetime(2024, 2, 17, 10, 23, 29, 305502) + yield mocked + + # Aggregator fixtures @pytest.fixture async def aggregator_genres(test_session): diff --git a/tests/edit/test_edit_create.py b/tests/edit/test_edit_create.py index 2121b9a1..2935ddc8 100644 --- a/tests/edit/test_edit_create.py +++ b/tests/edit/test_edit_create.py @@ -32,7 +32,7 @@ async def test_edit_create( "description": "Brief description", "after": { "title_en": "Bocchi The Rock!", - "synonyms": ["bochchi"], # This shoud be filtered out + "synonyms": ["bochchi"], # This should be filtered out }, }, ) @@ -243,3 +243,39 @@ async def test_edit_create_empty_edit( # Check status assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["code"] == "system:validation_error" + + +async def test_edit_create_rate_limit( + client, + aggregator_anime, + create_test_user, + get_test_token, + test_session, + mock_utcnow, +): + create_edit_limit = 25 + + for index in range(0, create_edit_limit + 1): + # Create edit for anime + response = await request_create_edit( + client, + get_test_token, + "anime", + "bocchi-the-rock-9e172d", + { + "description": "Brief description", + "after": { + "title_en": "Bocchi The Rock!", + }, + }, + ) + + # Make sure request prior to the rate limit is good + if index == create_edit_limit - 1: + assert response.status_code == status.HTTP_200_OK + assert "code" not in response.json() + + # Check the rate limit + if index == create_edit_limit: + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.json()["code"] == "edit:rate_limit" diff --git a/tests/edit/test_edit_update.py b/tests/edit/test_edit_update.py index 4fdd2905..bf20585a 100644 --- a/tests/edit/test_edit_update.py +++ b/tests/edit/test_edit_update.py @@ -156,3 +156,52 @@ async def test_edit_update_moderator( ) assert response.status_code == status.HTTP_200_OK + + +async def test_edit_create_rate_limit( + client, + aggregator_anime, + aggregator_anime_info, + create_test_user, + get_test_token, + test_session, + mock_utcnow, +): + # Create edit for anime + response = await request_create_edit( + client, + get_test_token, + "anime", + "bocchi-the-rock-9e172d", + { + "description": "Brief description", + "after": {"title_en": "Bocchi The Rock!"}, + }, + ) + + # Simulate delay between create/update + await asyncio.sleep(1) + + update_edit_limit = 25 + + for index in range(0, update_edit_limit + 1): + # Update created edit + response = await request_update_edit( + client, + get_test_token, + 18, + { + "description": "Brief description 2", + "after": {"title_en": "Bocchi The Rock!"}, + }, + ) + + # Make sure request prior to the rate limit is good + if index == update_edit_limit - 1: + assert response.status_code == status.HTTP_200_OK + assert "code" not in response.json() + + # Check the rate limit + if index == update_edit_limit: + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.json()["code"] == "edit:rate_limit" From e6457757498406c00433b0282e28f1d282474d53 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sun, 18 Aug 2024 06:23:08 +0300 Subject: [PATCH 30/44] Add alembic migrations to the Dockerfile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2ad8dd5b..59803107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR COPY sync.py . COPY aggregator.py . +COPY alembic ./alembic COPY app ./app -CMD poetry run gunicorn "app:create_app()" --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +CMD poetry run alembic upgrade head && poetry run gunicorn "app:create_app()" --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 From 916344a8cd4bc10eff5d45f31b078b7b1e0061c3 Mon Sep 17 00:00:00 2001 From: olexh Date: Thu, 22 Aug 2024 18:56:32 +0300 Subject: [PATCH 31/44] Remove trailing slash from client endpoints --- app/client/router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/client/router.py b/app/client/router.py index 39961d9f..2a76e4eb 100644 --- a/app/client/router.py +++ b/app/client/router.py @@ -26,7 +26,7 @@ @router.get( - "/", summary="List user clients", response_model=ClientPaginationResponse + "", summary="List user clients", response_model=ClientPaginationResponse ) async def list_user_clients( page: int = Depends(get_page), @@ -67,7 +67,7 @@ async def get_user_client(client: Client = Depends(validate_user_client)): @router.post( - "/", summary="Create new user client", response_model=ClientFullResponse + "", summary="Create new user client", response_model=ClientFullResponse ) async def create_user_client( create: ClientCreate = Depends(validate_client_create), From 120fd3e954333914d40435239c7a1ebec70a1f77 Mon Sep 17 00:00:00 2001 From: olexh Date: Thu, 22 Aug 2024 19:21:44 +0300 Subject: [PATCH 32/44] Remove trailing slash from client tests --- tests/client_requests/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/client_requests/client.py b/tests/client_requests/client.py index 8ebcec6d..9a6ce659 100644 --- a/tests/client_requests/client.py +++ b/tests/client_requests/client.py @@ -1,6 +1,6 @@ def request_client_create(client, token: str, name: str, description: str, endpoint: str): return client.post( - "/client/", + "/client", headers={"Auth": token}, json={ "name": name, @@ -53,7 +53,7 @@ def request_client_delete( def request_list_clients(client, token: str): return client.get( - "/client/", + "/client", headers={"Auth": token}, ) From 563010170818c96d2ee5ce86f744c2480ad74354 Mon Sep 17 00:00:00 2001 From: kuyugama Date: Thu, 22 Aug 2024 22:13:35 +0300 Subject: [PATCH 33/44] Limit Client "name", "description" and "endpoint" fields by length --- app/client/schemas.py | 35 ++++++++++++++++-- app/constants.py | 4 +++ tests/client/test_client_create.py | 48 +++++++++++++++++++++++++ tests/client/test_client_update.py | 57 ++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/app/client/schemas.py b/app/client/schemas.py index a87af755..a46e3a85 100644 --- a/app/client/schemas.py +++ b/app/client/schemas.py @@ -1,6 +1,7 @@ -from pydantic import Field, HttpUrl +from pydantic import Field, HttpUrl, field_validator from app.schemas import CustomModel, ClientResponse, PaginationResponse +from app import constants class ClientFullResponse(ClientResponse): @@ -15,31 +16,59 @@ class ClientPaginationResponse(CustomModel): class ClientCreate(CustomModel): name: str = Field( - examples=["ThirdPartyWatchlistImporter"], description="Client name" + examples=["ThirdPartyWatchlistImporter"], + description="Client name", + max_length=constants.MAX_CLIENT_NAME_LENGTH, ) description: str = Field( examples=["Client that imports watchlist from third-party services"], description="Short clear description of the client", + max_length=constants.MAX_CLIENT_DESCRIPTION_LENGTH, ) endpoint: HttpUrl = Field( examples=["https://example.com", "http://localhost/auth/confirm"], description="Endpoint of the client. " "User will be redirected to that endpoint after successful " "authorization", + max_length=constants.MAX_CLIENT_ENDPOINT_LENGTH, ) + @field_validator("endpoint") + def validate_endpoint(cls, v: HttpUrl) -> HttpUrl: + if len(str(v)) > constants.MAX_CLIENT_ENDPOINT_LENGTH: + raise ValueError( + f"Endpoint length should be less than {constants.MAX_CLIENT_ENDPOINT_LENGTH}" + ) + + return v + class ClientUpdate(CustomModel): name: str | None = Field( None, description="Client name", + max_length=constants.MAX_CLIENT_NAME_LENGTH, ) description: str | None = Field( None, description="Short clear description of the client", + max_length=constants.MAX_CLIENT_DESCRIPTION_LENGTH, + ) + endpoint: HttpUrl | None = Field( + None, + description="Endpoint of the client", + max_length=constants.MAX_CLIENT_ENDPOINT_LENGTH, ) - endpoint: HttpUrl | None = Field(None, description="Endpoint of the client") revoke_secret: bool = Field( False, description="Create new client secret and revoke previous", ) + + @field_validator("endpoint") + def validate_endpoint(cls, v: HttpUrl | None) -> HttpUrl | None: + if len(str(v)) > constants.MAX_CLIENT_ENDPOINT_LENGTH: + raise ValueError( + f"Endpoint length should be less than {constants.MAX_CLIENT_ENDPOINT_LENGTH}" + ) + + return v diff --git a/app/constants.py b/app/constants.py index aff52d3a..708507f4 100644 --- a/app/constants.py +++ b/app/constants.py @@ -101,6 +101,10 @@ MAX_USER_CLIENTS = 10 +MAX_CLIENT_NAME_LENGTH = 128 +MAX_CLIENT_DESCRIPTION_LENGTH = 512 +MAX_CLIENT_ENDPOINT_LENGTH = 128 + # Meilisearch index names SEARCH_INDEX_CHARACTERS = "content_characters" SEARCH_INDEX_COMPANIES = "content_companies" diff --git a/tests/client/test_client_create.py b/tests/client/test_client_create.py index 4d3d0f8b..ee9bb7fb 100644 --- a/tests/client/test_client_create.py +++ b/tests/client/test_client_create.py @@ -1,6 +1,7 @@ from starlette import status from tests.client_requests import request_client_create +from app import constants async def test_client_create(client, test_token, test_user): @@ -51,3 +52,50 @@ async def test_client_create_double(client, test_token): ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["code"] == "client:already_exists" + + +async def test_too_long_fields(client, test_token): + error_message_format = "Invalid field {field} in request body" + error_code = "system:validation_error" + + response = await request_client_create( + client, + test_token, + "a" * (constants.MAX_CLIENT_NAME_LENGTH + 1), + "description", + "http://localhost/", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="name" + ) + + response = await request_client_create( + client, + test_token, + "name", + "a" * (constants.MAX_CLIENT_DESCRIPTION_LENGTH + 1), + "http://localhost/", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="description" + ) + + response = await request_client_create( + client, + test_token, + "name", + "description", + "http://localhost/" + "a" * (constants.MAX_CLIENT_ENDPOINT_LENGTH + 1), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="endpoint" + ) diff --git a/tests/client/test_client_update.py b/tests/client/test_client_update.py index 781dacbd..41768891 100644 --- a/tests/client/test_client_update.py +++ b/tests/client/test_client_update.py @@ -3,6 +3,7 @@ from starlette import status from tests.client_requests import request_client_create, request_client_update +from app import constants async def test_client_update(client, test_token): @@ -52,3 +53,59 @@ async def test_client_update_nonexistent(client, test_token): assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json()["code"] == "client:not_found" + + +async def test_too_long_fields(client, test_token): + error_message_format = "Invalid field {field} in request body" + error_code = "system:validation_error" + + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + client_reference = response.json()["reference"] + + response = await request_client_update( + client, + test_token, + client_reference, + name="a" * (constants.MAX_CLIENT_NAME_LENGTH + 1), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="name" + ) + + response = await request_client_update( + client, + test_token, + client_reference, + description="a" * (constants.MAX_CLIENT_DESCRIPTION_LENGTH + 1), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="description" + ) + + response = await request_client_update( + client, + test_token, + client_reference, + endpoint="http://localhost/" + + "a" * (constants.MAX_CLIENT_ENDPOINT_LENGTH + 1), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="endpoint" + ) From a649089449a27484c428724a77439a80bb2ab53e Mon Sep 17 00:00:00 2001 From: kuyugama Date: Fri, 23 Aug 2024 10:16:13 +0300 Subject: [PATCH 34/44] Set Client "name" and "description" fields minimum length for 3 characters --- app/client/schemas.py | 20 ++++++++++++++++- tests/client/test_client_create.py | 33 +++++++++++++++++++++++++++ tests/client/test_client_update.py | 36 ++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/app/client/schemas.py b/app/client/schemas.py index a46e3a85..cae4c497 100644 --- a/app/client/schemas.py +++ b/app/client/schemas.py @@ -1,7 +1,7 @@ from pydantic import Field, HttpUrl, field_validator from app.schemas import CustomModel, ClientResponse, PaginationResponse -from app import constants +from app import constants, utils class ClientFullResponse(ClientResponse): @@ -18,11 +18,13 @@ class ClientCreate(CustomModel): name: str = Field( examples=["ThirdPartyWatchlistImporter"], description="Client name", + min_length=3, max_length=constants.MAX_CLIENT_NAME_LENGTH, ) description: str = Field( examples=["Client that imports watchlist from third-party services"], description="Short clear description of the client", + min_length=3, max_length=constants.MAX_CLIENT_DESCRIPTION_LENGTH, ) endpoint: HttpUrl = Field( @@ -33,6 +35,13 @@ class ClientCreate(CustomModel): max_length=constants.MAX_CLIENT_ENDPOINT_LENGTH, ) + @field_validator("name", "description", mode="before") + def validate_name(cls, v: str) -> str: + if not isinstance(v, str): + return v + + return utils.remove_bad_characters(v).strip() + @field_validator("endpoint") def validate_endpoint(cls, v: HttpUrl) -> HttpUrl: if len(str(v)) > constants.MAX_CLIENT_ENDPOINT_LENGTH: @@ -48,11 +57,13 @@ class ClientUpdate(CustomModel): None, description="Client name", max_length=constants.MAX_CLIENT_NAME_LENGTH, + min_length=3, ) description: str | None = Field( None, description="Short clear description of the client", max_length=constants.MAX_CLIENT_DESCRIPTION_LENGTH, + min_length=3, ) endpoint: HttpUrl | None = Field( None, @@ -64,6 +75,13 @@ class ClientUpdate(CustomModel): description="Create new client secret and revoke previous", ) + @field_validator("name", "description", mode="before") + def validate_name(cls, v: str) -> str: + if not isinstance(v, str): + return v + + return utils.remove_bad_characters(v).strip() + @field_validator("endpoint") def validate_endpoint(cls, v: HttpUrl | None) -> HttpUrl | None: if len(str(v)) > constants.MAX_CLIENT_ENDPOINT_LENGTH: diff --git a/tests/client/test_client_create.py b/tests/client/test_client_create.py index ee9bb7fb..94c0dd3a 100644 --- a/tests/client/test_client_create.py +++ b/tests/client/test_client_create.py @@ -99,3 +99,36 @@ async def test_too_long_fields(client, test_token): assert response.json()["message"] == error_message_format.format( field="endpoint" ) + + +async def test_too_short_fields(client, test_token): + error_message_format = "Invalid field {field} in request body" + error_code = "system:validation_error" + + response = await request_client_create( + client, + test_token, + "a", + "description", + "http://localhost/", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="name" + ) + + response = await request_client_create( + client, + test_token, + "name", + "a", + "http://localhost/", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="description" + ) diff --git a/tests/client/test_client_update.py b/tests/client/test_client_update.py index 41768891..5c33de1d 100644 --- a/tests/client/test_client_update.py +++ b/tests/client/test_client_update.py @@ -109,3 +109,39 @@ async def test_too_long_fields(client, test_token): assert response.json()["message"] == error_message_format.format( field="endpoint" ) + + +async def test_too_short_fields(client, test_token): + error_message_format = "Invalid field {field} in request body" + error_code = "system:validation_error" + + name = "test-client" + description = "test client description" + endpoint = "http://localhost/" + + response = await request_client_create( + client, test_token, name, description, endpoint + ) + assert response.status_code == status.HTTP_200_OK + + client_reference = response.json()["reference"] + + response = await request_client_update( + client, test_token, client_reference, name="a" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="name" + ) + + response = await request_client_update( + client, test_token, client_reference, description="a" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json()["code"] == error_code + assert response.json()["message"] == error_message_format.format( + field="description" + ) From b3733e79afaf4a68bf39e0f940b892b4b1592d07 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Thu, 29 Aug 2024 00:24:17 +0300 Subject: [PATCH 35/44] Rename 'banned' role to 'restricted' to avoid confusion with the banned bool flag --- app/constants.py | 4 ++-- tests/comments/test_comments_write.py | 15 +++++++++++++++ tests/conftest.py | 4 ++-- tests/edit/test_edit_close.py | 2 +- tests/edit/test_edit_create.py | 2 +- tests/upload/test_upload_avatar.py | 2 +- tests/upload/test_upload_cover.py | 2 +- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/constants.py b/app/constants.py index 708507f4..396a76a7 100644 --- a/app/constants.py +++ b/app/constants.py @@ -320,7 +320,7 @@ ROLE_USER = "user" ROLE_MODERATOR = "moderator" ROLE_ADMIN = "admin" -ROLE_BANNED = "banned" +ROLE_RESTRICTED = "restricted" ROLE_NOT_ACTIVATED = "not_activated" ROLE_DELETED = "deleted" @@ -394,7 +394,7 @@ PERMISSION_UPLOAD_AVATAR, PERMISSION_UPLOAD_COVER, ], - ROLE_BANNED: [], + ROLE_RESTRICTED: [], ROLE_DELETED: [], } diff --git a/tests/comments/test_comments_write.py b/tests/comments/test_comments_write.py index 443e8b5c..b153026d 100644 --- a/tests/comments/test_comments_write.py +++ b/tests/comments/test_comments_write.py @@ -115,3 +115,18 @@ async def test_comments_write_empty_markdown( assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["code"] == "system:validation_error" + + +async def test_comments_write_bad_permission( + client, + aggregator_anime, + aggregator_anime_info, + create_dummy_user_restricted, + get_dummy_token, +): + response = await request_comments_write( + client, get_dummy_token, "edit", "17", "First comment, yay!" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["code"] == "permission:denied" diff --git a/tests/conftest.py b/tests/conftest.py index ffa17c21..7d9a64ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,12 +139,12 @@ async def create_dummy_user(test_session): @pytest.fixture -async def create_dummy_user_banned(test_session): +async def create_dummy_user_restricted(test_session): return await helpers.create_user( test_session, username="dummy", email="dummy@mail.com", - role=constants.ROLE_BANNED, + role=constants.ROLE_RESTRICTED, ) diff --git a/tests/edit/test_edit_close.py b/tests/edit/test_edit_close.py index ae32e6d9..7f7929f7 100644 --- a/tests/edit/test_edit_close.py +++ b/tests/edit/test_edit_close.py @@ -99,7 +99,7 @@ async def test_edit_close_bad_permission( aggregator_anime, aggregator_anime_info, create_test_user, - create_dummy_user_banned, + create_dummy_user_restricted, get_test_token, get_dummy_token, test_session, diff --git a/tests/edit/test_edit_create.py b/tests/edit/test_edit_create.py index 2935ddc8..3f2d210a 100644 --- a/tests/edit/test_edit_create.py +++ b/tests/edit/test_edit_create.py @@ -149,7 +149,7 @@ async def test_edit_create_bad_permission( client, aggregator_anime, aggregator_anime_info, - create_dummy_user_banned, + create_dummy_user_restricted, get_dummy_token, test_session, ): diff --git a/tests/upload/test_upload_avatar.py b/tests/upload/test_upload_avatar.py index 8af48880..19d05f3a 100644 --- a/tests/upload/test_upload_avatar.py +++ b/tests/upload/test_upload_avatar.py @@ -31,7 +31,7 @@ async def test_upload_avatar( async def test_upload_avatar_bad_permission( client, - create_dummy_user_banned, + create_dummy_user_restricted, get_dummy_token, mock_s3_upload_file, ): diff --git a/tests/upload/test_upload_cover.py b/tests/upload/test_upload_cover.py index 141f398b..d4edcdde 100644 --- a/tests/upload/test_upload_cover.py +++ b/tests/upload/test_upload_cover.py @@ -34,7 +34,7 @@ async def test_upload_cover( async def test_upload_cover_bad_permission( client, - create_dummy_user_banned, + create_dummy_user_restricted, get_dummy_token, mock_s3_upload_file, ): From 90f36f5f6fda1ee7a14ce725ecab3dab0770e771 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Thu, 29 Aug 2024 17:59:03 +0300 Subject: [PATCH 36/44] update deps to solve security issue --- poetry.lock | 1275 +++++++++++++++++++++++++----------------------- pyproject.toml | 4 +- 2 files changed, 668 insertions(+), 611 deletions(-) diff --git a/poetry.lock b/poetry.lock index f2c6dc92..72d29fdf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,13 +42,13 @@ boto3 = ["boto3 (>=1.34.41,<1.34.70)"] [[package]] name = "aiofiles" -version = "23.2.1" +version = "24.1.0" description = "File support for asyncio." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, - {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, ] [[package]] @@ -173,13 +173,13 @@ frozenlist = ">=1.1.0" [[package]] name = "alembic" -version = "1.13.1" +version = "1.13.2" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.8" files = [ - {file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"}, - {file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"}, + {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"}, + {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"}, ] [package.dependencies] @@ -203,13 +203,13 @@ files = [ [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -318,57 +318,57 @@ test = ["flake8 (>=5.0,<6.0)", "uvloop (>=0.15.3)"] [[package]] name = "attrs" -version = "23.2.0" +version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "bcrypt" -version = "4.1.3" +version = "4.2.0" description = "Modern password hashing for your software and your servers" optional = false python-versions = ">=3.7" files = [ - {file = "bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:48429c83292b57bf4af6ab75809f8f4daf52aa5d480632e53707805cc1ce9b74"}, - {file = "bcrypt-4.1.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a8bea4c152b91fd8319fef4c6a790da5c07840421c2b785084989bf8bbb7455"}, - {file = "bcrypt-4.1.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d3b317050a9a711a5c7214bf04e28333cf528e0ed0ec9a4e55ba628d0f07c1a"}, - {file = "bcrypt-4.1.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:094fd31e08c2b102a14880ee5b3d09913ecf334cd604af27e1013c76831f7b05"}, - {file = "bcrypt-4.1.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4fb253d65da30d9269e0a6f4b0de32bd657a0208a6f4e43d3e645774fb5457f3"}, - {file = "bcrypt-4.1.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:193bb49eeeb9c1e2db9ba65d09dc6384edd5608d9d672b4125e9320af9153a15"}, - {file = "bcrypt-4.1.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8cbb119267068c2581ae38790e0d1fbae65d0725247a930fc9900c285d95725d"}, - {file = "bcrypt-4.1.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6cac78a8d42f9d120b3987f82252bdbeb7e6e900a5e1ba37f6be6fe4e3848286"}, - {file = "bcrypt-4.1.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01746eb2c4299dd0ae1670234bf77704f581dd72cc180f444bfe74eb80495b64"}, - {file = "bcrypt-4.1.3-cp37-abi3-win32.whl", hash = "sha256:037c5bf7c196a63dcce75545c8874610c600809d5d82c305dd327cd4969995bf"}, - {file = "bcrypt-4.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:8a893d192dfb7c8e883c4576813bf18bb9d59e2cfd88b68b725990f033f1b978"}, - {file = "bcrypt-4.1.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d4cf6ef1525f79255ef048b3489602868c47aea61f375377f0d00514fe4a78c"}, - {file = "bcrypt-4.1.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5698ce5292a4e4b9e5861f7e53b1d89242ad39d54c3da451a93cac17b61921a"}, - {file = "bcrypt-4.1.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec3c2e1ca3e5c4b9edb94290b356d082b721f3f50758bce7cce11d8a7c89ce84"}, - {file = "bcrypt-4.1.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3a5be252fef513363fe281bafc596c31b552cf81d04c5085bc5dac29670faa08"}, - {file = "bcrypt-4.1.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5f7cd3399fbc4ec290378b541b0cf3d4398e4737a65d0f938c7c0f9d5e686611"}, - {file = "bcrypt-4.1.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:c4c8d9b3e97209dd7111bf726e79f638ad9224b4691d1c7cfefa571a09b1b2d6"}, - {file = "bcrypt-4.1.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:31adb9cbb8737a581a843e13df22ffb7c84638342de3708a98d5c986770f2834"}, - {file = "bcrypt-4.1.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:551b320396e1d05e49cc18dd77d970accd52b322441628aca04801bbd1d52a73"}, - {file = "bcrypt-4.1.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6717543d2c110a155e6821ce5670c1f512f602eabb77dba95717ca76af79867d"}, - {file = "bcrypt-4.1.3-cp39-abi3-win32.whl", hash = "sha256:6004f5229b50f8493c49232b8e75726b568535fd300e5039e255d919fc3a07f2"}, - {file = "bcrypt-4.1.3-cp39-abi3-win_amd64.whl", hash = "sha256:2505b54afb074627111b5a8dc9b6ae69d0f01fea65c2fcaea403448c503d3991"}, - {file = "bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:cb9c707c10bddaf9e5ba7cdb769f3e889e60b7d4fea22834b261f51ca2b89fed"}, - {file = "bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9f8ea645eb94fb6e7bea0cf4ba121c07a3a182ac52876493870033141aa687bc"}, - {file = "bcrypt-4.1.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f44a97780677e7ac0ca393bd7982b19dbbd8d7228c1afe10b128fd9550eef5f1"}, - {file = "bcrypt-4.1.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d84702adb8f2798d813b17d8187d27076cca3cd52fe3686bb07a9083930ce650"}, - {file = "bcrypt-4.1.3.tar.gz", hash = "sha256:2ee15dd749f5952fe3f0430d0ff6b74082e159c50332a1413d51b5689cf06623"}, + {file = "bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7"}, + {file = "bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458"}, + {file = "bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5"}, + {file = "bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8"}, + {file = "bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34"}, + {file = "bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184"}, + {file = "bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221"}, ] [package.extras] @@ -584,13 +584,13 @@ wmi = ["wmi (>=1.5.1)"] [[package]] name = "dynaconf" -version = "3.2.5" +version = "3.2.6" description = "The dynamic configurator for your Python Project" optional = false python-versions = ">=3.8" files = [ - {file = "dynaconf-3.2.5-py2.py3-none-any.whl", hash = "sha256:12202fc26546851c05d4194c80bee00197e7c2febcb026e502b0863be9cbbdd8"}, - {file = "dynaconf-3.2.5.tar.gz", hash = "sha256:42c8d936b32332c4b84e4d4df6dd1626b6ef59c5a94eb60c10cd3c59d6b882f2"}, + {file = "dynaconf-3.2.6-py2.py3-none-any.whl", hash = "sha256:3911c740d717df4576ed55f616c7cbad6e06bc8ef23ffca444b6e2a12fb1c34c"}, + {file = "dynaconf-3.2.6.tar.gz", hash = "sha256:74cc1897396380bb957730eb341cc0976ee9c38bbcb53d3307c50caed0aedfb8"}, ] [package.extras] @@ -647,20 +647,21 @@ all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)" [[package]] name = "fastapi-cli" -version = "0.0.4" +version = "0.0.5" description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_cli-0.0.4-py3-none-any.whl", hash = "sha256:a2552f3a7ae64058cdbb530be6fa6dbfc975dc165e4fa66d224c3d396e25e809"}, - {file = "fastapi_cli-0.0.4.tar.gz", hash = "sha256:e2e9ffaffc1f7767f488d6da34b6f5a377751c996f397902eb6abb99a67bde32"}, + {file = "fastapi_cli-0.0.5-py3-none-any.whl", hash = "sha256:e94d847524648c748a5350673546bbf9bcaeb086b33c24f2e82e021436866a46"}, + {file = "fastapi_cli-0.0.5.tar.gz", hash = "sha256:d30e1239c6f46fcb95e606f02cdda59a1e2fa778a54b64686b3ff27f6211ff9f"}, ] [package.dependencies] typer = ">=0.12.3" +uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] -standard = ["fastapi", "uvicorn[standard] (>=0.15.0)"] +standard = ["uvicorn[standard] (>=0.15.0)"] [[package]] name = "frozenlist" @@ -922,13 +923,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] @@ -943,16 +944,17 @@ brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.7" +version = "3.8" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, + {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, + {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, ] [[package]] @@ -1261,68 +1263,79 @@ files = [ [[package]] name = "orjson" -version = "3.10.3" +version = "3.10.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, - {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, - {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, - {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, - {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, - {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, - {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, - {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, - {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, - {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, - {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, - {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, - {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, - {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, - {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, - {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, ] [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -1382,27 +1395,28 @@ starlette = ">=0.30.0,<1.0.0" [[package]] name = "psutil" -version = "5.9.8" +version = "6.0.0" description = "Cross-platform lib for process and system monitoring in Python." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -files = [ - {file = "psutil-5.9.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8"}, - {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73"}, - {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7"}, - {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36"}, - {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d"}, - {file = "psutil-5.9.8-cp27-none-win32.whl", hash = "sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e"}, - {file = "psutil-5.9.8-cp27-none-win_amd64.whl", hash = "sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631"}, - {file = "psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81"}, - {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421"}, - {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4"}, - {file = "psutil-5.9.8-cp36-cp36m-win32.whl", hash = "sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee"}, - {file = "psutil-5.9.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2"}, - {file = "psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0"}, - {file = "psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf"}, - {file = "psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8"}, - {file = "psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c"}, +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, ] [package.extras] @@ -1410,143 +1424,156 @@ test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] [[package]] name = "psycopg" -version = "3.1.19" +version = "3.2.1" description = "PostgreSQL database adapter for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "psycopg-3.1.19-py3-none-any.whl", hash = "sha256:dca5e5521c859f6606686432ae1c94e8766d29cc91f2ee595378c510cc5b0731"}, - {file = "psycopg-3.1.19.tar.gz", hash = "sha256:92d7b78ad82426cdcf1a0440678209faa890c6e1721361c2f8901f0dccd62961"}, + {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"}, + {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"}, ] [package.dependencies] -typing-extensions = ">=4.1" +typing-extensions = ">=4.4" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.1.19)"] -c = ["psycopg-c (==3.1.19)"] -dev = ["black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.4.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] +binary = ["psycopg-binary (==3.2.1)"] +c = ["psycopg-c (==3.2.1)"] +dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] -test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] name = "puremagic" -version = "1.23" +version = "1.27" description = "Pure python implementation of magic file detection" optional = false python-versions = "*" files = [ - {file = "puremagic-1.23-py3-none-any.whl", hash = "sha256:f67ba60a1820ae154016a0fe6a9fdfa11961f14602939b37b0165d59bd2c26ad"}, - {file = "puremagic-1.23.tar.gz", hash = "sha256:e0bb7dc814b9d606225b57d4d49175d27c24fb745de1a7b3506067f2be54438f"}, + {file = "puremagic-1.27-py3-none-any.whl", hash = "sha256:b5519ad89e9b7c96a5fd9947d9a907e44f97cc30eae6dcf746d90a58e3681936"}, + {file = "puremagic-1.27.tar.gz", hash = "sha256:7cb316f40912f56f34149f8ebdd77a91d099212d2ed936feb2feacfc7cbce2c1"}, ] [[package]] name = "pydantic" -version = "2.7.1" +version = "2.8.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, - {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.2" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.2" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, - {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, - {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, - {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, - {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, - {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, - {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, - {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, - {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, - {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, - {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, - {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, - {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, - {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [package.dependencies] @@ -1568,95 +1595,96 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstrument" -version = "4.6.2" +version = "4.7.2" description = "Call stack profiler for Python. Shows you why your code is slow!" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7a1b1cd768ea7ea9ab6f5490f7e74431321bcc463e9441dbc2f769617252d9e2"}, - {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a386b9d09d167451fb2111eaf86aabf6e094fed42c15f62ec51d6980bce7d96"}, - {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23c3e3ca8553b9aac09bd978c73d21b9032c707ac6d803bae6a20ecc048df4a8"}, - {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f329f5534ca069420246f5ce57270d975229bcb92a3a3fd6b2ca086527d9764"}, - {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dcdcc7ba224a0c5edfbd00b0f530f5aed2b26da5aaa2f9af5519d4aa8c7e41"}, - {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73db0c2c99119c65b075feee76e903b4ed82e59440fe8b5724acf5c7cb24721f"}, - {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:da58f265326f3cf3975366ccb8b39014f1e69ff8327958a089858d71c633d654"}, - {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:feebcf860f955401df30d029ec8de7a0c5515d24ea809736430fd1219686fe14"}, - {file = "pyinstrument-4.6.2-cp310-cp310-win32.whl", hash = "sha256:b2b66ff0b16c8ecf1ec22de001cfff46872b2c163c62429055105564eef50b2e"}, - {file = "pyinstrument-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:8d104b7a7899d5fa4c5bf1ceb0c1a070615a72c5dc17bc321b612467ad5c5d88"}, - {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:62f6014d2b928b181a52483e7c7b82f2c27e22c577417d1681153e5518f03317"}, - {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5c8d763c5df55131670ba2a01a8aebd0d490a789904a55eb6a8b8d497f110"}, - {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ed4e8c6c84e0e6429ba7008a66e435ede2d8cb027794c20923c55669d9c5633"}, - {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c0f0e1d8f8c70faa90ff57f78ac0dda774b52ea0bfb2d9f0f41ce6f3e7c869e"}, - {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3c44cb037ad0d6e9d9a48c14d856254ada641fbd0ae9de40da045fc2226a2a"}, - {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:be9901f17ac2f527c352f2fdca3d717c1d7f2ce8a70bad5a490fc8cc5d2a6007"}, - {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a9791bf8916c1cf439c202fded32de93354b0f57328f303d71950b0027c7811"}, - {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d6162615e783c59e36f2d7caf903a7e3ecb6b32d4a4ae8907f2760b2ef395bf6"}, - {file = "pyinstrument-4.6.2-cp311-cp311-win32.whl", hash = "sha256:28af084aa84bbfd3620ebe71d5f9a0deca4451267f363738ca824f733de55056"}, - {file = "pyinstrument-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:dd6007d3c2e318e09e582435dd8d111cccf30d342af66886b783208813caf3d7"}, - {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e3813c8ecfab9d7d855c5f0f71f11793cf1507f40401aa33575c7fd613577c23"}, - {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6c761372945e60fc1396b7a49f30592e8474e70a558f1a87346d27c8c4ce50f7"}, - {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fba3244e94c117bf4d9b30b8852bbdcd510e7329fdd5c7c8b3799e00a9215a8"}, - {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:803ac64e526473d64283f504df3b0d5c2c203ea9603cab428641538ffdc753a7"}, - {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2e554b1bb0df78f5ce8a92df75b664912ca93aa94208386102af454ec31b647"}, - {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7c671057fad22ee3ded897a6a361204ea2538e44c1233cad0e8e30f6d27f33db"}, - {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d02f31fa13a9e8dc702a113878419deba859563a32474c9f68e04619d43d6f01"}, - {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b55983a884f083f93f0fc6d12ff8df0acd1e2fb0580d2f4c7bfe6def33a84b58"}, - {file = "pyinstrument-4.6.2-cp312-cp312-win32.whl", hash = "sha256:fdc0a53b27e5d8e47147489c7dab596ddd1756b1e053217ef5bc6718567099ff"}, - {file = "pyinstrument-4.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:dd5c53a0159126b5ce7cbc4994433c9c671e057c85297ff32645166a06ad2c50"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b082df0bbf71251a7f4880a12ed28421dba84ea7110bb376e0533067a4eaff40"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90350533396071cb2543affe01e40bf534c35cb0d4b8fa9fdb0f052f9ca2cfe3"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67268bb0d579330cff40fd1c90b8510363ca1a0e7204225840614068658dab77"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20e15b4e1d29ba0b7fc81aac50351e0dc0d7e911e93771ebc3f408e864a2c93b"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2e625fc6ffcd4fd420493edd8276179c3f784df207bef4c2192725c1b310534c"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:113d2fc534c9ca7b6b5661d6ada05515bf318f6eb34e8d05860fe49eb7cfe17e"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3098cd72b71a322a72dafeb4ba5c566465e193d2030adad4c09566bd2f89bf4f"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:08fdc7f88c989316fa47805234c37a40fafe7b614afd8ae863f0afa9d1707b37"}, - {file = "pyinstrument-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5ebeba952c0056dcc9b9355328c78c4b5c2a33b4b4276a9157a3ab589f3d1bac"}, - {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:34e59e91c88ec9ad5630c0964eca823949005e97736bfa838beb4789e94912a2"}, - {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cd0320c39e99e3c0a3129d1ed010ac41e5a7eb96fb79900d270080a97962e995"}, - {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46992e855d630575ec635eeca0068a8ddf423d4fd32ea0875a94e9f8688f0b95"}, - {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e474c56da636253dfdca7cd1998b240d6b39f7ed34777362db69224fcf053b1"}, - {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b559322f30509ad8f082561792352d0805b3edfa508e492a36041fdc009259"}, - {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:06a8578b2943eb1dbbf281e1e59e44246acfefd79e1b06d4950f01b693de12af"}, - {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7bd3da31c46f1c1cb7ae89031725f6a1d1015c2041d9c753fe23980f5f9fd86c"}, - {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e63f4916001aa9c625976a50779282e0a5b5e9b17c52a50ef4c651e468ed5b88"}, - {file = "pyinstrument-4.6.2-cp38-cp38-win32.whl", hash = "sha256:32ec8db6896b94af790a530e1e0edad4d0f941a0ab8dd9073e5993e7ea46af7d"}, - {file = "pyinstrument-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:a59fc4f7db738a094823afe6422509fa5816a7bf74e768ce5a7a2ddd91af40ac"}, - {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3a165e0d2deb212d4cf439383982a831682009e1b08733c568cac88c89784e62"}, - {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ba858b3d6f6e5597c641edcc0e7e464f85aba86d71bc3b3592cb89897bf43f6"}, - {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd8e547cf3df5f0ec6e4dffbe2e857f6b28eda51b71c3c0b5a2fc0646527835"}, - {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de2c1714a37a820033b19cf134ead43299a02662f1379140974a9ab733c5f3a"}, - {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01fc45dedceec3df81668d702bca6d400d956c8b8494abc206638c167c78dfd9"}, - {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5b6e161ef268d43ee6bbfae7fd2cdd0a52c099ddd21001c126ca1805dc906539"}, - {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6ba8e368d0421f15ba6366dfd60ec131c1b46505d021477e0f865d26cf35a605"}, - {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edca46f04a573ac2fb11a84b937844e6a109f38f80f4b422222fb5be8ecad8cb"}, - {file = "pyinstrument-4.6.2-cp39-cp39-win32.whl", hash = "sha256:baf375953b02fe94d00e716f060e60211ede73f49512b96687335f7071adb153"}, - {file = "pyinstrument-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:af1a953bce9fd530040895d01ff3de485e25e1576dccb014f76ba9131376fcad"}, - {file = "pyinstrument-4.6.2.tar.gz", hash = "sha256:0002ee517ed8502bbda6eb2bb1ba8f95a55492fcdf03811ba13d4806e50dd7f6"}, + {file = "pyinstrument-4.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a316a929a29e4fb1c0a122c503e9442580daf485be20bd713fcc60b98bb48509"}, + {file = "pyinstrument-4.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50c56106e4b3a92dbf1c9d36b307cf67c5b667ae35195d41cf1ded7afc26a01a"}, + {file = "pyinstrument-4.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528b6c8267ebe114d04c8e189f80907b6af9e7a7d6a6597f2833ddcfedbde66f"}, + {file = "pyinstrument-4.7.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f856e7edd39f73d7a68180f03133fc7c6331d3849b8db4d480028c36433ab46"}, + {file = "pyinstrument-4.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6f28831c8386bf820d014282c2e8748049819f61eacb210029fd7e08f45df37"}, + {file = "pyinstrument-4.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78735eb3822746fd12f37ab9a84df35b613b9824b0f8819529c41d9aa09c26c6"}, + {file = "pyinstrument-4.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:03dfecfcb7d699b7d8f9d36fb6a11c476233a71eeea78b466c69bca300029603"}, + {file = "pyinstrument-4.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b9bd25ba7ef070f538c5e3c6b4a991ce6837a6a2c49c4feba10cb8f5f60182f4"}, + {file = "pyinstrument-4.7.2-cp310-cp310-win32.whl", hash = "sha256:fee18be41331fe0a016c315ea36da4ce965d1fdba051edad16823771e4a0c03d"}, + {file = "pyinstrument-4.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:1a73eb6c07b8c52b976b8a0029dc3dfee83c487f640e97c4b84fcf15cda91caa"}, + {file = "pyinstrument-4.7.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19c51585e93482cdef7d627f8210f6272d357bf298b6ebd9761bdc2cf50f1b30"}, + {file = "pyinstrument-4.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:201eb2460f815efda749a659bf4315d27e964a522c83e04173a052ce89de06d4"}, + {file = "pyinstrument-4.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:518f7fbb0f05377391b72e72e8d6942d6413a0d36df0e77a4625b6cbd4ce84fc"}, + {file = "pyinstrument-4.7.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dc1ae87dc6ba8e7fad7ef70996a94a9fd63d5c5c8daa86eb9bc3b2e87f6733a"}, + {file = "pyinstrument-4.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a340ef24718228c57f49750dcac68db1f7d1c9c4d3ce004d3c154f464bacb3d1"}, + {file = "pyinstrument-4.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:85e441fcb06d087ae836551dee6a9a9bacf12b0a0c9a6e956376e7c779190474"}, + {file = "pyinstrument-4.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fa1f4c0fd2cb118fea3e6d8ba5fcaa9b51c92344841935a7c2c4a8964647273e"}, + {file = "pyinstrument-4.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c8a500c7d077bba643fb3c12fc810f7e1f15fbf37d418cb751f1ee98e275ce6"}, + {file = "pyinstrument-4.7.2-cp311-cp311-win32.whl", hash = "sha256:aa8818f465ed4a6fbe6a2dd59589cc8087fd7ea5faebc32b45c1cb3eb27cfd36"}, + {file = "pyinstrument-4.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:ef64820320ab78f0ce0992104cb7d343ffbb199c015f163fbdc2c66cb3215347"}, + {file = "pyinstrument-4.7.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:10e39476dad9751f2e88a77e50eb5466d16701d9b4efc507a3addce24d1ef43e"}, + {file = "pyinstrument-4.7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7077831b06d9fec49a92100c8dfd237e1a4c363183746d5a9d44c0174c587547"}, + {file = "pyinstrument-4.7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2100cf016ee71be21d209d3003ce0dfdac8d74e5e45b9f9ae0a3cfceef7360a"}, + {file = "pyinstrument-4.7.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b00caeff2a7971752a428f9690a337a97ebbdbf14c0f05280b0a4176efd321c"}, + {file = "pyinstrument-4.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35dad76e54f0b94f4407579740d91d413ddbc471b465da3782ffa85a87180cbd"}, + {file = "pyinstrument-4.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c95ff1e05661457d3f53985a23579cec9fd23639af271fd238ddd545562d4"}, + {file = "pyinstrument-4.7.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:685e998538ba2145fbfe4428534f1cabb5b5719cd5454fbc88c3ab043f2267cb"}, + {file = "pyinstrument-4.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f43db19d1bb923b8b4b50f1d95994151cb04e848acd4740238e3805e87825c3"}, + {file = "pyinstrument-4.7.2-cp312-cp312-win32.whl", hash = "sha256:ef63b4157bf245a2b9543fa71cec71116a4e19c2a6a6ad96623d7b85eaa32119"}, + {file = "pyinstrument-4.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:140203d90e89a06dad86b07cb8d9ab1d763ddc1332502839daac19ff6360ae84"}, + {file = "pyinstrument-4.7.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2df465b065435152473b7c4d0b80c05d3136769251fd7fe725cfcb6eb87340fa"}, + {file = "pyinstrument-4.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:50023b396289a27ea5d2f60d78bdeec7e4ccc6051038dfd7f5638c15a314a5d5"}, + {file = "pyinstrument-4.7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:065451fed990ad050b0fdb4a2bd5f28426f5c5f4b94bd8dab9d144079e073761"}, + {file = "pyinstrument-4.7.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017788c61627f74c3ea503198628bccc46a87e421a282dfb055ff4500026748f"}, + {file = "pyinstrument-4.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8df61a879c7316f31791018c92f8cca92cd4dc5a624e629c3d969d77a3657fb"}, + {file = "pyinstrument-4.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:656910a5fbb7b99232f8f835815cdf69734b229434c26380c29a0ef09ec9874d"}, + {file = "pyinstrument-4.7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c2337616952ec3bd35dedb9a1ed396a3accfc0305bc54e22179e77fe63d50909"}, + {file = "pyinstrument-4.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ea4e4e7a8ea9a042fa2c4e0efc00d87b29e0af4a1a0b3dba907c3c63cdde4510"}, + {file = "pyinstrument-4.7.2-cp313-cp313-win32.whl", hash = "sha256:24012bc0e5a507189f5f1caa01b4589bb286348e929df6a898c926ffd6e5238a"}, + {file = "pyinstrument-4.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:3d8eaf57bc447b8e108b5d684b371c64232d9895b06a097d8dc2b92f3fdde561"}, + {file = "pyinstrument-4.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3cfa57f2a94a52fb3a3e66e910f753b6fd954e20c12407b8e80cc8e50733f771"}, + {file = "pyinstrument-4.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e9a5344b9e8a2748ba610502e7fa951d494591f8e5d8337100108f94bd73e30"}, + {file = "pyinstrument-4.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea9af525ce70e9d391b321015e3ef24cccf4df8c51c692492cade49e440b17c2"}, + {file = "pyinstrument-4.7.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b05d17721f99e7356e540a3be84bcad2c4f74144fe3a52d74a7da149f44d03d"}, + {file = "pyinstrument-4.7.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08581cb58877716d1839950ff0d474516ae743c575dff051babfb066e9c38405"}, + {file = "pyinstrument-4.7.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ad5b688488cab71b601e0aaefd726029f6ddc05525995424387fa88c6f1ce365"}, + {file = "pyinstrument-4.7.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5704125a8b8a0c0d98716d207e1882dfd90fe6c37bf6ac0055b671e43bb13b27"}, + {file = "pyinstrument-4.7.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d704ec91a774066c4a1d1f20046a00e1ef80f50ba9d024919e62365d84b55bdd"}, + {file = "pyinstrument-4.7.2-cp38-cp38-win32.whl", hash = "sha256:6969676c30ce6e078d453a232b074476e32506c5b30a44fc7847cbfe1cb8674f"}, + {file = "pyinstrument-4.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:b6504d60875443bee1f8c31517832b6c054ac0389b745a897484ea1e7edeec5c"}, + {file = "pyinstrument-4.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a483be96c025e0287125aad85be3a0bee8687f069e422fb29eab49dd3d53a53d"}, + {file = "pyinstrument-4.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ac0caa72765e8f068ad92e9c24c45cf0f4e31c902f403e264199a5667a2e034"}, + {file = "pyinstrument-4.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8279d811e86afab5bc31e4aa4f3310b8c5b83682d52cfabee990a9f6a67cd551"}, + {file = "pyinstrument-4.7.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d5b24a14d0fc74e6d9e471088936593cd9f55bb1bfd502e7801913e9d14308e"}, + {file = "pyinstrument-4.7.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf90a6b86313ca01b85909e93fb5aaa7a26422a0c6347a07e249b381e77219e"}, + {file = "pyinstrument-4.7.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e9a96dcbdb272a389fbecb28a5916fab09d2d1a515c997e7bed08c68d5835fbe"}, + {file = "pyinstrument-4.7.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:029855d9bd6bdf66b1948d697261446f049af0b576f0f4b9c2bb5a741a15fefc"}, + {file = "pyinstrument-4.7.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b0331ff6984642a0f66be9e4a66331f1a401948b8bf89ed60990f229fbd10432"}, + {file = "pyinstrument-4.7.2-cp39-cp39-win32.whl", hash = "sha256:4db19ffbb0047e00c6d444ac0e648505982399361aa609b3af9229a971dca79e"}, + {file = "pyinstrument-4.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:b174abcc7438f8aa20a190fcafd8eba099af54af445ce5ea1b28b25750f59652"}, + {file = "pyinstrument-4.7.2.tar.gz", hash = "sha256:8c4e4792e7bc2de6ad757dcb05bb6739b5aed64f834602e8121f611e3278e0d1"}, ] [package.extras] bin = ["click", "nox"] -docs = ["furo (==2021.6.18b36)", "myst-parser (==0.15.1)", "sphinx (==4.2.0)", "sphinxcontrib-programoutput (==0.17)"] -examples = ["django", "numpy"] -test = ["flaky", "greenlet (>=3.0.0a1)", "ipython", "pytest", "pytest-asyncio (==0.12.0)", "sphinx-autobuild (==2021.3.14)", "trio"] +docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"] +examples = ["django", "litestar", "numpy"] +test = ["cffi (>=v1.17.0rc1)", "flaky", "greenlet (>=3.0.0a1)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"] types = ["typing-extensions"] [[package]] name = "pyjwt" -version = "2.8.0" +version = "2.9.0" description = "JSON Web Token implementation in Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, - {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, ] [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] @@ -1770,73 +1798,75 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "requests" -version = "2.32.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, - {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1851,13 +1881,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.8.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, + {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, ] [package.dependencies] @@ -1869,13 +1899,13 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "s3transfer" -version = "0.10.1" +version = "0.10.2" description = "An Amazon S3 Transfer Manager" optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" files = [ - {file = "s3transfer-0.10.1-py3-none-any.whl", hash = "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"}, - {file = "s3transfer-0.10.1.tar.gz", hash = "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19"}, + {file = "s3transfer-0.10.2-py3-none-any.whl", hash = "sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69"}, + {file = "s3transfer-0.10.2.tar.gz", hash = "sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6"}, ] [package.dependencies] @@ -1886,18 +1916,23 @@ crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] [[package]] name = "setuptools" -version = "70.0.0" +version = "74.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, - {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, + {file = "setuptools-74.0.0-py3-none-any.whl", hash = "sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f"}, + {file = "setuptools-74.0.0.tar.gz", hash = "sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "shellingham" @@ -1934,64 +1969,64 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.30" +version = "2.0.32" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ - {file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b48154678e76445c7ded1896715ce05319f74b1e73cf82d4f8b59b46e9c0ddc"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2753743c2afd061bb95a61a51bbb6a1a11ac1c44292fad898f10c9839a7f75b2"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7bfc726d167f425d4c16269a9a10fe8630ff6d14b683d588044dcef2d0f6be7"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4f61ada6979223013d9ab83a3ed003ded6959eae37d0d685db2c147e9143797"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a365eda439b7a00732638f11072907c1bc8e351c7665e7e5da91b169af794af"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bba002a9447b291548e8d66fd8c96a6a7ed4f2def0bb155f4f0a1309fd2735d5"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-win32.whl", hash = "sha256:0138c5c16be3600923fa2169532205d18891b28afa817cb49b50e08f62198bb8"}, - {file = "SQLAlchemy-2.0.30-cp310-cp310-win_amd64.whl", hash = "sha256:99650e9f4cf3ad0d409fed3eec4f071fadd032e9a5edc7270cd646a26446feeb"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:955991a09f0992c68a499791a753523f50f71a6885531568404fa0f231832aa0"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f69e4c756ee2686767eb80f94c0125c8b0a0b87ede03eacc5c8ae3b54b99dc46"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c9db1ce00e59e8dd09d7bae852a9add716efdc070a3e2068377e6ff0d6fdaa"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1429a4b0f709f19ff3b0cf13675b2b9bfa8a7e79990003207a011c0db880a13"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:efedba7e13aa9a6c8407c48facfdfa108a5a4128e35f4c68f20c3407e4376aa9"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16863e2b132b761891d6c49f0a0f70030e0bcac4fd208117f6b7e053e68668d0"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-win32.whl", hash = "sha256:2ecabd9ccaa6e914e3dbb2aa46b76dede7eadc8cbf1b8083c94d936bcd5ffb49"}, - {file = "SQLAlchemy-2.0.30-cp311-cp311-win_amd64.whl", hash = "sha256:0b3f4c438e37d22b83e640f825ef0f37b95db9aa2d68203f2c9549375d0b2260"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a79d65395ac5e6b0c2890935bad892eabb911c4aa8e8015067ddb37eea3d56c"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a5baf9267b752390252889f0c802ea13b52dfee5e369527da229189b8bd592e"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb5a646930c5123f8461f6468901573f334c2c63c795b9af350063a736d0134"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:296230899df0b77dec4eb799bcea6fbe39a43707ce7bb166519c97b583cfcab3"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c62d401223f468eb4da32627bffc0c78ed516b03bb8a34a58be54d618b74d472"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3b69e934f0f2b677ec111b4d83f92dc1a3210a779f69bf905273192cf4ed433e"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-win32.whl", hash = "sha256:77d2edb1f54aff37e3318f611637171e8ec71472f1fdc7348b41dcb226f93d90"}, - {file = "SQLAlchemy-2.0.30-cp312-cp312-win_amd64.whl", hash = "sha256:b6c7ec2b1f4969fc19b65b7059ed00497e25f54069407a8701091beb69e591a5"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5a8e3b0a7e09e94be7510d1661339d6b52daf202ed2f5b1f9f48ea34ee6f2d57"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b60203c63e8f984df92035610c5fb76d941254cf5d19751faab7d33b21e5ddc0"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1dc3eabd8c0232ee8387fbe03e0a62220a6f089e278b1f0aaf5e2d6210741ad"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:40ad017c672c00b9b663fcfcd5f0864a0a97828e2ee7ab0c140dc84058d194cf"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e42203d8d20dc704604862977b1470a122e4892791fe3ed165f041e4bf447a1b"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-win32.whl", hash = "sha256:2a4f4da89c74435f2bc61878cd08f3646b699e7d2eba97144030d1be44e27584"}, - {file = "SQLAlchemy-2.0.30-cp37-cp37m-win_amd64.whl", hash = "sha256:b6bf767d14b77f6a18b6982cbbf29d71bede087edae495d11ab358280f304d8e"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc0c53579650a891f9b83fa3cecd4e00218e071d0ba00c4890f5be0c34887ed3"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:311710f9a2ee235f1403537b10c7687214bb1f2b9ebb52702c5aa4a77f0b3af7"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:408f8b0e2c04677e9c93f40eef3ab22f550fecb3011b187f66a096395ff3d9fd"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37a4b4fb0dd4d2669070fb05b8b8824afd0af57587393015baee1cf9890242d9"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a943d297126c9230719c27fcbbeab57ecd5d15b0bd6bfd26e91bfcfe64220621"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a089e218654e740a41388893e090d2e2c22c29028c9d1353feb38638820bbeb"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-win32.whl", hash = "sha256:fa561138a64f949f3e889eb9ab8c58e1504ab351d6cf55259dc4c248eaa19da6"}, - {file = "SQLAlchemy-2.0.30-cp38-cp38-win_amd64.whl", hash = "sha256:7d74336c65705b986d12a7e337ba27ab2b9d819993851b140efdf029248e818e"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae8c62fe2480dd61c532ccafdbce9b29dacc126fe8be0d9a927ca3e699b9491a"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2383146973a15435e4717f94c7509982770e3e54974c71f76500a0136f22810b"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8409de825f2c3b62ab15788635ccaec0c881c3f12a8af2b12ae4910a0a9aeef6"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0094c5dc698a5f78d3d1539853e8ecec02516b62b8223c970c86d44e7a80f6c7"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:edc16a50f5e1b7a06a2dcc1f2205b0b961074c123ed17ebda726f376a5ab0953"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f7703c2010355dd28f53deb644a05fc30f796bd8598b43f0ba678878780b6e4c"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-win32.whl", hash = "sha256:1f9a727312ff6ad5248a4367358e2cf7e625e98b1028b1d7ab7b806b7d757513"}, - {file = "SQLAlchemy-2.0.30-cp39-cp39-win_amd64.whl", hash = "sha256:a0ef36b28534f2a5771191be6edb44cc2673c7b2edf6deac6562400288664221"}, - {file = "SQLAlchemy-2.0.30-py3-none-any.whl", hash = "sha256:7108d569d3990c71e26a42f60474b4c02c8586c4681af5fd67e51a044fdea86a"}, - {file = "SQLAlchemy-2.0.30.tar.gz", hash = "sha256:2b1708916730f4830bc69d6f49d37f7698b5bd7530aca7f04f785f8849e95255"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c9045ecc2e4db59bfc97b20516dfdf8e41d910ac6fb667ebd3a79ea54084619"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1467940318e4a860afd546ef61fefb98a14d935cd6817ed07a228c7f7c62f389"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5954463675cb15db8d4b521f3566a017c8789222b8316b1e6934c811018ee08b"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167e7497035c303ae50651b351c28dc22a40bb98fbdb8468cdc971821b1ae533"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b27dfb676ac02529fb6e343b3a482303f16e6bc3a4d868b73935b8792edb52d0"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf2360a5e0f7bd75fa80431bf8ebcfb920c9f885e7956c7efde89031695cafb8"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win32.whl", hash = "sha256:306fe44e754a91cd9d600a6b070c1f2fadbb4a1a257b8781ccf33c7067fd3e4d"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win_amd64.whl", hash = "sha256:99db65e6f3ab42e06c318f15c98f59a436f1c78179e6a6f40f529c8cc7100b22"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21b053be28a8a414f2ddd401f1be8361e41032d2ef5884b2f31d31cb723e559f"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b178e875a7a25b5938b53b006598ee7645172fccafe1c291a706e93f48499ff5"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723a40ee2cc7ea653645bd4cf024326dea2076673fc9d3d33f20f6c81db83e1d"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295ff8689544f7ee7e819529633d058bd458c1fd7f7e3eebd0f9268ebc56c2a0"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49496b68cd190a147118af585173ee624114dfb2e0297558c460ad7495f9dfe2"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd9b73c5c15f0ec5ce18128b1fe9157ddd0044abc373e6ecd5ba376a7e5d961"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win32.whl", hash = "sha256:9365a3da32dabd3e69e06b972b1ffb0c89668994c7e8e75ce21d3e5e69ddef28"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win_amd64.whl", hash = "sha256:8bd63d051f4f313b102a2af1cbc8b80f061bf78f3d5bd0843ff70b5859e27924"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bab3db192a0c35e3c9d1560eb8332463e29e5507dbd822e29a0a3c48c0a8d92"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:19d98f4f58b13900d8dec4ed09dd09ef292208ee44cc9c2fe01c1f0a2fe440e9"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd33c61513cb1b7371fd40cf221256456d26a56284e7d19d1f0b9f1eb7dd7e8"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6ba0497c1d066dd004e0f02a92426ca2df20fac08728d03f67f6960271feec"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2b6be53e4fde0065524f1a0a7929b10e9280987b320716c1509478b712a7688c"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:916a798f62f410c0b80b63683c8061f5ebe237b0f4ad778739304253353bc1cb"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win32.whl", hash = "sha256:31983018b74908ebc6c996a16ad3690301a23befb643093fcfe85efd292e384d"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win_amd64.whl", hash = "sha256:4363ed245a6231f2e2957cccdda3c776265a75851f4753c60f3004b90e69bfeb"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8afd5b26570bf41c35c0121801479958b4446751a3971fb9a480c1afd85558e"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c750987fc876813f27b60d619b987b057eb4896b81117f73bb8d9918c14f1cad"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ada0102afff4890f651ed91120c1120065663506b760da4e7823913ebd3258be"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:78c03d0f8a5ab4f3034c0e8482cfcc415a3ec6193491cfa1c643ed707d476f16"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:3bd1cae7519283ff525e64645ebd7a3e0283f3c038f461ecc1c7b040a0c932a1"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win32.whl", hash = "sha256:01438ebcdc566d58c93af0171c74ec28efe6a29184b773e378a385e6215389da"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win_amd64.whl", hash = "sha256:4979dc80fbbc9d2ef569e71e0896990bc94df2b9fdbd878290bd129b65ab579c"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c742be912f57586ac43af38b3848f7688863a403dfb220193a882ea60e1ec3a"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:62e23d0ac103bcf1c5555b6c88c114089587bc64d048fef5bbdb58dfd26f96da"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:251f0d1108aab8ea7b9aadbd07fb47fb8e3a5838dde34aa95a3349876b5a1f1d"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef18a84e5116340e38eca3e7f9eeaaef62738891422e7c2a0b80feab165905f"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3eb6a97a1d39976f360b10ff208c73afb6a4de86dd2a6212ddf65c4a6a2347d5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0c1c9b673d21477cec17ab10bc4decb1322843ba35b481585facd88203754fc5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win32.whl", hash = "sha256:c41a2b9ca80ee555decc605bd3c4520cc6fef9abde8fd66b1cf65126a6922d65"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win_amd64.whl", hash = "sha256:8a37e4d265033c897892279e8adf505c8b6b4075f2b40d77afb31f7185cd6ecd"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fec964fba2ef46476312a03ec8c425956b05c20220a1a03703537824b5e8e1"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:328429aecaba2aee3d71e11f2477c14eec5990fb6d0e884107935f7fb6001632"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85a01b5599e790e76ac3fe3aa2f26e1feba56270023d6afd5550ed63c68552b3"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf04784797dcdf4c0aa952c8d234fa01974c4729db55c45732520ce12dd95b4"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4488120becf9b71b3ac718f4138269a6be99a42fe023ec457896ba4f80749525"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14e09e083a5796d513918a66f3d6aedbc131e39e80875afe81d98a03312889e6"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win32.whl", hash = "sha256:0d322cc9c9b2154ba7e82f7bf25ecc7c36fbe2d82e2933b3642fc095a52cfc78"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win_amd64.whl", hash = "sha256:7dd8583df2f98dea28b5cd53a1beac963f4f9d087888d75f22fcc93a07cf8d84"}, + {file = "SQLAlchemy-2.0.32-py3-none-any.whl", hash = "sha256:e567a8793a692451f706b363ccf3c45e056b67d90ead58c3bc9471af5d212202"}, + {file = "SQLAlchemy-2.0.32.tar.gz", hash = "sha256:c1b88cc8b02b6a5f0efb0345a03672d4c897dc7d92585176f88c67346f565ea8"}, ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} typing-extensions = ">=4.6.0" [package.extras] @@ -2066,13 +2101,13 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 [[package]] name = "typer" -version = "0.12.3" +version = "0.12.5" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, - {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, + {file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, + {file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, ] [package.dependencies] @@ -2083,13 +2118,13 @@ typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -2226,13 +2261,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.29.0" +version = "0.30.6" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, - {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, ] [package.dependencies] @@ -2251,42 +2286,42 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [[package]] name = "uvloop" -version = "0.19.0" +version = "0.20.0" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.0" files = [ - {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, - {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, - {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, - {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, - {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, - {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, - {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, - {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, - {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, - {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, - {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, - {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, - {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, - {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, - {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, - {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, - {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, - {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, - {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, - {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, - {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, - {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, - {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, - {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, - {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, - {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, - {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, - {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, - {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, - {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, - {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9ebafa0b96c62881d5cafa02d9da2e44c23f9f0cd829f3a32a6aff771449c996"}, + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:35968fc697b0527a06e134999eef859b4034b37aebca537daeb598b9d45a137b"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b16696f10e59d7580979b420eedf6650010a4a9c3bd8113f24a103dfdb770b10"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b04d96188d365151d1af41fa2d23257b674e7ead68cfd61c725a422764062ae"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:94707205efbe809dfa3a0d09c08bef1352f5d3d6612a506f10a319933757c006"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89e8d33bb88d7263f74dc57d69f0063e06b5a5ce50bb9a6b32f5fcbe655f9e73"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0e94b221295b5e69de57a1bd4aeb0b3a29f61be6e1b478bb8a69a73377db7ba"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fee6044b64c965c425b65a4e17719953b96e065c5b7e09b599ff332bb2744bdf"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:265a99a2ff41a0fd56c19c3838b29bf54d1d177964c300dad388b27e84fd7847"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10c2956efcecb981bf9cfb8184d27d5d64b9033f917115a960b83f11bfa0d6b"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e7d61fe8e8d9335fac1bf8d5d82820b4808dd7a43020c149b63a1ada953d48a6"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2beee18efd33fa6fdb0976e18475a4042cd31c7433c866e8a09ab604c7c22ff2"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8c36fdf3e02cec92aed2d44f63565ad1522a499c654f07935c8f9d04db69e95"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0fac7be202596c7126146660725157d4813aa29a4cc990fe51346f75ff8fde7"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0fba61846f294bce41eb44d60d58136090ea2b5b99efd21cbdf4e21927c56a"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95720bae002ac357202e0d866128eb1ac82545bcf0b549b9abe91b5178d9b541"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:36c530d8fa03bfa7085af54a48f2ca16ab74df3ec7108a46ba82fd8b411a2315"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e97152983442b499d7a71e44f29baa75b3b02e65d9c44ba53b10338e98dedb66"}, + {file = "uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469"}, ] [package.extras] @@ -2295,86 +2330,94 @@ test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)" [[package]] name = "watchfiles" -version = "0.21.0" +version = "0.24.0" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.8" files = [ - {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"}, - {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"}, - {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"}, - {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"}, - {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"}, - {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"}, - {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"}, - {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"}, - {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"}, - {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"}, - {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"}, - {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"}, - {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"}, - {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"}, - {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"}, - {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"}, - {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"}, - {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"}, - {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"}, - {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"}, - {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"}, - {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"}, - {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"}, - {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"}, - {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"}, - {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"}, - {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"}, - {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"}, - {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"}, - {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"}, - {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"}, - {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"}, - {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"}, + {file = "watchfiles-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:083dc77dbdeef09fa44bb0f4d1df571d2e12d8a8f985dccde71ac3ac9ac067a0"}, + {file = "watchfiles-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e94e98c7cb94cfa6e071d401ea3342767f28eb5a06a58fafdc0d2a4974f4f35c"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82ae557a8c037c42a6ef26c494d0631cacca040934b101d001100ed93d43f361"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acbfa31e315a8f14fe33e3542cbcafc55703b8f5dcbb7c1eecd30f141df50db3"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74fdffce9dfcf2dc296dec8743e5b0332d15df19ae464f0e249aa871fc1c571"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:449f43f49c8ddca87c6b3980c9284cab6bd1f5c9d9a2b00012adaaccd5e7decd"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4abf4ad269856618f82dee296ac66b0cd1d71450fc3c98532d93798e73399b7a"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f895d785eb6164678ff4bb5cc60c5996b3ee6df3edb28dcdeba86a13ea0465e"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ae3e208b31be8ce7f4c2c0034f33406dd24fbce3467f77223d10cd86778471c"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2efec17819b0046dde35d13fb8ac7a3ad877af41ae4640f4109d9154ed30a188"}, + {file = "watchfiles-0.24.0-cp310-none-win32.whl", hash = "sha256:6bdcfa3cd6fdbdd1a068a52820f46a815401cbc2cb187dd006cb076675e7b735"}, + {file = "watchfiles-0.24.0-cp310-none-win_amd64.whl", hash = "sha256:54ca90a9ae6597ae6dc00e7ed0a040ef723f84ec517d3e7ce13e63e4bc82fa04"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec"}, + {file = "watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d"}, + {file = "watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c"}, + {file = "watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968"}, + {file = "watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444"}, + {file = "watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896"}, + {file = "watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18"}, + {file = "watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07"}, + {file = "watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ee82c98bed9d97cd2f53bdb035e619309a098ea53ce525833e26b93f673bc318"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fd92bbaa2ecdb7864b7600dcdb6f2f1db6e0346ed425fbd01085be04c63f0b05"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f83df90191d67af5a831da3a33dd7628b02a95450e168785586ed51e6d28943c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fca9433a45f18b7c779d2bae7beeec4f740d28b788b117a48368d95a3233ed83"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b995bfa6bf01a9e09b884077a6d37070464b529d8682d7691c2d3b540d357a0c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed9aba6e01ff6f2e8285e5aa4154e2970068fe0fc0998c4380d0e6278222269b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5171ef898299c657685306d8e1478a45e9303ddcd8ac5fed5bd52ad4ae0b69b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4933a508d2f78099162da473841c652ad0de892719043d3f07cc83b33dfd9d91"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95cf3b95ea665ab03f5a54765fa41abf0529dbaf372c3b83d91ad2cfa695779b"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:01def80eb62bd5db99a798d5e1f5f940ca0a05986dcfae21d833af7a46f7ee22"}, + {file = "watchfiles-0.24.0-cp38-none-win32.whl", hash = "sha256:4d28cea3c976499475f5b7a2fec6b3a36208656963c1a856d328aeae056fc5c1"}, + {file = "watchfiles-0.24.0-cp38-none-win_amd64.whl", hash = "sha256:21ab23fdc1208086d99ad3f69c231ba265628014d4aed31d4e8746bd59e88cd1"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b665caeeda58625c3946ad7308fbd88a086ee51ccb706307e5b1fa91556ac886"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c51749f3e4e269231510da426ce4a44beb98db2dce9097225c338f815b05d4f"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b2509f08761f29a0fdad35f7e1638b8ab1adfa2666d41b794090361fb8b855"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a60e2bf9dc6afe7f743e7c9b149d1fdd6dbf35153c78fe3a14ae1a9aee3d98b"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7d9b87c4c55e3ea8881dfcbf6d61ea6775fffed1fedffaa60bd047d3c08c430"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78470906a6be5199524641f538bd2c56bb809cd4bf29a566a75051610bc982c3"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07cdef0c84c03375f4e24642ef8d8178e533596b229d32d2bbd69e5128ede02a"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d337193bbf3e45171c8025e291530fb7548a93c45253897cd764a6a71c937ed9"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ec39698c45b11d9694a1b635a70946a5bad066b593af863460a8e600f0dff1ca"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e28d91ef48eab0afb939fa446d8ebe77e2f7593f5f463fd2bb2b14132f95b6e"}, + {file = "watchfiles-0.24.0-cp39-none-win32.whl", hash = "sha256:7138eff8baa883aeaa074359daabb8b6c1e73ffe69d5accdc907d62e50b1c0da"}, + {file = "watchfiles-0.24.0-cp39-none-win_amd64.whl", hash = "sha256:b3ef2c69c655db63deb96b3c3e587084612f9b1fa983df5e0c3379d41307467f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:632676574429bee8c26be8af52af20e0c718cc7f5f67f3fb658c71928ccd4f7f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a2a9891723a735d3e2540651184be6fd5b96880c08ffe1a98bae5017e65b544b"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fa2bc0efef3e209a8199fd111b8969fe9db9c711acc46636686331eda7dd4"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:96619302d4374de5e2345b2b622dc481257a99431277662c30f606f3e22f42be"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:85d5f0c7771dcc7a26c7a27145059b6bb0ce06e4e751ed76cdf123d7039b60b5"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951088d12d339690a92cef2ec5d3cfd957692834c72ffd570ea76a6790222777"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fb58bcaa343fedc6a9e91f90195b20ccb3135447dc9e4e2570c3a39565853e"}, + {file = "watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1"}, ] [package.dependencies] @@ -2382,83 +2425,97 @@ anyio = ">=3.0.0" [[package]] name = "websockets" -version = "12.0" +version = "13.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.8" files = [ - {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, - {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, - {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, - {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, - {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, - {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, - {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, - {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, - {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, - {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, - {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, - {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, - {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, - {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, - {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, - {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, - {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, - {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, - {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, - {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, - {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, - {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, - {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, - {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, - {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1841c9082a3ba4a05ea824cf6d99570a6a2d8849ef0db16e9c826acb28089e8f"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c5870b4a11b77e4caa3937142b650fbbc0914a3e07a0cf3131f35c0587489c1c"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d3d1f2eb79fe7b0fb02e599b2bf76a7619c79300fc55f0b5e2d382881d4f7f"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15c7d62ee071fa94a2fc52c2b472fed4af258d43f9030479d9c4a2de885fd543"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6724b554b70d6195ba19650fef5759ef11346f946c07dbbe390e039bcaa7cc3d"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a952fa2ae57a42ba7951e6b2605e08a24801a4931b5644dfc68939e041bc7f"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17118647c0ea14796364299e942c330d72acc4b248e07e639d34b75067b3cdd8"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a11aae1de4c178fa653b07d90f2fb1a2ed31919a5ea2361a38760192e1858b"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0617fd0b1d14309c7eab6ba5deae8a7179959861846cbc5cb528a7531c249448"}, + {file = "websockets-13.0.1-cp310-cp310-win32.whl", hash = "sha256:11f9976ecbc530248cf162e359a92f37b7b282de88d1d194f2167b5e7ad80ce3"}, + {file = "websockets-13.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c3c493d0e5141ec055a7d6809a28ac2b88d5b878bb22df8c621ebe79a61123d0"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df"}, + {file = "websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f"}, + {file = "websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237"}, + {file = "websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185"}, + {file = "websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2"}, + {file = "websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb"}, + {file = "websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b74593e9acf18ea5469c3edaa6b27fa7ecf97b30e9dabd5a94c4c940637ab96e"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:132511bfd42e77d152c919147078460c88a795af16b50e42a0bd14f0ad71ddd2"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:165bedf13556f985a2aa064309baa01462aa79bf6112fbd068ae38993a0e1f1b"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e801ca2f448850685417d723ec70298feff3ce4ff687c6f20922c7474b4746ae"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30d3a1f041360f029765d8704eae606781e673e8918e6b2c792e0775de51352f"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67648f5e50231b5a7f6d83b32f9c525e319f0ddc841be0de64f24928cd75a603"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4f0426d51c8f0926a4879390f53c7f5a855e42d68df95fff6032c82c888b5f36"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ef48e4137e8799998a343706531e656fdec6797b80efd029117edacb74b0a10a"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:249aab278810bee585cd0d4de2f08cfd67eed4fc75bde623be163798ed4db2eb"}, + {file = "websockets-13.0.1-cp38-cp38-win32.whl", hash = "sha256:06c0a667e466fcb56a0886d924b5f29a7f0886199102f0a0e1c60a02a3751cb4"}, + {file = "websockets-13.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1f3cf6d6ec1142412d4535adabc6bd72a63f5f148c43fe559f06298bc21953c9"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1fa082ea38d5de51dd409434edc27c0dcbd5fed2b09b9be982deb6f0508d25bc"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4a365bcb7be554e6e1f9f3ed64016e67e2fa03d7b027a33e436aecf194febb63"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10a0dc7242215d794fb1918f69c6bb235f1f627aaf19e77f05336d147fce7c37"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59197afd478545b1f73367620407b0083303569c5f2d043afe5363676f2697c9"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d20516990d8ad557b5abeb48127b8b779b0b7e6771a265fa3e91767596d7d97"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1a2e272d067030048e1fe41aa1ec8cfbbaabce733b3d634304fa2b19e5c897f"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad327ac80ba7ee61da85383ca8822ff808ab5ada0e4a030d66703cc025b021c4"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:518f90e6dd089d34eaade01101fd8a990921c3ba18ebbe9b0165b46ebff947f0"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68264802399aed6fe9652e89761031acc734fc4c653137a5911c2bfa995d6d6d"}, + {file = "websockets-13.0.1-cp39-cp39-win32.whl", hash = "sha256:a5dc0c42ded1557cc7c3f0240b24129aefbad88af4f09346164349391dea8e58"}, + {file = "websockets-13.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b448a0690ef43db5ef31b3a0d9aea79043882b4632cfc3eaab20105edecf6097"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:faef9ec6354fe4f9a2c0bbb52fb1ff852effc897e2a4501e25eb3a47cb0a4f89"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:03d3f9ba172e0a53e37fa4e636b86cc60c3ab2cfee4935e66ed1d7acaa4625ad"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d450f5a7a35662a9b91a64aefa852f0c0308ee256122f5218a42f1d13577d71e"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f55b36d17ac50aa8a171b771e15fbe1561217510c8768af3d546f56c7576cdc"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b9c006cac63772b31abbcd3e3abb6228233eec966bf062e89e7fa7ae0b7333"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b79915a1179a91f6c5f04ece1e592e2e8a6bd245a0e45d12fd56b2b59e559a32"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f40de079779acbcdbb6ed4c65af9f018f8b77c5ec4e17a4b737c05c2db554491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e4ba642fc87fa532bac07e5ed7e19d56940b6af6a8c61d4429be48718a380f"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a02b0161c43cc9e0232711eff846569fad6ec836a7acab16b3cf97b2344c060"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6aa74a45d4cdc028561a7d6ab3272c8b3018e23723100b12e58be9dfa5a24491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00fd961943b6c10ee6f0b1130753e50ac5dcd906130dcd77b0003c3ab797d026"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d93572720d781331fb10d3da9ca1067817d84ad1e7c31466e9f5e59965618096"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:71e6e5a3a3728886caee9ab8752e8113670936a193284be9d6ad2176a137f376"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c4a6343e3b0714e80da0b0893543bf9a5b5fa71b846ae640e56e9abc6fbc4c83"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a678532018e435396e37422a95e3ab87f75028ac79570ad11f5bf23cd2a7d8c"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6716c087e4aa0b9260c4e579bb82e068f84faddb9bfba9906cb87726fa2e870"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e33505534f3f673270dd67f81e73550b11de5b538c56fe04435d63c02c3f26b5"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acab3539a027a85d568c2573291e864333ec9d912675107d6efceb7e2be5d980"}, + {file = "websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817"}, + {file = "websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e"}, ] [[package]] @@ -2646,4 +2703,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "744906f5b992165d0fc5e2c03e1db4c9dd16ecbc8e923dbc7b0bed98d599b47f" +content-hash = "fbc6921d47d09abe0013483b8c8aa624135e9b632757f1d87a59aace653b15bd" diff --git a/pyproject.toml b/pyproject.toml index 94de829b..baa807c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" fastapi = "0.111.0" -uvicorn = "0.29.0" +uvicorn = "0.30.6" sqlalchemy = "^2.0.20" dynaconf = "^3.2.1" asyncpg = "^0.28.0" @@ -28,7 +28,7 @@ python-multipart = "0.0.9" puremagic = "^1.15" imagesize = "^1.4.1" sqlalchemy-utils = "^0.41.1" -requests = "2.32.0" +requests = "^2.32.3" urllib3 = "2.2.2" gunicorn = "^22.0.0" prometheus-fastapi-instrumentator = "^7.0.0" From 84dc648f4b6d558238de7bcb5863017bf35b2b51 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Thu, 29 Aug 2024 18:33:32 +0300 Subject: [PATCH 37/44] change ASGI server to uvicorn, use python:3.12.5-alpine3.20 as base image --- Dockerfile | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 59803107..4c320d8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,20 @@ -FROM python:3.11-slim-buster +FROM python:3.12.5-alpine3.20 as base -RUN pip install poetry==1.8.3 +ENV VIRTUAL_ENV=/project/.venv \ + PATH="/project/.venv/bin:$PATH" + + +FROM base as builder ENV POETRY_NO_INTERACTION=1 \ POETRY_VIRTUALENVS_IN_PROJECT=1 \ POETRY_VIRTUALENVS_CREATE=1 \ - POETRY_CACHE_DIR=/tmp/poetry_cache + POETRY_CACHE_DIR=/tmp/poetry_cache \ + PIP_ROOT_USER_ACTION=ignore + +RUN apk add gcc python3-dev musl-dev linux-headers + +RUN pip install poetry==1.8.3 WORKDIR /project @@ -14,9 +23,17 @@ RUN touch README.md RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR + + +FROM base as runtime + +COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} + +WORKDIR /project + COPY sync.py . COPY aggregator.py . COPY alembic ./alembic COPY app ./app -CMD poetry run alembic upgrade head && poetry run gunicorn "app:create_app()" --workers 4 --worker-class uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +CMD uvicorn app:create_app --host 0.0.0.0 --port 8000 From 606c1475a63adb039568771baeef5b35e73930cd Mon Sep 17 00:00:00 2001 From: Nitekot Date: Thu, 29 Aug 2024 19:12:11 +0300 Subject: [PATCH 38/44] add alembic config, get db url from app settings --- Dockerfile | 6 +++++- alembic/env.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4c320d8a..f58f52ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,9 +31,13 @@ COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} WORKDIR /project +# app source files COPY sync.py . COPY aggregator.py . -COPY alembic ./alembic COPY app ./app +# db migrations files +COPY alembic ./alembic +COPY docs/alembic.example.ini ./alembic.ini + CMD uvicorn app:create_app --host 0.0.0.0 --port 8000 diff --git a/alembic/env.py b/alembic/env.py index fb079c11..8d39a0fa 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -8,6 +8,8 @@ from alembic import context from app.models import Base +from app.utils import get_settings + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -28,7 +30,8 @@ # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. - +settings = get_settings() +config.set_main_option('sqlalchemy.url', settings.database.endpoint) def run_migrations_offline() -> None: """Run migrations in 'offline' mode. From 991d47dddaf624e9b011fc0e57b33a50c2b9d4e0 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Thu, 29 Aug 2024 22:08:12 +0300 Subject: [PATCH 39/44] add delete old package step --- .github/workflows/docker-publish.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0e45e292..8e8ed818 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,21 +28,19 @@ jobs: # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@v3.5.0 + uses: sigstore/cosign-installer@v3.6.0 with: - cosign-release: 'v2.3.0' + cosign-release: 'v2.4.0' # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.5.0 + uses: docker/setup-buildx-action@v3.6.1 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' uses: docker/login-action@v3.3.0 with: registry: ${{ env.REGISTRY }} @@ -61,10 +59,10 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.7.0 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha @@ -76,7 +74,6 @@ jobs: # transparency data even for private images, pass --force to cosign below. # https://github.com/sigstore/cosign - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} env: # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable TAGS: ${{ steps.meta.outputs.tags }} @@ -84,3 +81,12 @@ jobs: # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + + # Delete old package versions + # https://github.com/actions/delete-package-versions + - name: Delete old package versions + uses: actions/delete-package-versions@v5 + with: + package-name: 'hikka' + package-type: 'container' + min-versions-to-keep: 8 From d51f1c4be7fafa8a4537c0ddf4008ccafc6aa3cf Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sat, 31 Aug 2024 02:10:26 +0300 Subject: [PATCH 40/44] Auth client selectinload fixes --- app/auth/service.py | 7 +++++-- app/service.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/auth/service.py b/app/auth/service.py index 34e8680e..33e6e19c 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -288,7 +288,7 @@ async def list_user_thirdparty_auth_tokens( ) -> ScalarResult[AuthToken]: return await session.scalars( select(AuthToken) - .options(selectinload(AuthToken.client)) + .options(selectinload(AuthToken.client).selectinload(Client.user)) .filter( AuthToken.user_id == user.id, AuthToken.client_id.is_not(None), @@ -305,7 +305,10 @@ async def get_auth_token( return await session.scalar( select(AuthToken) .filter(AuthToken.id == reference) - .options(selectinload(AuthToken.client), selectinload(AuthToken.user)) + .options( + selectinload(AuthToken.client).selectinload(Client.user), + selectinload(AuthToken.user), + ) ) diff --git a/app/service.py b/app/service.py index 9322ff23..d25e95a8 100644 --- a/app/service.py +++ b/app/service.py @@ -32,6 +32,7 @@ Magazine, Company, Comment, + Client, Person, Genre, Anime, @@ -144,7 +145,10 @@ async def get_auth_token( return await session.scalar( select(AuthToken) .filter(AuthToken.secret == secret) - .options(selectinload(AuthToken.user), selectinload(AuthToken.client)) + .options( + selectinload(AuthToken.user), + selectinload(AuthToken.client).selectinload(Client.user), + ) ) From 56a7359d7e3ac961fa16b7e0a16dd8c3dc2456f1 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sat, 31 Aug 2024 02:14:49 +0300 Subject: [PATCH 41/44] Small fixes --- app/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/utils.py b/app/utils.py index d8f2ef1c..1a2c36d2 100644 --- a/app/utils.py +++ b/app/utils.py @@ -334,6 +334,8 @@ async def check_cloudflare_captcha(response, secret): def is_protected_username(username: str): + username = username.strip().lower() + usernames = [ ["admin", "blog", "dev", "ftp", "mail", "pop", "pop3", "imap", "smtp"], ["stage", "stats", "status", "www", "beta", "about", "access"], @@ -395,7 +397,7 @@ def is_protected_username(username: str): def remove_bad_characters(text): - text.replace("\ufff4", "") + text = text.replace("\ufff4", "") return text From 7c5921e455ffa0dd819f24b9f8984c1b19187e91 Mon Sep 17 00:00:00 2001 From: Nitekot Date: Sun, 1 Sep 2024 20:32:13 +0300 Subject: [PATCH 42/44] add factory flag explicitly --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f58f52ee..2119a377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ COPY app ./app COPY alembic ./alembic COPY docs/alembic.example.ini ./alembic.ini -CMD uvicorn app:create_app --host 0.0.0.0 --port 8000 +CMD uvicorn --factory app:create_app --host 0.0.0.0 --port 8000 From f1042db460681f4854df7767bd79d602616bfbf6 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Fri, 6 Sep 2024 00:51:46 +0300 Subject: [PATCH 43/44] Increase edit description length limit --- app/edit/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/edit/schemas.py b/app/edit/schemas.py index 5c91e52f..bb9a1124 100644 --- a/app/edit/schemas.py +++ b/app/edit/schemas.py @@ -87,7 +87,7 @@ def validate_sort(cls, sort_list): class EditArgs(CustomModel): - description: str | None = Field(None, examples=["..."], max_length=420) + description: str | None = Field(None, examples=["..."], max_length=2048) auto: bool = Field(default=False) after: dict From 57628554ff0a4b6a8f539a1c28f7be97f2ddace1 Mon Sep 17 00:00:00 2001 From: Yaroslaw Biloshytskyi Date: Sun, 15 Sep 2024 13:51:29 +0300 Subject: [PATCH 44/44] Remove captcha from edit endpoints --- app/edit/dependencies.py | 21 ++------------------- app/edit/router.py | 3 --- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/app/edit/dependencies.py b/app/edit/dependencies.py index 96b6d3ea..447b371e 100644 --- a/app/edit/dependencies.py +++ b/app/edit/dependencies.py @@ -1,19 +1,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.utils import check_user_permissions +from app.dependencies import auth_required from app.database import get_session -from fastapi import Depends, Header -from app.models import AuthToken from app.errors import Abort +from fastapi import Depends from app import constants from . import service from . import utils -from app.dependencies import ( - check_captcha as _check_captcha, - auth_token_optional, - auth_required, -) - from app.service import ( get_user_by_username, get_content_by_slug, @@ -193,17 +187,6 @@ async def validate_edit_create( return args -async def check_captcha( - captcha: str | None = Header(None, alias="captcha"), - auth_token: AuthToken | None = Depends(auth_token_optional), -): - # If authorized through third-party client - disable captcha validation - if auth_token is not None and auth_token.client is not None: - return True - - return await _check_captcha(captcha) - - # Todo: perhaps the log based rate limiting logic could be abstracted in the future? async def validate_edit_create_rate_limit( session: AsyncSession = Depends(get_session), diff --git a/app/edit/router.py b/app/edit/router.py index 2afb813e..e8d0ed8a 100644 --- a/app/edit/router.py +++ b/app/edit/router.py @@ -40,7 +40,6 @@ validate_edit_close, validate_content, validate_edit_id, - check_captcha, ) from .schemas import ( @@ -88,7 +87,6 @@ async def create_edit( ), args: EditArgs = Depends(validate_edit_create), author: User = Depends(validate_edit_create_rate_limit), - _: bool = Depends(check_captcha), ): return await service.create_pending_edit( session, content_type, content, args, author @@ -101,7 +99,6 @@ async def update_edit( args: EditArgs = Depends(validate_edit_update_args), edit: Edit = Depends(validate_edit_update), user: User = Depends(validate_edit_update_rate_limit), - _: bool = Depends(check_captcha), ): return await service.update_pending_edit(session, edit, user, args)