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 0000000..e0633b3 Binary files /dev/null and b/library_service/static/dited.regular.ttf differ diff --git a/library_service/static/favicon.svg b/library_service/static/favicon.svg new file mode 100644 index 0000000..d1fc9b0 --- /dev/null +++ b/library_service/static/favicon.svg @@ -0,0 +1,62 @@ +『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 0000000..9781090 Binary files /dev/null and b/library_service/static/novem.regular.ttf differ 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 +
+
+
+ + + + 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")