From 756e941f99c0f49a5d24a48abf946bcbd8fd2ca6 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Thu, 18 Dec 2025 18:52:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=8D=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 +- docker-compose.yml | 4 +- library_service/auth.py | 210 ++++++ library_service/favicon.svg | 1 - library_service/main.py | 9 +- library_service/models/__init__.py | 3 +- library_service/models/db/__init__.py | 17 +- library_service/models/db/author.py | 14 +- library_service/models/db/book.py | 14 +- library_service/models/db/genre.py | 14 +- library_service/models/db/links.py | 31 +- library_service/models/db/role.py | 20 + library_service/models/db/user.py | 28 + library_service/models/dto/__init__.py | 26 +- library_service/models/dto/author.py | 13 +- library_service/models/dto/book.py | 18 +- library_service/models/dto/combined.py | 53 ++ library_service/models/dto/genre.py | 13 +- library_service/models/dto/role.py | 31 + library_service/models/dto/token.py | 15 + library_service/models/dto/user.py | 61 ++ library_service/routers/__init__.py | 3 + library_service/routers/auth.py | 156 +++++ library_service/routers/authors.py | 34 +- library_service/routers/books.py | 87 ++- library_service/routers/genres.py | 38 +- library_service/routers/misc.py | 47 +- library_service/routers/relationships.py | 240 +++---- library_service/settings.py | 29 +- library_service/static/avatar.svg | 9 + library_service/static/dited.regular.ttf | Bin 0 -> 104344 bytes library_service/static/favicon.svg | 62 ++ library_service/static/logo.svg | 59 ++ library_service/static/novem.regular.ttf | Bin 0 -> 153296 bytes library_service/static/script.js | 135 ++++ library_service/static/styles.css | 77 +++ library_service/templates/api.html | 60 ++ library_service/templates/index.html | 182 ++++-- migrations/env.py | 5 +- migrations/versions/9d7a43ac5dfc_genres.py | 44 +- migrations/versions/b838606ad8d1_auth.py | 82 +++ migrations/versions/d266fdc61e99_init.py | 61 +- poetry.lock | 722 +++++++++++++++++---- pyproject.toml | 9 +- tests/mock_app.py | 9 +- tests/mock_routers/authors.py | 1 + tests/mock_routers/books.py | 1 + tests/mock_routers/genres.py | 1 + tests/mock_routers/relationships.py | 2 +- tests/mocks/mock_session.py | 21 +- tests/mocks/mock_storage.py | 30 +- tests/test_authors.py | 8 +- tests/test_books.py | 24 +- tests/test_misc.py | 32 +- tests/test_relationships.py | 21 +- 55 files changed, 2314 insertions(+), 577 deletions(-) create mode 100644 library_service/auth.py delete mode 100644 library_service/favicon.svg create mode 100644 library_service/models/db/role.py create mode 100644 library_service/models/db/user.py create mode 100644 library_service/models/dto/combined.py create mode 100644 library_service/models/dto/role.py create mode 100644 library_service/models/dto/token.py create mode 100644 library_service/models/dto/user.py create mode 100644 library_service/routers/auth.py create mode 100644 library_service/static/avatar.svg create mode 100644 library_service/static/dited.regular.ttf create mode 100644 library_service/static/favicon.svg create mode 100644 library_service/static/logo.svg create mode 100644 library_service/static/novem.regular.ttf create mode 100644 library_service/static/script.js create mode 100644 library_service/static/styles.css create mode 100644 library_service/templates/api.html create mode 100644 migrations/versions/b838606ad8d1_auth.py diff --git a/.env b/.env index 5685a36..47af945 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ +POSTGRES_HOST = "localhost" +POSTGRES_PORT = "5432" POSTGRES_USER = "postgres" POSTGRES_PASSWORD = "postgres" -POSTGRES_DB = "postgres" -POSTGRES_SERVER = "db" +POSTGRES_DB = "lib" diff --git a/docker-compose.yml b/docker-compose.yml index 44cb0e8..cd46558 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,8 @@ services: - .:/code ports: - "8000:8000" - depends_on: - - db + # depends_on: + # - db tests: container_name: tests diff --git a/library_service/auth.py b/library_service/auth.py new file mode 100644 index 0000000..9fffee1 --- /dev/null +++ b/library_service/auth.py @@ -0,0 +1,210 @@ +"""Модуль авторизации и аутентификации""" +import os +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlmodel import Session, select + +from library_service.models.db import Role, User +from library_service.models.dto import TokenData +from library_service.settings import get_session + + +# Конфигурация из переменных окружения +ALGORITHM = os.getenv("ALGORITHM", "HS256") +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) + +# Хэширование паролей +pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") + +# OAuth2 схема +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Проверка пароль по его хешу.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Хэширование пароля.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + """Создание JWT access токена.""" + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + minutes=ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict) -> str: + """Создание JWT refresh токена.""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> TokenData: + """Декодирование и проверка JWT токенов.""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + user_id: int = payload.get("user_id") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return TokenData(username=username, user_id=user_id) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def authenticate_user(session: Session, username: str, password: str) -> User | None: + """Аутентификация пользователя по имени пользователя и паролю.""" + statement = select(User).where(User.username == username) + user = session.exec(statement).first() + if not user or not verify_password(password, user.hashed_password): + return None + return user + + +def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + session: Session = Depends(get_session), +) -> User: + """Получить текущего авторизованного пользователя.""" + token_data = decode_token(token) + + user = session.get(User, token_data.user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def get_current_active_user( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Получить текущего активного пользователя.""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + return current_user + + +def require_role(role_name: str): + """Dependency, требующая выполнения определенной роли.""" + + def role_checker(current_user: User = Depends(get_current_active_user)) -> User: + user_roles = [role.name for role in current_user.roles] + if role_name not in user_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role '{role_name}' required", + ) + return current_user + + return role_checker + + +# Создание dependencies +RequireAuth = Annotated[User, Depends(get_current_active_user)] +RequireAdmin = Annotated[User, Depends(require_role("admin"))] +RequireModerator = Annotated[User, Depends(require_role("moderator"))] + + +def seed_roles(session: Session) -> dict[str, Role]: + """Создаёт роли по умолчанию, если их нет.""" + default_roles = [ + {"name": "admin", "description": "Администратор системы"}, + {"name": "moderator", "description": "Модератор"}, + {"name": "user", "description": "Обычный пользователь"}, + ] + + roles = {} + for role_data in default_roles: + existing = session.exec( + select(Role).where(Role.name == role_data["name"]) + ).first() + + if existing: + roles[role_data["name"]] = existing + else: + role = Role(**role_data) + session.add(role) + session.commit() + session.refresh(role) + roles[role_data["name"]] = role + print(f"[+] Created role: {role_data['name']}") + + return roles + + +def seed_admin(session: Session, admin_role: Role) -> User | None: + """Создаёт администратора по умолчанию, если нет ни одного.""" + existing_admins = session.exec( + select(User).join(User.roles).where(Role.name == "admin") + ).all() + + if existing_admins: + print(f"[*] Admin already exists: {existing_admins[0].username}") + return None + + admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") + admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com") + admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD") + + if not admin_password: + import secrets + admin_password = secrets.token_urlsafe(16) + print(f"[!] Generated admin password: {admin_password}") + print("[!] Please save this password and set DEFAULT_ADMIN_PASSWORD env var") + + admin_user = User( + username=admin_username, + email=admin_email, + full_name="Системный администратор", + hashed_password=get_password_hash(admin_password), + is_active=True, + is_verified=True, + ) + admin_user.roles.append(admin_role) + + session.add(admin_user) + session.commit() + session.refresh(admin_user) + + print(f"[+] Created admin user: {admin_username}") + return admin_user + + +def run_seeds(session: Session) -> None: + """Запускаем создание ролей и администратора.""" + roles = seed_roles(session) + seed_admin(session, roles["admin"]) diff --git a/library_service/favicon.svg b/library_service/favicon.svg deleted file mode 100644 index 99e7bd4..0000000 --- a/library_service/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -『LiB』 \ No newline at end of file diff --git a/library_service/main.py b/library_service/main.py index 61307c5..6fa9b1e 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -1,12 +1,12 @@ +"""Основной модуль""" +from contextlib import asynccontextmanager + from alembic import command from alembic.config import Config -from contextlib import asynccontextmanager from fastapi import FastAPI -from toml import load -from .settings import engine, get_app from .routers import api_router -from .routers.misc import get_info +from .settings import engine, get_app app = get_app() alembic_cfg = Config("alembic.ini") @@ -14,6 +14,7 @@ alembic_cfg = Config("alembic.ini") @asynccontextmanager async def lifespan(app: FastAPI): + """Жизененый цикл сервиса""" print("[+] Initializing...") # Настройка базы данных diff --git a/library_service/models/__init__.py b/library_service/models/__init__.py index 4794be4..28ca7ae 100644 --- a/library_service/models/__init__.py +++ b/library_service/models/__init__.py @@ -1,2 +1,3 @@ -from .dto import * +"""Модуль моделей""" from .db import * +from .dto import * diff --git a/library_service/models/db/__init__.py b/library_service/models/db/__init__.py index aeb3699..8c04171 100644 --- a/library_service/models/db/__init__.py +++ b/library_service/models/db/__init__.py @@ -1,25 +1,22 @@ +"""Модуль моделей для базы данных""" from .author import Author from .book import Book from .genre import Genre +from .role import Role +from .user import User from .links import ( AuthorBookLink, GenreBookLink, - AuthorWithBooks, - BookWithAuthors, - GenreWithBooks, - BookWithGenres, - BookWithAuthorsAndGenres, + UserRoleLink ) __all__ = [ "Author", "Book", "Genre", + "Role", + "User", "AuthorBookLink", - "AuthorWithBooks", - "BookWithAuthors", "GenreBookLink", - "GenreWithBooks", - "BookWithGenres", - "BookWithAuthorsAndGenres", + "UserRoleLink", ] diff --git a/library_service/models/db/author.py b/library_service/models/db/author.py index ea07fbc..1cad188 100644 --- a/library_service/models/db/author.py +++ b/library_service/models/db/author.py @@ -1,14 +1,18 @@ -from typing import List, Optional, TYPE_CHECKING -from sqlmodel import SQLModel, Field, Relationship -from ..dto.author import AuthorBase -from .links import AuthorBookLink +"""Модуль DB-моделей авторов""" +from typing import TYPE_CHECKING, List + +from sqlmodel import Field, Relationship + +from library_service.models.dto.author import AuthorBase +from library_service.models.db.links import AuthorBookLink if TYPE_CHECKING: from .book import Book class Author(AuthorBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) + """Модель автора в базе данных""" + id: int | None = Field(default=None, primary_key=True, index=True) books: List["Book"] = Relationship( back_populates="authors", link_model=AuthorBookLink ) diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py index cf09158..55218f3 100644 --- a/library_service/models/db/book.py +++ b/library_service/models/db/book.py @@ -1,7 +1,10 @@ -from typing import List, Optional, TYPE_CHECKING -from sqlmodel import SQLModel, Field, Relationship -from ..dto.book import BookBase -from .links import AuthorBookLink, GenreBookLink +"""Модуль DB-моделей книг""" +from typing import TYPE_CHECKING, List + +from sqlmodel import Field, Relationship + +from library_service.models.dto.book import BookBase +from library_service.models.db.links import AuthorBookLink, GenreBookLink if TYPE_CHECKING: from .author import Author @@ -9,7 +12,8 @@ if TYPE_CHECKING: class Book(BookBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) + """Модель книги в базе данных""" + id: int | None = Field(default=None, primary_key=True, index=True) authors: List["Author"] = Relationship( back_populates="books", link_model=AuthorBookLink ) diff --git a/library_service/models/db/genre.py b/library_service/models/db/genre.py index 120beeb..d8a3cfa 100644 --- a/library_service/models/db/genre.py +++ b/library_service/models/db/genre.py @@ -1,14 +1,18 @@ -from typing import List, Optional, TYPE_CHECKING -from sqlmodel import SQLModel, Field, Relationship -from ..dto.genre import GenreBase -from .links import GenreBookLink +"""Модуль DB-моделей жанров""" +from typing import TYPE_CHECKING, List + +from sqlmodel import Field, Relationship + +from library_service.models.dto.genre import GenreBase +from library_service.models.db.links import GenreBookLink if TYPE_CHECKING: from .book import Book class Genre(GenreBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) + """Модель жанра в базе данных""" + id: int | None = Field(default=None, primary_key=True, index=True) books: List["Book"] = Relationship( back_populates="genres", link_model=GenreBookLink ) diff --git a/library_service/models/db/links.py b/library_service/models/db/links.py index 78f5553..1a9bc2c 100644 --- a/library_service/models/db/links.py +++ b/library_service/models/db/links.py @@ -1,12 +1,9 @@ +"""Модуль связей между сущностями в БД""" from sqlmodel import SQLModel, Field -from typing import List - -from library_service.models.dto.author import AuthorRead -from library_service.models.dto.book import BookRead -from library_service.models.dto.genre import GenreRead class AuthorBookLink(SQLModel, table=True): + """Модель связи автора и книги""" author_id: int | None = Field( default=None, foreign_key="author.id", primary_key=True ) @@ -14,26 +11,14 @@ class AuthorBookLink(SQLModel, table=True): class GenreBookLink(SQLModel, table=True): + """Модель связи жанра и книги""" genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True) book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True) -class AuthorWithBooks(AuthorRead): - books: List[BookRead] = Field(default_factory=list) +class UserRoleLink(SQLModel, table=True): + """Модель связи роли и пользователя""" + __tablename__ = "user_roles" - -class BookWithAuthors(BookRead): - authors: List[AuthorRead] = Field(default_factory=list) - - -class BookWithGenres(BookRead): - genres: List[GenreRead] = Field(default_factory=list) - - -class GenreWithBooks(GenreRead): - books: List[BookRead] = Field(default_factory=list) - - -class BookWithAuthorsAndGenres(BookRead): - authors: List[AuthorRead] = Field(default_factory=list) - genres: List[GenreRead] = Field(default_factory=list) + user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True) + role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True) diff --git a/library_service/models/db/role.py b/library_service/models/db/role.py new file mode 100644 index 0000000..4aab599 --- /dev/null +++ b/library_service/models/db/role.py @@ -0,0 +1,20 @@ +"""Модуль DB-моделей ролей""" +from typing import TYPE_CHECKING, List + +from sqlmodel import Field, Relationship + +from library_service.models.dto.role import RoleBase +from library_service.models.db.links import UserRoleLink + +if TYPE_CHECKING: + from .user import User + + +class Role(RoleBase, table=True): + """Модель роли в базе данных""" + __tablename__ = "roles" + + id: int | None = Field(default=None, primary_key=True, index=True) + + # Связи + users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink) diff --git a/library_service/models/db/user.py b/library_service/models/db/user.py new file mode 100644 index 0000000..ff1e3f9 --- /dev/null +++ b/library_service/models/db/user.py @@ -0,0 +1,28 @@ +"""Модуль DB-моделей пользователей""" +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlmodel import Field, Relationship + +from library_service.models.dto.user import UserBase +from library_service.models.db.links import UserRoleLink + +if TYPE_CHECKING: + from .role import Role + + +class User(UserBase, table=True): + """Модель пользователя в базе данных""" + __tablename__ = "users" + + id: int | None = Field(default=None, primary_key=True, index=True) + hashed_password: str = Field(nullable=False) + is_active: bool = Field(default=True) + is_verified: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime | None = Field( + default=None, sa_column_kwargs={"onupdate": datetime.utcnow} + ) + + # Связи + roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink) diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py index e47f418..ad17b84 100644 --- a/library_service/models/dto/__init__.py +++ b/library_service/models/dto/__init__.py @@ -1,7 +1,12 @@ -from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList -from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList - -from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList +"""Модуль DTO-моделей""" +from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate +from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate +from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate +from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate +from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate +from .token import Token, TokenData +from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres, + BookWithAuthorsAndGenres, BookFilteredList) __all__ = [ "AuthorBase", @@ -14,9 +19,22 @@ __all__ = [ "BookUpdate", "BookRead", "BookList", + "BookFilteredList", "GenreBase", "GenreCreate", "GenreUpdate", "GenreRead", "GenreList", + "RoleBase", + "RoleCreate", + "RoleUpdate", + "RoleRead", + "RoleList", + "Token", + "TokenData", + "UserBase", + "UserCreate", + "UserRead", + "UserUpdate", + "UserLogin", ] diff --git a/library_service/models/dto/author.py b/library_service/models/dto/author.py index 59ca6e0..877925d 100644 --- a/library_service/models/dto/author.py +++ b/library_service/models/dto/author.py @@ -1,9 +1,12 @@ -from sqlmodel import SQLModel +"""Модуль DTO-моделей авторов""" +from typing import List + from pydantic import ConfigDict -from typing import Optional, List +from sqlmodel import SQLModel class AuthorBase(SQLModel): + """Базовая модель автора""" name: str model_config = ConfigDict( # pyright: ignore @@ -12,17 +15,21 @@ class AuthorBase(SQLModel): class AuthorCreate(AuthorBase): + """Модель автора для создания""" pass class AuthorUpdate(SQLModel): - name: Optional[str] = None + """Модель автора для обновления""" + name: str | None = None class AuthorRead(AuthorBase): + """Модель автора для чтения""" id: int class AuthorList(SQLModel): + """Список авторов""" authors: List[AuthorRead] total: int diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py index 5667749..546cfea 100644 --- a/library_service/models/dto/book.py +++ b/library_service/models/dto/book.py @@ -1,9 +1,15 @@ -from sqlmodel import SQLModel +"""Модуль DTO-моделей книг""" +from typing import List, TYPE_CHECKING + from pydantic import ConfigDict -from typing import Optional, List +from sqlmodel import SQLModel + +if TYPE_CHECKING: + from .combined import BookWithAuthorsAndGenres class BookBase(SQLModel): + """Базовая модель книги""" title: str description: str @@ -15,18 +21,22 @@ class BookBase(SQLModel): class BookCreate(BookBase): + """Модель книги для создания""" pass class BookUpdate(SQLModel): - title: Optional[str] = None - description: Optional[str] = None + """Модель книги для обновления""" + title: str | None = None + description: str | None = None class BookRead(BookBase): + """Модель книги для чтения""" id: int class BookList(SQLModel): + """Список книг""" books: List[BookRead] total: int diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/combined.py new file mode 100644 index 0000000..ae0ba5c --- /dev/null +++ b/library_service/models/dto/combined.py @@ -0,0 +1,53 @@ +"""Модуль объединёных объектов""" +from typing import List +from sqlmodel import SQLModel, Field + +from .author import AuthorRead +from .genre import GenreRead +from .book import BookRead + + +class AuthorWithBooks(SQLModel): + """Модель автора с книгами""" + id: int + name: str + bio: str + books: List[BookRead] = Field(default_factory=list) + + +class GenreWithBooks(SQLModel): + """Модель жанра с книгами""" + id: int + name: str + books: List[BookRead] = Field(default_factory=list) + + +class BookWithAuthors(SQLModel): + """Модель книги с авторами""" + id: int + title: str + description: str + authors: List[AuthorRead] = Field(default_factory=list) + + +class BookWithGenres(SQLModel): + """Модель книги с жанрами""" + id: int + title: str + description: str + genres: List[GenreRead] = Field(default_factory=list) + + +class BookWithAuthorsAndGenres(SQLModel): + """Модель с авторами и жанрами""" + id: int + title: str + description: str + authors: List[AuthorRead] = Field(default_factory=list) + genres: List[GenreRead] = Field(default_factory=list) + + +class BookFilteredList(SQLModel): + """Список книг с фильтрацией""" + books: List[BookWithAuthorsAndGenres] + total: int diff --git a/library_service/models/dto/genre.py b/library_service/models/dto/genre.py index 48856d2..643207a 100644 --- a/library_service/models/dto/genre.py +++ b/library_service/models/dto/genre.py @@ -1,9 +1,12 @@ -from sqlmodel import SQLModel +"""Модуль DTO-моделей жанров""" +from typing import List + from pydantic import ConfigDict -from typing import Optional, List +from sqlmodel import SQLModel class GenreBase(SQLModel): + """Базовая модель жанра""" name: str model_config = ConfigDict( # pyright: ignore @@ -12,17 +15,21 @@ class GenreBase(SQLModel): class GenreCreate(GenreBase): + """Модель жанра для создания""" pass class GenreUpdate(SQLModel): - name: Optional[str] = None + """Модель жанра для обновления""" + name: str | None = None class GenreRead(GenreBase): + """Модель жанра для чтения""" id: int class GenreList(SQLModel): + """Списко жанров""" genres: List[GenreRead] total: int diff --git a/library_service/models/dto/role.py b/library_service/models/dto/role.py new file mode 100644 index 0000000..6a0326f --- /dev/null +++ b/library_service/models/dto/role.py @@ -0,0 +1,31 @@ +"""Модуль DTO-моделей ролей""" +from typing import List + +from sqlmodel import SQLModel + + +class RoleBase(SQLModel): + """Базовая модель роли""" + name: str + description: str | None = None + + +class RoleCreate(RoleBase): + """Модель роли для создания""" + pass + + +class RoleUpdate(SQLModel): + """Модель роли для обновления""" + name: str | None = None + + +class RoleRead(RoleBase): + """Модель роли для чтения""" + id: int + + +class RoleList(SQLModel): + """Список ролей""" + roles: List[RoleRead] + total: int diff --git a/library_service/models/dto/token.py b/library_service/models/dto/token.py new file mode 100644 index 0000000..903bc93 --- /dev/null +++ b/library_service/models/dto/token.py @@ -0,0 +1,15 @@ +"""Модуль DTO-моделей токенов""" +from sqlmodel import SQLModel + + +class Token(SQLModel): + """Модель токена""" + access_token: str + token_type: str = "bearer" + refresh_token: str | None = None + + +class TokenData(SQLModel): + """Модель содержимого токена""" + username: str | None = None + user_id: int | None = None diff --git a/library_service/models/dto/user.py b/library_service/models/dto/user.py new file mode 100644 index 0000000..ea70179 --- /dev/null +++ b/library_service/models/dto/user.py @@ -0,0 +1,61 @@ +"""Модуль DTO-моделей пользователей""" +import re +from typing import List + +from pydantic import ConfigDict, EmailStr, field_validator +from sqlmodel import Field, SQLModel + + +class UserBase(SQLModel): + """Базовая модель пользователя""" + username: str = Field(min_length=3, max_length=50, index=True, unique=True) + email: EmailStr = Field(index=True, unique=True) + full_name: str | None = Field(default=None, max_length=100) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "username": "johndoe", + "email": "john@example.com", + "full_name": "John Doe", + } + } + ) + + +class UserCreate(UserBase): + """Модель пользователя для создания""" + password: str = Field(min_length=8, max_length=100) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + """Валидация пароля""" + if not re.search(r"[A-Z]", v): + raise ValueError("Пароль должен содержать символы в верхнем регистре") + if not re.search(r"[a-z]", v): + raise ValueError("Пароль должен содержать символы в нижнем регистре") + if not re.search(r"\d", v): + raise ValueError("пароль должен содержать цифры") + return v + + +class UserLogin(SQLModel): + """Модель аутентификации для пользователя""" + username: str + password: str + + +class UserRead(UserBase): + """Модель пользователя для чтения""" + id: int + is_active: bool + is_verified: bool + roles: List[str] = [] + + +class UserUpdate(SQLModel): + """Модель пользователя для обновления""" + email: EmailStr | None = None + full_name: str | None = None + password: str | None = None diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py index a94f037..7661a50 100644 --- a/library_service/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -1,5 +1,7 @@ +"""Модуль объединения роутеров""" from fastapi import APIRouter +from .auth import router as auth_router from .authors import router as authors_router from .books import router as books_router from .genres import router as genres_router @@ -9,6 +11,7 @@ from .misc import router as misc_router api_router = APIRouter() # Подключение всех маршрутов +api_router.include_router(auth_router) api_router.include_router(authors_router) api_router.include_router(books_router) api_router.include_router(genres_router) diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py new file mode 100644 index 0000000..5199be1 --- /dev/null +++ b/library_service/routers/auth.py @@ -0,0 +1,156 @@ +"""Модуль работы с авторизацией и аутентификацией пользователей""" +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlmodel import Session, select + +from library_service.models.db import Role, User +from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate +from library_service.settings import get_session +from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, + RequireAuth, authenticate_user, get_password_hash, + create_access_token, create_refresh_token) + +router = APIRouter(prefix="/auth", tags=["authentication"]) + + +@router.post( + "/register", + response_model=UserRead, + status_code=status.HTTP_201_CREATED, + summary="Регистрация нового пользователя", + description="Создает нового пользователя в системе", +) +def register(user_data: UserCreate, session: Session = Depends(get_session)): + """Эндпоинт регистрации пользователя""" + # Проверка если username существует + existing_user = session.exec( + select(User).where(User.username == user_data.username) + ).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered", + ) + + # Проверка если email существует + existing_email = session.exec( + select(User).where(User.email == user_data.email) + ).first() + if existing_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" + ) + + # Создание пользователя + db_user = User( + **user_data.model_dump(exclude={"password"}), + hashed_password=get_password_hash(user_data.password) + ) + + # Назначение роли по умолчанию + default_role = session.exec(select(Role).where(Role.name == "user")).first() + if default_role: + db_user.roles.append(default_role) + + session.add(db_user) + session.commit() + session.refresh(db_user) + + return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles]) + + +@router.post( + "/token", + response_model=Token, + summary="Получение токена", + description="Аутентификация и получение JWT токена", +) +def login( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + session: Session = Depends(get_session), +): + """Эндпоинт аутентификации и получения JWT токена""" + user = authenticate_user(session, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username, "user_id": user.id}, + expires_delta=access_token_expires, + ) + refresh_token = create_refresh_token( + data={"sub": user.username, "user_id": user.id} + ) + + return Token( + access_token=access_token, refresh_token=refresh_token, token_type="bearer" + ) + + +@router.get( + "/me", + response_model=UserRead, + summary="Текущий пользователь", + description="Получить информацию о текущем авторизованном пользователе", +) +def read_users_me(current_user: RequireAuth): + """Эндпоинт получения информации о себе""" + return UserRead( + **current_user.model_dump(), roles=[role.name for role in current_user.roles] + ) + + +@router.put( + "/me", + response_model=UserRead, + summary="Обновить профиль", + description="Обновить информацию текущего пользователя", +) +def update_user_me( + user_update: UserUpdate, + current_user: RequireAuth, + session: Session = Depends(get_session), +): + """Эндпоинт обновления пользователя""" + if user_update.email: + current_user.email = user_update.email + if user_update.full_name: + current_user.full_name = user_update.full_name + if user_update.password: + current_user.hashed_password = get_password_hash(user_update.password) + + session.add(current_user) + session.commit() + session.refresh(current_user) + + return UserRead( + **current_user.model_dump(), roles=[role.name for role in current_user.roles] + ) + + +@router.get( + "/users", + response_model=list[UserRead], + summary="Список пользователей", + description="Получить список всех пользователей (только для админов)", +) +def read_users( + admin: RequireAdmin, + session: Session = Depends(get_session), + skip: int = 0, + limit: int = 100, +): + """Эндпоинт получения списка всех пользователей""" + users = session.exec(select(User).offset(skip).limit(limit)).all() + return [ + UserRead(**user.model_dump(), roles=[role.name for role in user.roles]) + for user in users + ] diff --git a/library_service/routers/authors.py b/library_service/routers/authors.py index 6eef937..19e3283 100644 --- a/library_service/routers/authors.py +++ b/library_service/routers/authors.py @@ -1,28 +1,28 @@ -from fastapi import APIRouter, Path, Depends, HTTPException +"""Модуль работы с авторами""" +from fastapi import APIRouter, Depends, HTTPException, Path from sqlmodel import Session, select +from library_service.auth import RequireAuth from library_service.settings import get_session -from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks -from library_service.models.dto import ( - AuthorCreate, - AuthorUpdate, - AuthorRead, - AuthorList, - BookRead, -) - +from library_service.models.db import Author, AuthorBookLink, Book +from library_service.models.dto import (BookRead, AuthorWithBooks, + AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) router = APIRouter(prefix="/authors", tags=["authors"]) -# Create an author @router.post( "/", response_model=AuthorRead, summary="Создать автора", description="Добавляет автора в систему", ) -def create_author(author: AuthorCreate, session: Session = Depends(get_session)): +def create_author( + current_user: RequireAuth, + author: AuthorCreate, + session: Session = Depends(get_session), +): + """Эндпоинт создания автора""" db_author = Author(**author.model_dump()) session.add(db_author) session.commit() @@ -30,7 +30,6 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session)) return AuthorRead(**db_author.model_dump()) -# Read authors @router.get( "/", response_model=AuthorList, @@ -38,6 +37,7 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session)) description="Возвращает список всех авторов в системе", ) def read_authors(session: Session = Depends(get_session)): + """Эндпоинт чтения списка авторов""" authors = session.exec(select(Author)).all() return AuthorList( authors=[AuthorRead(**author.model_dump()) for author in authors], @@ -45,7 +45,6 @@ def read_authors(session: Session = Depends(get_session)): ) -# Read an author with their books @router.get( "/{author_id}", response_model=AuthorWithBooks, @@ -56,6 +55,7 @@ def get_author( author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт чтения конкретного автора""" author = session.get(Author, author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") @@ -72,7 +72,6 @@ def get_author( return AuthorWithBooks(**author_data) -# Update an author @router.put( "/{author_id}", response_model=AuthorRead, @@ -80,10 +79,12 @@ def get_author( description="Обновляет информацию об авторе в системе", ) def update_author( + current_user: RequireAuth, author: AuthorUpdate, author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт обновления автора""" db_author = session.get(Author, author_id) if not db_author: raise HTTPException(status_code=404, detail="Author not found") @@ -97,7 +98,6 @@ def update_author( return AuthorRead(**db_author.model_dump()) -# Delete an author @router.delete( "/{author_id}", response_model=AuthorRead, @@ -105,9 +105,11 @@ def update_author( description="Удаляет автора из системы", ) def delete_author( + current_user: RequireAuth, author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт удаления автора""" author = session.get(Author, author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") diff --git a/library_service/routers/books.py b/library_service/routers/books.py index c8df20f..80c9d6d 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,29 +1,32 @@ -from fastapi import APIRouter, Path, Depends, HTTPException -from sqlmodel import Session, select +"""Модуль работы с книгами""" +from typing import List -from library_service.models.db.links import BookWithAuthorsAndGenres +from fastapi import APIRouter, Depends, HTTPException, Path, Query +from sqlmodel import Session, select, col, func + +from library_service.auth import RequireAuth from library_service.settings import get_session -from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink -from library_service.models.dto import ( - AuthorRead, - BookList, - BookRead, - BookCreate, - BookUpdate, +from library_service.models.db import Author, AuthorBookLink, Book +from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate +from library_service.models.dto.combined import ( + BookWithAuthorsAndGenres, + BookFilteredList ) router = APIRouter(prefix="/books", tags=["books"]) -# Create a book @router.post( "/", response_model=Book, summary="Создать книгу", description="Добавляет книгу в систему", ) -def create_book(book: BookCreate, session: Session = Depends(get_session)): +def create_book( + current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session) +): + """Эндпоинт создания книги""" db_book = Book(**book.model_dump()) session.add(db_book) session.commit() @@ -31,7 +34,6 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)): return BookRead(**db_book.model_dump()) -# Read books @router.get( "/", response_model=BookList, @@ -39,13 +41,13 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)): description="Возвращает список всех книг в системе", ) def read_books(session: Session = Depends(get_session)): + """Эндпоинт чтения списка книг""" books = session.exec(select(Book)).all() return BookList( books=[BookRead(**book.model_dump()) for book in books], total=len(books) ) -# Read a book with their authors and genres @router.get( "/{book_id}", response_model=BookWithAuthorsAndGenres, @@ -56,6 +58,7 @@ def get_book( book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт чтения конкретной книги""" book = session.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") @@ -76,10 +79,9 @@ def get_book( book_data["authors"] = author_reads book_data["genres"] = genre_reads - return BookWithAuthors(**book_data) + return BookWithAuthorsAndGenres(**book_data) -# Update a book @router.put( "/{book_id}", response_model=Book, @@ -87,10 +89,12 @@ def get_book( description="Обновляет информацию о книге в системе", ) def update_book( + current_user: RequireAuth, book: BookUpdate, book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт обновления книги""" db_book = session.get(Book, book_id) if not db_book: raise HTTPException(status_code=404, detail="Book not found") @@ -102,7 +106,6 @@ def update_book( return db_book -# Delete a book @router.delete( "/{book_id}", response_model=BookRead, @@ -110,9 +113,11 @@ def update_book( description="Удаляет книгу их системы", ) def delete_book( + current_user: RequireAuth, book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт удаления книги""" book = session.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") @@ -122,3 +127,51 @@ def delete_book( session.delete(book) session.commit() return book_read + + +@router.get( + "/filter", + response_model=BookFilteredList, + summary="Фильтрация книг", + description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией" +) +def filter_books( + session: Session = Depends(get_session), + q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"), + author_ids: List[int] | None = Query(None, description="Список ID авторов"), + genre_ids: List[int] | None = Query(None, description="Список ID жанров"), + page: int = Query(1, gt=0, description="Номер страницы"), + size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), +): + """Эндпоинт получения отфильтрованного списка книг""" + statement = select(Book).distinct() + + if q: + statement = statement.where( + (col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%")) + ) + + if author_ids: + statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids)) + + if genre_ids: + statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids)) + + total_statement = select(func.count()).select_from(statement.subquery()) + total = session.exec(total_statement).one() + + offset = (page - 1) * size + statement = statement.offset(offset).limit(size) + results = session.exec(statement).all() + + books_with_data = [] + for db_book in results: + books_with_data.append( + BookWithAuthorsAndGenres( + **db_book.model_dump(), + authors=[AuthorRead(**a.model_dump()) for a in db_book.authors], + genres=[GenreRead(**g.model_dump()) for g in db_book.genres] + ) + ) + + return BookFilteredList(books=books_with_data, total=total) diff --git a/library_service/routers/genres.py b/library_service/routers/genres.py index e1e352b..8ff9fe4 100644 --- a/library_service/routers/genres.py +++ b/library_service/routers/genres.py @@ -1,28 +1,28 @@ -from fastapi import APIRouter, Path, Depends, HTTPException +"""Модуль работы с жанрами""" +from fastapi import APIRouter, Depends, HTTPException, Path from sqlmodel import Session, select +from library_service.auth import RequireAuth +from library_service.models.db import Book, Genre, GenreBookLink +from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks from library_service.settings import get_session -from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks -from library_service.models.dto import ( - GenreCreate, - GenreUpdate, - GenreRead, - GenreList, - BookRead, -) - router = APIRouter(prefix="/genres", tags=["genres"]) -# Create a genre +# Создание жанра @router.post( "/", response_model=GenreRead, summary="Создать жанр", description="Добавляет жанр книг в систему", ) -def create_genre(genre: GenreCreate, session: Session = Depends(get_session)): +def create_genre( + current_user: RequireAuth, + genre: GenreCreate, + session: Session = Depends(get_session), +): + """Эндпоинт создания жанра""" db_genre = Genre(**genre.model_dump()) session.add(db_genre) session.commit() @@ -30,7 +30,7 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)): return GenreRead(**db_genre.model_dump()) -# Read genres +# Чтение жанров @router.get( "/", response_model=GenreList, @@ -38,13 +38,14 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)): description="Возвращает список всех жанров в системе", ) def read_genres(session: Session = Depends(get_session)): + """Эндпоинт чтения списка жанров""" genres = session.exec(select(Genre)).all() return GenreList( genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres) ) -# Read a genre with their books +# Чтение жанра с его книгами @router.get( "/{genre_id}", response_model=GenreWithBooks, @@ -55,6 +56,7 @@ def get_genre( genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт чтения конкретного жанра""" genre = session.get(Genre, genre_id) if not genre: raise HTTPException(status_code=404, detail="Genre not found") @@ -71,7 +73,7 @@ def get_genre( return GenreWithBooks(**genre_data) -# Update a genre +# Обновление жанра @router.put( "/{genre_id}", response_model=GenreRead, @@ -79,10 +81,12 @@ def get_genre( description="Обновляет информацию о жанре в системе", ) def update_genre( + current_user: RequireAuth, genre: GenreUpdate, genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт обновления жанра""" db_genre = session.get(Genre, genre_id) if not db_genre: raise HTTPException(status_code=404, detail="Genre not found") @@ -96,7 +100,7 @@ def update_genre( return GenreRead(**db_genre.model_dump()) -# Delete a genre +# Удаление жанра @router.delete( "/{genre_id}", response_model=GenreRead, @@ -104,9 +108,11 @@ def update_genre( description="Удаляет автора из системы", ) def delete_genre( + current_user: RequireAuth, genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): + """Эндпоинт удаления жанра""" genre = session.get(Genre, genre_id) if not genre: raise HTTPException(status_code=404, detail="Genre not found") diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index 4f0a7ed..b00687d 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -1,21 +1,22 @@ -from fastapi import APIRouter, Path, Request -from fastapi.params import Depends -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from pathlib import Path +"""Модуль прочих эндпоинтов""" from datetime import datetime +from pathlib import Path from typing import Dict +from fastapi import APIRouter, Request +from fastapi.params import Depends +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + from library_service.settings import get_app -# Загрузка шаблонов -templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") router = APIRouter(tags=["misc"]) +templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") -# Форматированная информация о приложении def get_info(app) -> Dict: + """Форматированная информация о приложении""" return { "status": "ok", "app_info": { @@ -27,29 +28,49 @@ def get_info(app) -> Dict: } -# Эндпоинт главной страницы @router.get("/", include_in_schema=False) async def root(request: Request, app=Depends(get_app)): + """Эндпоинт главной страницы""" return templates.TemplateResponse(request, "index.html", get_info(app)) -# Редирект иконки вкладки +@router.get("/api", include_in_schema=False) +async def root(request: Request, app=Depends(get_app)): + """Страница с сылками на документацию API""" + return templates.TemplateResponse(request, "api.html", get_info(app)) + + @router.get("/favicon.ico", include_in_schema=False) def redirect_favicon(): + """Редирект иконки вкладки""" return RedirectResponse("/favicon.svg") -# Эндпоинт иконки вкладки @router.get("/favicon.svg", include_in_schema=False) async def favicon(): - return FileResponse("library_service/favicon.svg", media_type="image/svg+xml") + """Эндпоинт иконки вкладки""" + return FileResponse( + "library_service/static/favicon.svg", media_type="image/svg+xml" + ) + + +@router.get("/static/{path:path}", include_in_schema=False) +async def serve_static(path: str): + """Статические файлы""" + static_dir = Path(__file__).parent.parent / "static" + file_path = static_dir / path + + if not file_path.is_file() or not file_path.is_relative_to(static_dir): + return JSONResponse(status_code=404, content={"error": "File not found"}) + + return FileResponse(file_path) -# Эндпоинт информации об API @router.get( "/api/info", summary="Информация о сервисе", description="Возвращает информацию о системе", ) async def api_info(app=Depends(get_app)): + """Эндпоинт информации об API""" return JSONResponse(content=get_info(app)) diff --git a/library_service/routers/relationships.py b/library_service/routers/relationships.py index 4e808de..52dd816 100644 --- a/library_service/routers/relationships.py +++ b/library_service/routers/relationships.py @@ -1,15 +1,82 @@ +"""Модуль работы со связями""" +from typing import Dict, List + from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select -from typing import List, Dict -from library_service.settings import get_session -from library_service.models.db import Author, Book, Genre, AuthorBookLink, GenreBookLink +from library_service.auth import RequireAuth +from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink from library_service.models.dto import AuthorRead, BookRead, GenreRead +from library_service.settings import get_session + router = APIRouter(tags=["relations"]) -# Add author to book +def check_entity_exists(session, model, entity_id, entity_name): + """Проверка существования связи между сущностями в БД""" + entity = session.get(model, entity_id) + if not entity: + raise HTTPException(status_code=404, detail=f"{entity_name} not found") + return entity + + +def add_relationship(session, link_model, id1, field1, id2, field2, detail): + """Создание связи между сущностями в БД""" + existing_link = session.exec( + select(link_model) + .where(getattr(link_model, field1) == id1) + .where(getattr(link_model, field2) == id2) + ).first() + + if existing_link: + raise HTTPException(status_code=400, detail=detail) + + link = link_model(**{field1: id1, field2: id2}) + session.add(link) + session.commit() + session.refresh(link) + return link + + +def remove_relationship(session, link_model, id1, field1, id2, field2): + """Удаление связи между сущностями в БД""" + link = session.exec( + select(link_model) + .where(getattr(link_model, field1) == id1) + .where(getattr(link_model, field2) == id2) + ).first() + + if not link: + raise HTTPException(status_code=404, detail="Relationship not found") + + session.delete(link) + session.commit() + return {"message": "Relationship removed successfully"} + + +def get_related( + session, + main_model, + main_id, + main_name, + related_model, + link_model, + link_main_field, + link_related_field, + read_model + ): + """Получение связанных в БД сущностей""" + check_entity_exists(session, main_model, main_id, main_name) + + related = session.exec( + select(related_model).join(link_model) + .where(getattr(link_model, link_main_field) == main_id) + ).all() + + return [read_model(**obj.model_dump()) for obj in related] + + @router.post( "/relationships/author-book", response_model=AuthorBookLink, @@ -17,33 +84,19 @@ router = APIRouter(tags=["relations"]) description="Добавляет связь между автором и книгой в систему", ) def add_author_to_book( - author_id: int, book_id: int, session: Session = Depends(get_session) + current_user: RequireAuth, + author_id: int, + book_id: int, + session: Session = Depends(get_session), ): - author = session.get(Author, author_id) - if not author: - raise HTTPException(status_code=404, detail="Author not found") + """Эндпоинт добавления автора к книге""" + check_entity_exists(session, Author, author_id, "Author") + check_entity_exists(session, Book, book_id, "Book") - book = session.get(Book, book_id) - if not book: - raise HTTPException(status_code=404, detail="Book not found") - - existing_link = session.exec( - select(AuthorBookLink) - .where(AuthorBookLink.author_id == author_id) - .where(AuthorBookLink.book_id == book_id) - ).first() - - if existing_link: - raise HTTPException(status_code=400, detail="Relationship already exists") - - link = AuthorBookLink(author_id=author_id, book_id=book_id) - session.add(link) - session.commit() - session.refresh(link) - return link + return add_relationship(session, AuthorBookLink, + author_id, "author_id", book_id, "book_id", "Relationship already exists") -# Remove author from book @router.delete( "/relationships/author-book", response_model=Dict[str, str], @@ -51,23 +104,16 @@ def add_author_to_book( description="Удаляет связь между автором и книгой в системе", ) def remove_author_from_book( - author_id: int, book_id: int, session: Session = Depends(get_session) + current_user: RequireAuth, + author_id: int, + book_id: int, + session: Session = Depends(get_session), ): - link = session.exec( - select(AuthorBookLink) - .where(AuthorBookLink.author_id == author_id) - .where(AuthorBookLink.book_id == book_id) - ).first() - - if not link: - raise HTTPException(status_code=404, detail="Relationship not found") - - session.delete(link) - session.commit() - return {"message": "Relationship removed successfully"} + """Эндпоинт удаления автора из книги""" + return remove_relationship(session, AuthorBookLink, + author_id, "author_id", book_id, "book_id") -# Get author's books @router.get( "/authors/{author_id}/books/", response_model=List[BookRead], @@ -75,18 +121,12 @@ def remove_author_from_book( description="Возвращает все книги в системе, написанные автором", ) def get_books_for_author(author_id: int, session: Session = Depends(get_session)): - author = session.get(Author, author_id) - if not author: - raise HTTPException(status_code=404, detail="Author not found") - - books = session.exec( - select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id) - ).all() - - return [BookRead(**book.model_dump()) for book in books] + """Эндпоинт получения книг, написанных автором""" + return get_related(session, + Author, author_id, "Author", Book, + AuthorBookLink, "author_id", "book_id", BookRead) -# Get book's authors @router.get( "/books/{book_id}/authors/", response_model=List[AuthorRead], @@ -94,18 +134,12 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session) description="Возвращает всех авторов книги в системе", ) def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): - book = session.get(Book, book_id) - if not book: - raise HTTPException(status_code=404, detail="Book not found") - - authors = session.exec( - select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) - ).all() - - return [AuthorRead(**author.model_dump()) for author in authors] + """Эндпоинт получения авторов книги""" + return get_related(session, + Book, book_id, "Book", Author, + AuthorBookLink, "book_id", "author_id", AuthorRead) -# Add genre to book @router.post( "/relationships/genre-book", response_model=GenreBookLink, @@ -113,33 +147,19 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): description="Добавляет связь между книгой и жанром в систему", ) def add_genre_to_book( - genre_id: int, book_id: int, session: Session = Depends(get_session) + current_user: RequireAuth, + genre_id: int, + book_id: int, + session: Session = Depends(get_session), ): - genre = session.get(Genre, genre_id) - if not genre: - raise HTTPException(status_code=404, detail="Genre not found") + """Эндпоинт добавления жанра к книге""" + check_entity_exists(session, Genre, genre_id, "Genre") + check_entity_exists(session, Book, book_id, "Book") - book = session.get(Book, book_id) - if not book: - raise HTTPException(status_code=404, detail="Book not found") - - existing_link = session.exec( - select(GenreBookLink) - .where(GenreBookLink.genre_id == genre_id) - .where(GenreBookLink.book_id == book_id) - ).first() - - if existing_link: - raise HTTPException(status_code=400, detail="Relationship already exists") - - link = GenreBookLink(genre_id=genre_id, book_id=book_id) - session.add(link) - session.commit() - session.refresh(link) - return link + return add_relationship(session, GenreBookLink, + genre_id, "genre_id", book_id, "book_id", "Relationship already exists") -# Remove author from book @router.delete( "/relationships/genre-book", response_model=Dict[str, str], @@ -147,55 +167,37 @@ def add_genre_to_book( description="Удаляет связь между жанром и книгой в системе", ) def remove_genre_from_book( - genre_id: int, book_id: int, session: Session = Depends(get_session) + current_user: RequireAuth, + genre_id: int, + book_id: int, + session: Session = Depends(get_session), ): - link = session.exec( - select(GenreBookLink) - .where(GenreBookLink.genre_id == genre_id) - .where(GenreBookLink.book_id == book_id) - ).first() - - if not link: - raise HTTPException(status_code=404, detail="Relationship not found") - - session.delete(link) - session.commit() - return {"message": "Relationship removed successfully"} + """Эндпоинт удаления жанра из книги""" + return remove_relationship(session, GenreBookLink, + genre_id, "genre_id", book_id, "book_id") -# Get genre's books @router.get( - "/genres/{author_id}/books/", + "/genres/{genre_id}/books/", response_model=List[BookRead], summary="Получить книги, написанные в жанре", description="Возвращает все книги в системе в этом жанре", ) def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): - genre = session.get(Genre, genre_id) - if not genre: - raise HTTPException(status_code=404, detail="Genre not found") - - books = session.exec( - select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id) - ).all() - - return [BookRead(**book.model_dump()) for book in books] + """Эндпоинт получения книг с жанром""" + return get_related(session, + Genre, genre_id, "Genre", Book, + GenreBookLink, "genre_id", "book_id", BookRead) -# Get book's genres @router.get( "/books/{book_id}/genres/", response_model=List[GenreRead], summary="Получить жанры книги", description="Возвращает все жанры книги в системе", ) -def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): - book = session.get(Book, book_id) - if not book: - raise HTTPException(status_code=404, detail="Book not found") - - genres = session.exec( - select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) - ).all() - - return [GenreRead(**author.model_dump()) for genre in genres] +def get_genres_for_book(book_id: int, session: Session = Depends(get_session)): + """Эндпоинт получения жанров книги""" + return get_related(session, + Book, book_id, "Book", Genre, + GenreBookLink, "book_id", "genre_id", GenreRead) diff --git a/library_service/settings.py b/library_service/settings.py index 7994cef..a6585b2 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -1,59 +1,66 @@ +"""Модуль настроек проекта""" import os + from dotenv import load_dotenv from fastapi import FastAPI -from sqlmodel import create_engine, SQLModel, Session +from sqlmodel import Session, create_engine from toml import load load_dotenv() -with open("pyproject.toml") as f: +with open("pyproject.toml", 'r', encoding='utf-8') as f: config = load(f) -# Dependency to get the FastAPI application instance def get_app() -> FastAPI: + """Dependency, для получение экземплярра FastAPI application""" return FastAPI( title=config["tool"]["poetry"]["name"], description=config["tool"]["poetry"]["description"], version=config["tool"]["poetry"]["version"], openapi_tags=[ + { + "name": "authentication", + "description": "Авторизация пользователя." + }, { "name": "authors", - "description": "Operations with authors.", + "description": "Действия с авторами.", }, { "name": "books", - "description": "Operations with books.", + "description": "Действия с книгами.", }, { "name": "genres", - "description": "Operations with genres.", + "description": "Действия с жанрами.", }, { "name": "relations", - "description": "Operations with relations.", + "description": "Действия с связями.", }, { "name": "misc", - "description": "Miscellaneous operations.", + "description": "Прочие.", }, ], ) +HOST = os.getenv("POSTGRES_HOST") +PORT = os.getenv("POSTGRES_PORT") USER = os.getenv("POSTGRES_USER") PASSWORD = os.getenv("POSTGRES_PASSWORD") DATABASE = os.getenv("POSTGRES_DB") -HOST = os.getenv("POSTGRES_SERVER") if not USER or not PASSWORD or not DATABASE or not HOST: raise ValueError("Missing environment variables") -POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}" +POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}" engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True) -# Dependency to get a database session def get_session(): + """Dependency, для получение сессии БД""" with Session(engine) as session: yield session diff --git a/library_service/static/avatar.svg b/library_service/static/avatar.svg new file mode 100644 index 0000000..4168b04 --- /dev/null +++ b/library_service/static/avatar.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/library_service/static/dited.regular.ttf b/library_service/static/dited.regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e0633b3a4c89135b5f2b7dd5fffc57872a65c58f GIT binary patch literal 104344 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『LiB』 diff --git a/library_service/static/logo.svg b/library_service/static/logo.svg new file mode 100644 index 0000000..6fe7c5d --- /dev/null +++ b/library_service/static/logo.svg @@ -0,0 +1,59 @@ + + + + + + + 『LiB』 + diff --git a/library_service/static/novem.regular.ttf b/library_service/static/novem.regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9781090c069610b5b1d6089f9060e7b4a387f682 GIT binary patch literal 153296 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

NnOR z*ZEu~Q_>~P8E1c1kcMmTL?8uQ4Qamuvt(sVEcj$#=Jn~3H0Mtkd*zZ&OsFA?Db%77 z>3jswDbL0!l!{rVY8_Xx!f36=ox0M^<<_mT+A|fjr(+bCs&h5GZFk3Zj>CBcI^K>A z{06Z&0!IZFj*f*KMOw@WB1E|6U%} zz#PN?^4%MUNG`=&V#b(na??UM4~Q9Y`t4SNHWL(CPsrdw5saefPv}ZFO9c%cRKlxH z-Fqwo3ts8-!wy2ewn7QDZXYQp@ixKV&wUyoWE%CG3TzUQ-uS>5dj z8)ZjSBXY8&EU7E?Nckk_yaKmfft#iyZBviM)vuw$@CRY$+ek|yO6a%(asxecuh!>? zXw$-b{w&?$eLv*cByWfIVWyl%WdUg-+NkFXZbUt{oF`nzI$cSZRYB~OZfWTVqN)!k z4SvPng)i*J{A%i#o?y^fXv0;b2q6ns zMdtTJ8zpH*NzZvV-4+M*4u6QO!Ps@l!#phV) z`yi7g$Ci+uUY7Z{v5$pe+TT?Jqj9K&uAxWD=p9aTpoob6F0Tf-&%|%UK*biM4TP5F zN&|4E^BkSXi<~YY9H!Vb`^kCj3;c-}ttP^vh2Bn&>LoI?{$hDgZcEmj;$r4u{mr`n z@wL^oL8w(~V+I;Yt&K{LmUaOwx$20)U_*^2HmbF+@JsvavR)u07Lzi)W>BCCZ!Zg| z#Q}qgoro0Z@$=CKe}rYFl2$A5Ab4mVpz&iW)!m9{Woqmwh9fR8Ue!$aVb>m{k`k&n=Lvu)!QafK{PKiiekuyOj1_-et(mMS-8fNffE`; zmWXzdH(QqN$&n$1_$@46f%W>V?V^-93?xezF-mj~7{c2X?yPNOF=!zUkuTWj(1A~q zd_p$$MyAoEE-sjGS0XB6jDhtkp~S}GMMfhJVng0VDfM;X6hZ?bG!~;PY=BF;ofu8Y zJk=Pdks*Cj2Wt=bAOkt0Lc`YZq-dkoL8vASlyCtDJq;XV0Zd015Yqaxen7iVy6op? zbT&6JQFTa}M!h!vk{G6K+Pzq8bn@4-gv=a)~&MwM39H7+oa3 z!Wk=ZI3v2ixfJ|mwof`1ZSDr4g-MDqjvg;FUpwDP6ee_Etp^hrt;l9I)uqvf#2A7( zMY6{jk#nkj#e5X3}Boi0fb&4P(CMd8Y z#J>u>3?C!H@KEwcs4Pl4KzK>p%Hh|ZA(F$#5KRMXjnTx*Swbv5+K40N5ROq2J-Vp~ zrq}#)i@bUuXvFIeGzc~sTu-f~tz zwVKJ^Z8O_Jw9!J`=g(}v)WB>nPGEt`Yo+gE-jcyVzKWlKKpQP0b{1wsSrW4uRN*Xz zW^orYvZ<3!_?ZfyBoo+=c{nj)VHIBGtepT`kaWyM76P+Y>u0q|?b=J9gV5moe$;ob zBN1Pv=&50nas-__#2pwGimWo=sC=5_6ftGvH?S3N^fO8u)xPUVgEL&x9}!zbHKlZ` zOKp`*&vRTVpkq6!FfMhZQxtlecFV|vZ*p-sM7bKHml=E3#7+!+Z0C4UN+J^Fa{{DK z60Z=r0!T1KEQk#dr5w`hDd~^WeP#T|*;~9`>yHe41|)wNK2*|>kYuHM(%@qB=p#Q^L!H zVCPs#A|c;)bsL-mev3BUB4<9=xZiBo6| z@3cr~AQR`eVQvxOzVoPRhUVB?nh!N#tA0^V?yOcV9k;L~EB^OOkRX&=0gvhWk*_b1AGC$ znjCj1?#<4Afeo)n`bM^?E8&Mb)Mwr}ilQp?wRNSrL+BIuQ}}b}c!;`PR}T(Vx7Sag zp3n;CLwnWjLx+~;)=$7+Cl4L^>xUX9OrT5ZcBJK4mvujNr%VU)VN=?=!lqCXO;)#K zL)eOGyy2l=53Rsq!UPQmPd_9ng~y*oI%jr!#nSRLpDQDS0os};CCjlx78U6}f<0T+ z-p2&|JY~k(WClJU0V;Hb)5$ZDnI{y6ITCrrR`Dk4pae3fX|hA2+N3D@s0hl7L_U#^ z425wVA>~X8d%RL0KZ}zRNh7c-1_}|>B{=rO*+o`@+BGNCSkH*NJ$}oMJ=%2Ks7Sr)B3<*)`b2 z!%hf|jcHLP%U{VB?}3XAe??Zxd{XmQ*1h7~mV-^&ZucH93@~cO&))A0`9P6f>lf2d z8>ycmB^((oGE!o{LrW(MMy$`MJBZP$JM?wUnrV&SRQclNjFvg2TgUb&Wa~x%6-p;t zCa&%%BsjHU@5s>UjYe#lyC?}#H;xi3MNBwRC*j&i8B$G>&_ZrwC~%~46_PTB?m2e-SkDcp_`CqThgjL&a{lb9Ifq`sPDkLkVO*G>ztOJGT6@Mqu{0ilDi-y zM@kcz9nS^yIL)liVsjjaO-LQT+dskgN-jWmF-22Xe19T-S?`H}YR!HJC8s)X82}Pd;@-9G?(JV_L&3 zf0y(F=3y|PE;|^No6)Lyu<9D=1OBY(;I#et@#YwbRD0=7kMuIkbR7MrR9jP%|7=aE zR&A>><^~No$nFVLe7}Gbo5*bEQ#2pn zq+KL9K3ZSXG5H$8`v9v45>?Qc8(onV8St*=(vQQO^>}LPtNUAIe6XosxVTl#ckIXl zj1Sxm*;TmwL%+q-^C~268F^PG37CZtqPs_a#PiXEz3WF)bU+O#h=f!^{RX|Pfks43 zw_2-YOd3?IKu6@2>2VWuEEdyr1ku&m?OnmQlnp{ zf>Nt*H|v?-n0Z%OC)(bwB;{qwi?wd9+LxiOkwtZ}-YoF4?kV41mp zFdiRkj%%O+&M?QdFdjCT;{)IXxYiuk!D4s}zhX7sdn(TJv=m!f2N&sIOK`sg*1|jR z?_#{V1h1}#6*wQ%T4;kwxN<=&9D~_=$HPo~ejT>51pgkRTcc-I>bBOw8f@oiXu6zpM0jo`q-E;;#n0 zPG8lEN2lR6zJD#=vk)(R0gfq~_Jqyl;bKqRWj3&g1W;h&P2S>n>a1`oilkgi_@!jn>4xKnd;Qese09*8r67pn3|xP)I`;+4p*;JN2nv!QR?+-l4?<{s!g@44%LZH zw<#E9n5w4XoTRff6>Q(2fh3W#eNG(=N)LYe3wM;EnZ&Mel6>6ng zrBh16+__JE8l)6Z*Ln?Y)W6FQQJCWMIq}HonPpVI;Ppiw} z$Lce1tGYscR$Zz7NnNErr><7lsB6{d)phE6b%XkXx>0>m-K4&xZdPAbUs1QHTh&+9 z*VNb5ZR#89o9ds{CUv{I1I~l<)wk50>f7pH)Mj;;`i}Znb+@`leOG-?{hRu}`hohP z`jNU<{k!_H`VV!V`ic6f`cJh*-LL*j{kQs=dH_DJ{zv^>J*a-6eyM(?9#X$nzfljX zN7QfC@6_+rR`sZQOg*liP=8QQsz0iy)SuL!)zj)3_5ajg)L+%J>N)i{^}nbk1H<}f7M?KN(==kV_XioH|=*`iI(Mi$C(cEZWbV_t;G(TDp zod!=uZ;4Ki&WO&8&Wg@P4)#g-RCG>sZggJM8=W65giGNg@Hx04x*%EsKvrZJp6Qu3x)k@xqIi(W&;ZG7jDU*>z3dy}hqv}DohRp#vE9=h7rGfnSXJKI`y8=W0y z-=;sLwX?(Q+uF@_vv2D#zsQ_OX>365ux0`->365ux0`-|4!qj5n>F(+r%Z+51huC!2k% z>DOelZ#DgxZ1%0DACt|#)%0Vs*|!=vPd58j1DDBW-)g>lvi*Jo=gDS2!+h_I*;U<( z)~;T-P7in6RDOLsc5BAUg?M3A^+L1nG5aNEPbAl7hP@4kdyYB3Twd>B@U(SsH`@$4 zwiz^RGsEA8!+x55j`#T!7q4EIUx9zBPF%DQKcs4f-tl)D@U~lU^q6k<81T(BV4JDI z)z-u4hkM=q_V%`EgBPw{yZW6gmb`UcwLV(EW{BReUA}bLI=;Ag^*dJ$UUJbo?9n>M zEp&(4TRVDkJ*$@HmaJMzAJo<}LytG^^?0}S@VMcg$E$rBk9+$xhJU-En|4DlZBrRg z?FK*Ert-M8TYTs?9qi@~w$0>mZ=cHTw@qU_ZtpVZ4V|@{0d2GIpThLmHj@XWeOh~A z#)>t|78VxL-~7xa>lWrOz(0lK7p`2nu&|W=YLItK>nto@vSQuB>Yge zv*?+3;h$U&ZuIPeW%PF){msu=jenNopTe6Lu359Na3TGzI&tC33l=Y|TA_E*N$dF) zGiOC7ua8!(kIq;gZCGACcja=8fvb5R&0C&3b=m6N+GVQ?^OrBhue*r;<`yhmpIf(Z zef4SPyVmnQH*eYU+#1|w;kF33c^BdDi04J%m2RtThz7>G)vH!t zG(K^AFQ+{bLYznGdk9`d~11n2)H}`l#0QgnyXs@DKCR{KI^?{xM`OeI6g` zFXdaP^~K9kgk7}!qFQ=^{V~+E=1=UCdoOy3F<2jKv+ZJM%aa%nCbZo5p9Ghr8@ejG7$=g*&?R@AlSN z%t||Y%*%VseTL>c9L*b+JJYb-X?%a@G}~@xL3FW;39+M#zou;_-_v zc(>_ex8x=4H5%))7Fm#Z6=R?+f2=NXLK*w)0 z-#>e{-D~!b*Ug>@5`SB9pue-dl~rKe>vnL@NY*yZu=^=2M(llUtY)^iwN9>Gz6v#C nRDstmN8U~t+Br?X9QR!qBg>(cJp?WF!p@y&tJA+B`Y-%{^mC_K literal 0 HcmV?d00001 diff --git a/library_service/static/script.js b/library_service/static/script.js new file mode 100644 index 0000000..4cb272f --- /dev/null +++ b/library_service/static/script.js @@ -0,0 +1,135 @@ +// Load authors and genres asynchronously +Promise.all([ + fetch("/authors").then((response) => response.json()), + fetch("/genres").then((response) => response.json()), +]) + .then(([authorsData, genresData]) => { + // Populate authors dropdown + const dropdown = document.getElementById("author-dropdown"); + authorsData.authors.forEach((author) => { + const div = document.createElement("div"); + div.className = "p-2 hover:bg-gray-100 cursor-pointer"; + div.setAttribute("data-value", author.name); + div.textContent = author.name; + dropdown.appendChild(div); + }); + + // Populate genres list + const list = document.getElementById("genres-list"); + genresData.genres.forEach((genre) => { + const li = document.createElement("li"); + li.className = "mb-1"; + li.innerHTML = ` + + `; + list.appendChild(li); + }); + + initializeAuthorDropdown(); + }) + .catch((error) => console.error("Error loading data:", error)); + +function initializeAuthorDropdown() { + const authorSearchInput = document.getElementById("author-search-input"); + const authorDropdown = document.getElementById("author-dropdown"); + const selectedAuthorsContainer = document.getElementById( + "selected-authors-container", + ); + const dropdownItems = authorDropdown.querySelectorAll("[data-value]"); + let selectedAuthors = new Set(); + + // Function to update highlights in dropdown + const updateDropdownHighlights = () => { + dropdownItems.forEach((item) => { + const value = item.dataset.value; + if (selectedAuthors.has(value)) { + item.classList.add("bg-gray-200"); + } else { + item.classList.remove("bg-gray-200"); + } + }); + }; + + // Function to render selected authors + const renderSelectedAuthors = () => { + Array.from(selectedAuthorsContainer.children).forEach((child) => { + if (child.id !== "author-search-input") { + child.remove(); + } + }); + + selectedAuthors.forEach((author) => { + const authorChip = document.createElement("span"); + authorChip.className = + "flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full"; + authorChip.innerHTML = ` + ${author} + + `; + selectedAuthorsContainer.insertBefore(authorChip, authorSearchInput); + }); + updateDropdownHighlights(); + }; + + // Handle input focus to show dropdown + authorSearchInput.addEventListener("focus", () => { + authorDropdown.classList.remove("hidden"); + }); + + // Handle input for filtering + authorSearchInput.addEventListener("input", () => { + const query = authorSearchInput.value.toLowerCase(); + dropdownItems.forEach((item) => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(query) ? "block" : "none"; + }); + authorDropdown.classList.remove("hidden"); + }); + + // Handle clicks outside to hide dropdown + document.addEventListener("click", (event) => { + if ( + !selectedAuthorsContainer.contains(event.target) && + !authorDropdown.contains(event.target) + ) { + authorDropdown.classList.add("hidden"); + } + }); + + // Handle author selection from dropdown + authorDropdown.addEventListener("click", (event) => { + const selectedValue = event.target.dataset.value; + if (selectedValue) { + if (selectedAuthors.has(selectedValue)) { + selectedAuthors.delete(selectedValue); + } else { + selectedAuthors.add(selectedValue); + } + authorSearchInput.value = ""; + renderSelectedAuthors(); + authorSearchInput.focus(); + } + }); + + // Handle removing selected author chip + selectedAuthorsContainer.addEventListener("click", (event) => { + if (event.target.closest("button")) { + const authorToRemove = event.target.closest("button").dataset.author; + selectedAuthors.delete(authorToRemove); + renderSelectedAuthors(); + authorSearchInput.focus(); + } + }); + + // Initial render and highlights (without auto-focus) + renderSelectedAuthors(); +} diff --git a/library_service/static/styles.css b/library_service/static/styles.css new file mode 100644 index 0000000..5a8b0a3 --- /dev/null +++ b/library_service/static/styles.css @@ -0,0 +1,77 @@ +@font-face { + font-family: "Novem"; + src: url("novem.regular.ttf") format("truetype"); +} + +@font-face { + font-family: "Dited"; + src: url("dited.regular.ttf") format("truetype"); +} + +h1 { + font-family: "Novem", sans-serif; + letter-spacing: 10px; +} + +nav ul li a { + font-family: "Dited", sans-serif; + letter-spacing: 2.5px; + font-size: large; +} + +/* Custom checkbox styles */ +.custom-checkbox { + display: inline-block; + position: relative; + cursor: pointer; + font-size: 14px; + user-select: none; +} + +.custom-checkbox input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + height: 18px; + width: 18px; + background-color: #fff; + border: 2px solid #d1d5db; /* gray-300 */ + border-radius: 4px; + transition: all 0.2s ease; + display: inline-block; + margin-right: 8px; +} + +.custom-checkbox:hover input ~ .checkmark { + border-color: #6b7280; /* gray-500 */ +} + +.custom-checkbox input:checked ~ .checkmark { + background-color: #6b7280; /* gray-500 */ + border-color: #6b7280; +} + +.checkmark:after { + content: ""; + position: absolute; + display: none; +} + +.custom-checkbox input:checked ~ .checkmark:after { + display: block; +} + +.custom-checkbox .checkmark:after { + left: 6.5px; + top: 6px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} diff --git a/library_service/templates/api.html b/library_service/templates/api.html new file mode 100644 index 0000000..a1307fa --- /dev/null +++ b/library_service/templates/api.html @@ -0,0 +1,60 @@ + + + + + + {{ app_info.title }} + + + + +

Welcome to {{ app_info.title }}!

+

Description: {{ app_info.description }}

+

Version: {{ app_info.version }}

+

Current Time: {{ server_time }}

+

Status: {{ status }}

+
+ + diff --git a/library_service/templates/index.html b/library_service/templates/index.html index a1307fa..c9beb6d 100644 --- a/library_service/templates/index.html +++ b/library_service/templates/index.html @@ -3,58 +3,136 @@ - {{ app_info.title }} - + LiB + + - - -

Welcome to {{ app_info.title }}!

-

Description: {{ app_info.description }}

-

Version: {{ app_info.version }}

-

Current Time: {{ server_time }}

-

Status: {{ status }}

- + + +
+
+ + +

LiB

+
+ + +
+
+ + +
+ + + +
+ +
+
+

Product Title 1

+

+ A short description of the product, highlighting its + key features and benefits. +

+
+ $29.99 +
+ + +
+
+

Product Title 2

+

+ Another great product with amazing features. You'll + love it! +

+
+ $49.99 +
+ + +
+
+

Product Title 3

+

+ This product is a must-have for every modern home. + High quality and durable. +

+
+ $19.99 +
+
+
+ + +
+
+

© 2025 My Awesome Library. All rights reserved.

+
+
+ diff --git a/migrations/env.py b/migrations/env.py index c4e5b77..bcc58f9 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,8 +1,7 @@ from logging.config import fileConfig -from alembic import context -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from alembic import context +from sqlalchemy import engine_from_config, pool from sqlmodel import SQLModel from library_service.settings import POSTGRES_DATABASE_URL diff --git a/migrations/versions/9d7a43ac5dfc_genres.py b/migrations/versions/9d7a43ac5dfc_genres.py index 3056f1a..21d4227 100644 --- a/migrations/versions/9d7a43ac5dfc_genres.py +++ b/migrations/versions/9d7a43ac5dfc_genres.py @@ -5,41 +5,49 @@ Revises: d266fdc61e99 Create Date: 2025-06-25 11:24:30.229418 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa import sqlmodel - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '9d7a43ac5dfc' -down_revision: Union[str, None] = 'd266fdc61e99' +revision: str = "9d7a43ac5dfc" +down_revision: Union[str, None] = "d266fdc61e99" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('genre', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "genre", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_genre_id'), 'genre', ['id'], unique=False) - op.create_table('genrebooklink', - sa.Column('genre_id', sa.Integer(), nullable=False), - sa.Column('book_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['book_id'], ['book.id'], ), - sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ), - sa.PrimaryKeyConstraint('genre_id', 'book_id') + op.create_index(op.f("ix_genre_id"), "genre", ["id"], unique=False) + op.create_table( + "genrebooklink", + sa.Column("genre_id", sa.Integer(), nullable=False), + sa.Column("book_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["book_id"], + ["book.id"], + ), + sa.ForeignKeyConstraint( + ["genre_id"], + ["genre.id"], + ), + sa.PrimaryKeyConstraint("genre_id", "book_id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('genrebooklink') - op.drop_index(op.f('ix_genre_id'), table_name='genre') - op.drop_table('genre') + op.drop_table("genrebooklink") + op.drop_index(op.f("ix_genre_id"), table_name="genre") + op.drop_table("genre") # ### end Alembic commands ### diff --git a/migrations/versions/b838606ad8d1_auth.py b/migrations/versions/b838606ad8d1_auth.py new file mode 100644 index 0000000..5995ff3 --- /dev/null +++ b/migrations/versions/b838606ad8d1_auth.py @@ -0,0 +1,82 @@ +"""auth + +Revision ID: b838606ad8d1 +Revises: 9d7a43ac5dfc +Create Date: 2025-12-07 20:18:05.839579 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b838606ad8d1" +down_revision: Union[str, None] = "9d7a43ac5dfc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "roles", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_roles_id"), "roles", ["id"], unique=False) + op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True) + op.create_table( + "users", + sa.Column( + "username", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False + ), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "full_name", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True + ), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + op.create_table( + "user_roles", + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("role_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["role_id"], + ["roles.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("user_id", "role_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_roles") + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + op.drop_index(op.f("ix_roles_name"), table_name="roles") + op.drop_index(op.f("ix_roles_id"), table_name="roles") + op.drop_table("roles") + # ### end Alembic commands ### diff --git a/migrations/versions/d266fdc61e99_init.py b/migrations/versions/d266fdc61e99_init.py index 42ca91d..012e677 100644 --- a/migrations/versions/d266fdc61e99_init.py +++ b/migrations/versions/d266fdc61e99_init.py @@ -1,19 +1,19 @@ """init Revision ID: d266fdc61e99 -Revises: +Revises: Create Date: 2025-05-27 18:04:22.279035 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa import sqlmodel - +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'd266fdc61e99' +revision: str = "d266fdc61e99" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,34 +21,43 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('author', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "author", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_author_id'), 'author', ['id'], unique=False) - op.create_table('book', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_index(op.f("ix_author_id"), "author", ["id"], unique=False) + op.create_table( + "book", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_book_id'), 'book', ['id'], unique=False) - op.create_table('authorbooklink', - sa.Column('author_id', sa.Integer(), nullable=False), - sa.Column('book_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['author_id'], ['author.id'], ), - sa.ForeignKeyConstraint(['book_id'], ['book.id'], ), - sa.PrimaryKeyConstraint('author_id', 'book_id') + op.create_index(op.f("ix_book_id"), "book", ["id"], unique=False) + op.create_table( + "authorbooklink", + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("book_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["author_id"], + ["author.id"], + ), + sa.ForeignKeyConstraint( + ["book_id"], + ["book.id"], + ), + sa.PrimaryKeyConstraint("author_id", "book_id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('authorbooklink') - op.drop_index(op.f('ix_book_id'), table_name='book') - op.drop_table('book') - op.drop_index(op.f('ix_author_id'), table_name='author') - op.drop_table('author') + op.drop_table("authorbooklink") + op.drop_index(op.f("ix_book_id"), table_name="book") + op.drop_table("book") + op.drop_index(op.f("ix_author_id"), table_name="author") + op.drop_table("author") # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 38f5513..7b421ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "25.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, +] + [[package]] name = "alembic" version = "1.16.2" @@ -53,6 +65,75 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, + {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"}, + {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.0.1", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] + +[[package]] +name = "astroid" +version = "4.0.2" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.10.0" +groups = ["dev"] +files = [ + {file = "astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b"}, + {file = "astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070"}, +] + [[package]] name = "black" version = "25.1.0" @@ -110,6 +191,103 @@ files = [ {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "click" version = "8.2.1" @@ -138,6 +316,99 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "46.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "dill" +version = "0.4.0" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, + {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "dnspython" version = "2.7.0" @@ -159,6 +430,25 @@ idna = ["idna (>=3.7)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "email-validator" version = "2.2.0" @@ -439,6 +729,22 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "isort" +version = "7.0.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.10.0" +groups = ["dev"] +files = [ + {file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"}, + {file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -585,6 +891,18 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -703,6 +1021,27 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.dependencies] +argon2-cffi = {version = ">=18.2.0", optional = true, markers = "extra == \"argon2\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "pathspec" version = "0.12.1" @@ -826,23 +1165,49 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -850,115 +1215,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pydantic-extra-types" @@ -1023,6 +1410,31 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pylint" +version = "4.0.4" +description = "python code static checker" +optional = false +python-versions = ">=3.10.0" +groups = ["dev"] +files = [ + {file = "pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0"}, + {file = "pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2"}, +] + +[package.dependencies] +astroid = ">=4.0.2,<=4.1.dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} +isort = ">=5,<5.13 || >5.13,<8" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2" +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + [[package]] name = "pytest" version = "8.4.1" @@ -1045,6 +1457,25 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +pytest = ">=8.2,<10" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dotenv" version = "0.21.1" @@ -1060,6 +1491,30 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-jose" +version = "3.5.0" +description = "JOSE implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""} +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + [[package]] name = "python-multipart" version = "0.0.20" @@ -1171,6 +1626,21 @@ click = ">=8.1.7" rich = ">=13.7.1" typing-extensions = ">=4.12.2" +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "shellingham" version = "1.5.4" @@ -1183,6 +1653,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1337,6 +1819,18 @@ files = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + [[package]] name = "typer" version = "0.16.0" @@ -1357,26 +1851,26 @@ typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, - {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -1750,4 +2244,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "a3555dac28547317a5d8d75507b5923d03b58412a988a260b627c079782bc15c" +content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e" diff --git a/pyproject.toml b/pyproject.toml index e3a7a41..822e221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "LibraryAPI" -version = "0.1.3" +version = "0.2.0" description = "Это простое API для управления авторами, книгами и их жанрами." authors = ["wowlikon"] readme = "README.md" @@ -16,10 +16,17 @@ sqlmodel = "^0.0.24" uvicorn = "^0.34.3" jinja2 = "^3.1.6" toml = "^0.10.2" +python-jose = {extras = ["cryptography"], version = "^3.5.0"} +passlib = {extras = ["argon2"], version = "^1.7.4"} +aiofiles = "^25.1.0" +pydantic = {extras = ["email"], version = "^2.12.5"} [tool.poetry.group.dev.dependencies] black = "^25.1.0" pytest = "^8.4.1" +isort = "^7.0.0" +pytest-asyncio = "^1.3.0" +pylint = "^4.0.4" [tool.poetry.requires-plugins] poetry-plugin-export = ">=1.8" diff --git a/tests/mock_app.py b/tests/mock_app.py index ca63a53..621a9f6 100644 --- a/tests/mock_app.py +++ b/tests/mock_app.py @@ -1,23 +1,24 @@ from fastapi import FastAPI -from tests.mock_routers import books, authors, genres, relationships + from library_service.routers.misc import router as misc_router +from tests.mock_routers import authors, books, genres, relationships def create_mock_app() -> FastAPI: - """Create FastAPI app with mock routers for testing""" + """Создание FastAPI app с моками роутеров для тестов""" app = FastAPI( title="Library API Test", description="Library API for testing without database", version="1.0.0", ) - # Include mock routers + # Подключение мок-роутеров app.include_router(books.router) app.include_router(authors.router) app.include_router(genres.router) app.include_router(relationships.router) - # Include real misc router (it doesn't use database) + # Подключение реального misc роутера app.include_router(misc_router) return app diff --git a/tests/mock_routers/authors.py b/tests/mock_routers/authors.py index e90da1a..1313eb3 100644 --- a/tests/mock_routers/authors.py +++ b/tests/mock_routers/authors.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException + from tests.mocks.mock_storage import mock_storage router = APIRouter(prefix="/authors", tags=["authors"]) diff --git a/tests/mock_routers/books.py b/tests/mock_routers/books.py index be58a4f..702a32c 100644 --- a/tests/mock_routers/books.py +++ b/tests/mock_routers/books.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException + from tests.mocks.mock_storage import mock_storage router = APIRouter(prefix="/books", tags=["books"]) diff --git a/tests/mock_routers/genres.py b/tests/mock_routers/genres.py index 46fb6a6..aeccecc 100644 --- a/tests/mock_routers/genres.py +++ b/tests/mock_routers/genres.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException + from tests.mocks.mock_storage import mock_storage router = APIRouter(prefix="/genres", tags=["genres"]) diff --git a/tests/mock_routers/relationships.py b/tests/mock_routers/relationships.py index b8385e4..fa6c1c8 100644 --- a/tests/mock_routers/relationships.py +++ b/tests/mock_routers/relationships.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException + from tests.mocks.mock_storage import mock_storage router = APIRouter(tags=["relations"]) @@ -36,5 +37,4 @@ def get_authors_for_book(book_id: int): @router.post("/relationships/genre-book") def add_genre_to_book(genre_id: int, book_id: int): - # For tests that need genre functionality return {"genre_id": genre_id, "book_id": book_id} diff --git a/tests/mocks/mock_session.py b/tests/mocks/mock_session.py index 01aa854..1d972cd 100644 --- a/tests/mocks/mock_session.py +++ b/tests/mocks/mock_session.py @@ -1,4 +1,5 @@ -from typing import Optional, List, Any +from typing import Any, List + from tests.mocks.mock_storage import mock_storage @@ -8,20 +9,13 @@ class MockSession: def __init__(self): self.storage = mock_storage - def add(self, obj: Any): - """Mock add - not needed for our implementation""" - pass + def add(self, obj: Any): ... - def commit(self): - """Mock commit - not needed for our implementation""" - pass + def commit(self): ... - def refresh(self, obj: Any): - """Mock refresh - not needed for our implementation""" - pass + def refresh(self, obj: Any): ... def get(self, model_class, pk: int): - """Mock get method to retrieve object by primary key""" if hasattr(model_class, "__name__"): model_name = model_class.__name__.lower() else: @@ -35,12 +29,9 @@ class MockSession: return self.storage.get_genre(pk) return None - def delete(self, obj: Any): - """Mock delete - handled in storage methods""" - pass + def delete(self, obj: Any): ... def exec(self, statement): - """Mock exec method for queries""" return MockResult([]) diff --git a/tests/mocks/mock_storage.py b/tests/mocks/mock_storage.py index 9a92fe7..81d8fba 100644 --- a/tests/mocks/mock_storage.py +++ b/tests/mocks/mock_storage.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Dict, List class MockStorage: @@ -15,7 +15,7 @@ class MockStorage: self.genre_id_counter = 1 def clear_all(self): - """Clear all data""" + """Очистка всех данных""" self.books.clear() self.authors.clear() self.genres.clear() @@ -33,7 +33,7 @@ class MockStorage: self.book_id_counter += 1 return book - def get_book(self, book_id: int) -> Optional[dict]: + def get_book(self, book_id: int) -> dict | None: return self.books.get(book_id) def get_all_books(self) -> List[dict]: @@ -42,9 +42,9 @@ class MockStorage: def update_book( self, book_id: int, - title: Optional[str] = None, - description: Optional[str] = None, - ) -> Optional[dict]: + title: str | None = None, + description: str | None = None, + ) -> dict | None: if book_id not in self.books: return None book = self.books[book_id] @@ -54,7 +54,7 @@ class MockStorage: book["description"] = description return book - def delete_book(self, book_id: int) -> Optional[dict]: + def delete_book(self, book_id: int) -> dict | None: if book_id not in self.books: return None book = self.books.pop(book_id) @@ -74,15 +74,15 @@ class MockStorage: self.author_id_counter += 1 return author - def get_author(self, author_id: int) -> Optional[dict]: + def get_author(self, author_id: int) -> dict | None: return self.authors.get(author_id) def get_all_authors(self) -> List[dict]: return list(self.authors.values()) def update_author( - self, author_id: int, name: Optional[str] = None - ) -> Optional[dict]: + self, author_id: int, name: str | None = None + ) -> dict | None: if author_id not in self.authors: return None author = self.authors[author_id] @@ -90,7 +90,7 @@ class MockStorage: author["name"] = name return author - def delete_author(self, author_id: int) -> Optional[dict]: + def delete_author(self, author_id: int) -> dict | None: if author_id not in self.authors: return None author = self.authors.pop(author_id) @@ -107,15 +107,13 @@ class MockStorage: self.genre_id_counter += 1 return genre - def get_genre(self, genre_id: int) -> Optional[dict]: + def get_genre(self, genre_id: int) -> dict | None: return self.genres.get(genre) def get_all_authors(self) -> List[dict]: return list(self.authors.values()) - def update_genre( - self, genre_id: int, name: Optional[str] = None - ) -> Optional[dict]: + def update_genre(self, genre_id: int, name: str | None = None) -> dict | None: if genre_id not in self.genres: return None genre = self.genres[genre_id] @@ -123,7 +121,7 @@ class MockStorage: genre["name"] = name return genre - def delete_genre(self, genre_id: int) -> Optional[dict]: + def delete_genre(self, genre_id: int) -> dict | None: if genre_id not in self.genres: return None genre = self.genres.pop(genre_id) diff --git a/tests/test_authors.py b/tests/test_authors.py index a24ffd5..f6336f3 100644 --- a/tests/test_authors.py +++ b/tests/test_authors.py @@ -1,5 +1,6 @@ import pytest from fastapi.testclient import TestClient + from tests.mock_app import mock_app from tests.mocks.mock_storage import mock_storage @@ -8,7 +9,6 @@ client = TestClient(mock_app) @pytest.fixture(autouse=True) def setup_database(): - """Clear mock storage before each test""" mock_storage.clear_all() yield mock_storage.clear_all() @@ -29,7 +29,6 @@ def test_create_author(): def test_list_authors(): - # First create an author client.post("/authors", json={"name": "Test Author"}) response = client.get("/authors") @@ -42,7 +41,6 @@ def test_list_authors(): def test_get_existing_author(): - # First create an author client.post("/authors", json={"name": "Test Author"}) response = client.get("/authors/1") @@ -63,7 +61,6 @@ def test_get_not_existing_author(): def test_update_author(): - # First create an author client.post("/authors", json={"name": "Test Author"}) response = client.get("/authors/1") @@ -84,10 +81,7 @@ def test_update_not_existing_author(): def test_delete_author(): - # First create an author client.post("/authors", json={"name": "Test Author"}) - - # Update it first client.put("/authors/1", json={"name": "Updated Author"}) response = client.get("/authors/1") diff --git a/tests/test_books.py b/tests/test_books.py index 56a3075..d6e5720 100644 --- a/tests/test_books.py +++ b/tests/test_books.py @@ -1,5 +1,6 @@ import pytest from fastapi.testclient import TestClient + from tests.mock_app import mock_app from tests.mocks.mock_storage import mock_storage @@ -8,7 +9,6 @@ client = TestClient(mock_app) @pytest.fixture(autouse=True) def setup_database(): - """Clear mock storage before each test""" mock_storage.clear_all() yield mock_storage.clear_all() @@ -35,9 +35,7 @@ def test_create_book(): def test_list_books(): - # First create a book - client.post( - "/books", json={"title": "Test Book", "description": "Test Description"} + client.post("/books", json={"title": "Test Book", "description": "Test Description"} ) response = client.get("/books") @@ -50,9 +48,7 @@ def test_list_books(): def test_get_existing_book(): - # First create a book - client.post( - "/books", json={"title": "Test Book", "description": "Test Description"} + client.post("/books", json={"title": "Test Book", "description": "Test Description"} ) response = client.get("/books/1") @@ -74,9 +70,7 @@ def test_get_not_existing_book(): def test_update_book(): - # First create a book - client.post( - "/books", json={"title": "Test Book", "description": "Test Description"} + client.post("/books", json={"title": "Test Book", "description": "Test Description"} ) response = client.get("/books/1") @@ -102,14 +96,8 @@ def test_update_not_existing_book(): def test_delete_book(): - # First create a book - client.post( - "/books", json={"title": "Test Book", "description": "Test Description"} - ) - - # Update it first - client.put( - "/books/1", json={"title": "Updated Book", "description": "Updated Description"} + client.post("/books", json={"title": "Test Book", "description": "Test Description"}) + client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"} ) response = client.get("/books/1") diff --git a/tests/test_misc.py b/tests/test_misc.py index f1f7d44..f24c78e 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,6 +1,8 @@ -import pytest from datetime import datetime + +import pytest from fastapi.testclient import TestClient + from tests.mock_app import mock_app from tests.mocks.mock_storage import mock_storage @@ -9,20 +11,15 @@ client = TestClient(mock_app) @pytest.fixture(autouse=True) def setup_database(): - """Setup and cleanup mock database for each test""" - # Clear data before each test mock_storage.clear_all() yield - # Clear data after each test (optional, but good practice) mock_storage.clear_all() -# Test the main page of the application def test_main_page(): - response = client.get("/") # Send GET request to the main page + response = client.get("/api") try: - content = response.content.decode("utf-8") # Decode response content - # Find indices of key elements in the content + content = response.content.decode("utf-8") title_idx = content.index("Welcome to ") description_idx = content.index("Description: ") version_idx = content.index("Version: ") @@ -38,25 +35,16 @@ def test_main_page(): assert content[time_idx + 1] != "<", "Time not provided" assert content[status_idx + 1] != "<", "Status not provided" except Exception as e: - print(f"Error: {e}") # Print error if an exception occurs - assert False, "Unexpected error" # Force test failure on unexpected error + print(f"Error: {e}") + assert False, "Unexpected error" -# Test application info endpoint def test_app_info_test(): - response = client.get("/api/info") # Send GET request to the info endpoint + response = client.get("/api/info") assert response.status_code == 200, "Invalid response status" assert response.json()["status"] == "ok", "Status not ok" assert response.json()["app_info"]["title"] != "", "Title not provided" assert response.json()["app_info"]["description"] != "", "Description not provided" assert response.json()["app_info"]["version"] != "", "Version not provided" - # Check time difference - assert ( - 0 - < ( - datetime.now() - datetime.fromisoformat(response.json()["server_time"]) - ).total_seconds() - ), "Negative time difference" - assert ( - datetime.now() - datetime.fromisoformat(response.json()["server_time"]) - ).total_seconds() < 1, "Time difference too large" + assert (0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds()), "Negative time difference" + assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large" diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 0ff1004..01cd44a 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -1,5 +1,6 @@ import pytest from fastapi.testclient import TestClient + from tests.mock_app import mock_app from tests.mocks.mock_storage import mock_storage @@ -8,7 +9,6 @@ client = TestClient(mock_app) @pytest.fixture(autouse=True) def setup_database(): - """Clear mock storage before each test""" mock_storage.clear_all() yield mock_storage.clear_all() @@ -30,28 +30,18 @@ def make_genrebook_relationship(genre_id, book_id): def test_prepare_data(): - # Create books - assert client.post( - "/books", json={"title": "Test Book 1", "description": "Test Description 1"} - ).status_code == 200 - assert client.post( - "/books", json={"title": "Test Book 2", "description": "Test Description 2"} - ).status_code == 200 - assert client.post( - "/books", json={"title": "Test Book 3", "description": "Test Description 3"} - ).status_code == 200 + assert (client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"}).status_code == 200) + assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200) + assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200) - # Create authors assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200 assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200 assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200 - # Create genres assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200 assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200 assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200 - # Create relationships make_authorbook_relationship(1, 1) make_authorbook_relationship(2, 1) make_authorbook_relationship(1, 2) @@ -63,8 +53,8 @@ def test_prepare_data(): make_genrebook_relationship(2, 3) make_genrebook_relationship(3, 3) + def test_get_book_authors(): - # Setup test data test_prepare_data() response1 = client.get("/books/1/authors") @@ -91,7 +81,6 @@ def test_get_book_authors(): def test_get_author_books(): - # Setup test data test_prepare_data() response1 = client.get("/authors/1/books")