Добавление авторизации и фронтэнда

This commit is contained in:
2025-12-18 18:52:09 +03:00
parent 2c24f66de0
commit 756e941f99
55 changed files with 2314 additions and 577 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
POSTGRES_HOST = "localhost"
POSTGRES_PORT = "5432"
POSTGRES_USER = "postgres" POSTGRES_USER = "postgres"
POSTGRES_PASSWORD = "postgres" POSTGRES_PASSWORD = "postgres"
POSTGRES_DB = "postgres" POSTGRES_DB = "lib"
POSTGRES_SERVER = "db"
+2 -2
View File
@@ -17,8 +17,8 @@ services:
- .:/code - .:/code
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: # depends_on:
- db # - db
tests: tests:
container_name: tests container_name: tests
+210
View File
@@ -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"])
-1
View File
@@ -1 +0,0 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="80" height="80" rx="4" ry="4" fill="#fff" stroke="#000" stroke-width="2"/><rect x="20" y="15" width="60" height="70" rx="10" ry="10"/><rect x="20" y="15" width="60" height="66" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="62" rx="10" ry="10"/><rect x="20" y="15" width="60" height="60" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="56" rx="10" ry="10"/><rect x="20" y="15" width="60" height="54" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="50" rx="10" ry="10"/><rect x="20" y="15" width="60" height="48" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="44" rx="10" ry="10"/><rect x="22" y="21" width="2" height="58" rx="10" ry="10" stroke="#000" stroke-width="4"/><rect x="22" y="55" width="4" height="26" rx="2" ry="15"/><text x="50" y="40" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" stroke="#fff" stroke-width=".5" fill="none" font-size="20">『LiB』</text></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+5 -4
View File
@@ -1,12 +1,12 @@
"""Основной модуль"""
from contextlib import asynccontextmanager
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from toml import load
from .settings import engine, get_app
from .routers import api_router from .routers import api_router
from .routers.misc import get_info from .settings import engine, get_app
app = get_app() app = get_app()
alembic_cfg = Config("alembic.ini") alembic_cfg = Config("alembic.ini")
@@ -14,6 +14,7 @@ alembic_cfg = Config("alembic.ini")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Жизененый цикл сервиса"""
print("[+] Initializing...") print("[+] Initializing...")
# Настройка базы данных # Настройка базы данных
+2 -1
View File
@@ -1,2 +1,3 @@
from .dto import * """Модуль моделей"""
from .db import * from .db import *
from .dto import *
+7 -10
View File
@@ -1,25 +1,22 @@
"""Модуль моделей для базы данных"""
from .author import Author from .author import Author
from .book import Book from .book import Book
from .genre import Genre from .genre import Genre
from .role import Role
from .user import User
from .links import ( from .links import (
AuthorBookLink, AuthorBookLink,
GenreBookLink, GenreBookLink,
AuthorWithBooks, UserRoleLink
BookWithAuthors,
GenreWithBooks,
BookWithGenres,
BookWithAuthorsAndGenres,
) )
__all__ = [ __all__ = [
"Author", "Author",
"Book", "Book",
"Genre", "Genre",
"Role",
"User",
"AuthorBookLink", "AuthorBookLink",
"AuthorWithBooks",
"BookWithAuthors",
"GenreBookLink", "GenreBookLink",
"GenreWithBooks", "UserRoleLink",
"BookWithGenres",
"BookWithAuthorsAndGenres",
] ]
+9 -5
View File
@@ -1,14 +1,18 @@
from typing import List, Optional, TYPE_CHECKING """Модуль DB-моделей авторов"""
from sqlmodel import SQLModel, Field, Relationship from typing import TYPE_CHECKING, List
from ..dto.author import AuthorBase
from .links import AuthorBookLink from sqlmodel import Field, Relationship
from library_service.models.dto.author import AuthorBase
from library_service.models.db.links import AuthorBookLink
if TYPE_CHECKING: if TYPE_CHECKING:
from .book import Book from .book import Book
class Author(AuthorBase, table=True): 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( books: List["Book"] = Relationship(
back_populates="authors", link_model=AuthorBookLink back_populates="authors", link_model=AuthorBookLink
) )
+9 -5
View File
@@ -1,7 +1,10 @@
from typing import List, Optional, TYPE_CHECKING """Модуль DB-моделей книг"""
from sqlmodel import SQLModel, Field, Relationship from typing import TYPE_CHECKING, List
from ..dto.book import BookBase
from .links import AuthorBookLink, GenreBookLink 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: if TYPE_CHECKING:
from .author import Author from .author import Author
@@ -9,7 +12,8 @@ if TYPE_CHECKING:
class Book(BookBase, table=True): 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( authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink back_populates="books", link_model=AuthorBookLink
) )
+9 -5
View File
@@ -1,14 +1,18 @@
from typing import List, Optional, TYPE_CHECKING """Модуль DB-моделей жанров"""
from sqlmodel import SQLModel, Field, Relationship from typing import TYPE_CHECKING, List
from ..dto.genre import GenreBase
from .links import GenreBookLink from sqlmodel import Field, Relationship
from library_service.models.dto.genre import GenreBase
from library_service.models.db.links import GenreBookLink
if TYPE_CHECKING: if TYPE_CHECKING:
from .book import Book from .book import Book
class Genre(GenreBase, table=True): 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( books: List["Book"] = Relationship(
back_populates="genres", link_model=GenreBookLink back_populates="genres", link_model=GenreBookLink
) )
+8 -23
View File
@@ -1,12 +1,9 @@
"""Модуль связей между сущностями в БД"""
from sqlmodel import SQLModel, Field 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): class AuthorBookLink(SQLModel, table=True):
"""Модель связи автора и книги"""
author_id: int | None = Field( author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True default=None, foreign_key="author.id", primary_key=True
) )
@@ -14,26 +11,14 @@ class AuthorBookLink(SQLModel, table=True):
class GenreBookLink(SQLModel, table=True): class GenreBookLink(SQLModel, table=True):
"""Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=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) book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class AuthorWithBooks(AuthorRead): class UserRoleLink(SQLModel, table=True):
books: List[BookRead] = Field(default_factory=list) """Модель связи роли и пользователя"""
__tablename__ = "user_roles"
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
class BookWithAuthors(BookRead): role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
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)
+20
View File
@@ -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)
+28
View File
@@ -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)
+22 -4
View File
@@ -1,7 +1,12 @@
from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList """Модуль DTO-моделей"""
from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList 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__ = [ __all__ = [
"AuthorBase", "AuthorBase",
@@ -14,9 +19,22 @@ __all__ = [
"BookUpdate", "BookUpdate",
"BookRead", "BookRead",
"BookList", "BookList",
"BookFilteredList",
"GenreBase", "GenreBase",
"GenreCreate", "GenreCreate",
"GenreUpdate", "GenreUpdate",
"GenreRead", "GenreRead",
"GenreList", "GenreList",
"RoleBase",
"RoleCreate",
"RoleUpdate",
"RoleRead",
"RoleList",
"Token",
"TokenData",
"UserBase",
"UserCreate",
"UserRead",
"UserUpdate",
"UserLogin",
] ]
+10 -3
View File
@@ -1,9 +1,12 @@
from sqlmodel import SQLModel """Модуль DTO-моделей авторов"""
from typing import List
from pydantic import ConfigDict from pydantic import ConfigDict
from typing import Optional, List from sqlmodel import SQLModel
class AuthorBase(SQLModel): class AuthorBase(SQLModel):
"""Базовая модель автора"""
name: str name: str
model_config = ConfigDict( # pyright: ignore model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class AuthorBase(SQLModel):
class AuthorCreate(AuthorBase): class AuthorCreate(AuthorBase):
"""Модель автора для создания"""
pass pass
class AuthorUpdate(SQLModel): class AuthorUpdate(SQLModel):
name: Optional[str] = None """Модель автора для обновления"""
name: str | None = None
class AuthorRead(AuthorBase): class AuthorRead(AuthorBase):
"""Модель автора для чтения"""
id: int id: int
class AuthorList(SQLModel): class AuthorList(SQLModel):
"""Список авторов"""
authors: List[AuthorRead] authors: List[AuthorRead]
total: int total: int
+14 -4
View File
@@ -1,9 +1,15 @@
from sqlmodel import SQLModel """Модуль DTO-моделей книг"""
from typing import List, TYPE_CHECKING
from pydantic import ConfigDict from pydantic import ConfigDict
from typing import Optional, List from sqlmodel import SQLModel
if TYPE_CHECKING:
from .combined import BookWithAuthorsAndGenres
class BookBase(SQLModel): class BookBase(SQLModel):
"""Базовая модель книги"""
title: str title: str
description: str description: str
@@ -15,18 +21,22 @@ class BookBase(SQLModel):
class BookCreate(BookBase): class BookCreate(BookBase):
"""Модель книги для создания"""
pass pass
class BookUpdate(SQLModel): class BookUpdate(SQLModel):
title: Optional[str] = None """Модель книги для обновления"""
description: Optional[str] = None title: str | None = None
description: str | None = None
class BookRead(BookBase): class BookRead(BookBase):
"""Модель книги для чтения"""
id: int id: int
class BookList(SQLModel): class BookList(SQLModel):
"""Список книг"""
books: List[BookRead] books: List[BookRead]
total: int total: int
+53
View File
@@ -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
+10 -3
View File
@@ -1,9 +1,12 @@
from sqlmodel import SQLModel """Модуль DTO-моделей жанров"""
from typing import List
from pydantic import ConfigDict from pydantic import ConfigDict
from typing import Optional, List from sqlmodel import SQLModel
class GenreBase(SQLModel): class GenreBase(SQLModel):
"""Базовая модель жанра"""
name: str name: str
model_config = ConfigDict( # pyright: ignore model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase): class GenreCreate(GenreBase):
"""Модель жанра для создания"""
pass pass
class GenreUpdate(SQLModel): class GenreUpdate(SQLModel):
name: Optional[str] = None """Модель жанра для обновления"""
name: str | None = None
class GenreRead(GenreBase): class GenreRead(GenreBase):
"""Модель жанра для чтения"""
id: int id: int
class GenreList(SQLModel): class GenreList(SQLModel):
"""Списко жанров"""
genres: List[GenreRead] genres: List[GenreRead]
total: int total: int
+31
View File
@@ -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
+15
View File
@@ -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
+61
View File
@@ -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
+3
View File
@@ -1,5 +1,7 @@
"""Модуль объединения роутеров"""
from fastapi import APIRouter from fastapi import APIRouter
from .auth import router as auth_router
from .authors import router as authors_router from .authors import router as authors_router
from .books import router as books_router from .books import router as books_router
from .genres import router as genres_router from .genres import router as genres_router
@@ -9,6 +11,7 @@ from .misc import router as misc_router
api_router = APIRouter() api_router = APIRouter()
# Подключение всех маршрутов # Подключение всех маршрутов
api_router.include_router(auth_router)
api_router.include_router(authors_router) api_router.include_router(authors_router)
api_router.include_router(books_router) api_router.include_router(books_router)
api_router.include_router(genres_router) api_router.include_router(genres_router)
+156
View File
@@ -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
]
+18 -16
View File
@@ -1,28 +1,28 @@
from fastapi import APIRouter, Path, Depends, HTTPException """Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireAuth
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import ( from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
AuthorUpdate,
AuthorRead,
AuthorList,
BookRead,
)
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
# Create an author
@router.post( @router.post(
"/", "/",
response_model=AuthorRead, response_model=AuthorRead,
summary="Создать автора", summary="Создать автора",
description="Добавляет автора в систему", 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()) db_author = Author(**author.model_dump())
session.add(db_author) session.add(db_author)
session.commit() session.commit()
@@ -30,7 +30,6 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
return AuthorRead(**db_author.model_dump()) return AuthorRead(**db_author.model_dump())
# Read authors
@router.get( @router.get(
"/", "/",
response_model=AuthorList, response_model=AuthorList,
@@ -38,6 +37,7 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
description="Возвращает список всех авторов в системе", description="Возвращает список всех авторов в системе",
) )
def read_authors(session: Session = Depends(get_session)): def read_authors(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка авторов"""
authors = session.exec(select(Author)).all() authors = session.exec(select(Author)).all()
return AuthorList( return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors], 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( @router.get(
"/{author_id}", "/{author_id}",
response_model=AuthorWithBooks, response_model=AuthorWithBooks,
@@ -56,6 +55,7 @@ def get_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного автора"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -72,7 +72,6 @@ def get_author(
return AuthorWithBooks(**author_data) return AuthorWithBooks(**author_data)
# Update an author
@router.put( @router.put(
"/{author_id}", "/{author_id}",
response_model=AuthorRead, response_model=AuthorRead,
@@ -80,10 +79,12 @@ def get_author(
description="Обновляет информацию об авторе в системе", description="Обновляет информацию об авторе в системе",
) )
def update_author( def update_author(
current_user: RequireAuth,
author: AuthorUpdate, author: AuthorUpdate,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления автора"""
db_author = session.get(Author, author_id) db_author = session.get(Author, author_id)
if not db_author: if not db_author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -97,7 +98,6 @@ def update_author(
return AuthorRead(**db_author.model_dump()) return AuthorRead(**db_author.model_dump())
# Delete an author
@router.delete( @router.delete(
"/{author_id}", "/{author_id}",
response_model=AuthorRead, response_model=AuthorRead,
@@ -105,9 +105,11 @@ def update_author(
description="Удаляет автора из системы", description="Удаляет автора из системы",
) )
def delete_author( def delete_author(
current_user: RequireAuth,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления автора"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
+70 -17
View File
@@ -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.settings import get_session
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import ( from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate
AuthorRead, from library_service.models.dto.combined import (
BookList, BookWithAuthorsAndGenres,
BookRead, BookFilteredList
BookCreate,
BookUpdate,
) )
router = APIRouter(prefix="/books", tags=["books"]) router = APIRouter(prefix="/books", tags=["books"])
# Create a book
@router.post( @router.post(
"/", "/",
response_model=Book, response_model=Book,
summary="Создать книгу", summary="Создать книгу",
description="Добавляет книгу в систему", 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()) db_book = Book(**book.model_dump())
session.add(db_book) session.add(db_book)
session.commit() session.commit()
@@ -31,7 +34,6 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
return BookRead(**db_book.model_dump()) return BookRead(**db_book.model_dump())
# Read books
@router.get( @router.get(
"/", "/",
response_model=BookList, response_model=BookList,
@@ -39,13 +41,13 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
description="Возвращает список всех книг в системе", description="Возвращает список всех книг в системе",
) )
def read_books(session: Session = Depends(get_session)): def read_books(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка книг"""
books = session.exec(select(Book)).all() books = session.exec(select(Book)).all()
return BookList( return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books) books=[BookRead(**book.model_dump()) for book in books], total=len(books)
) )
# Read a book with their authors and genres
@router.get( @router.get(
"/{book_id}", "/{book_id}",
response_model=BookWithAuthorsAndGenres, response_model=BookWithAuthorsAndGenres,
@@ -56,6 +58,7 @@ def get_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретной книги"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
@@ -76,10 +79,9 @@ def get_book(
book_data["authors"] = author_reads book_data["authors"] = author_reads
book_data["genres"] = genre_reads book_data["genres"] = genre_reads
return BookWithAuthors(**book_data) return BookWithAuthorsAndGenres(**book_data)
# Update a book
@router.put( @router.put(
"/{book_id}", "/{book_id}",
response_model=Book, response_model=Book,
@@ -87,10 +89,12 @@ def get_book(
description="Обновляет информацию о книге в системе", description="Обновляет информацию о книге в системе",
) )
def update_book( def update_book(
current_user: RequireAuth,
book: BookUpdate, book: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления книги"""
db_book = session.get(Book, book_id) db_book = session.get(Book, book_id)
if not db_book: if not db_book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
@@ -102,7 +106,6 @@ def update_book(
return db_book return db_book
# Delete a book
@router.delete( @router.delete(
"/{book_id}", "/{book_id}",
response_model=BookRead, response_model=BookRead,
@@ -110,9 +113,11 @@ def update_book(
description="Удаляет книгу их системы", description="Удаляет книгу их системы",
) )
def delete_book( def delete_book(
current_user: RequireAuth,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления книги"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
@@ -122,3 +127,51 @@ def delete_book(
session.delete(book) session.delete(book)
session.commit() session.commit()
return book_read 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)
+22 -16
View File
@@ -1,28 +1,28 @@
from fastapi import APIRouter, Path, Depends, HTTPException """Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select 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.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"]) router = APIRouter(prefix="/genres", tags=["genres"])
# Create a genre # Создание жанра
@router.post( @router.post(
"/", "/",
response_model=GenreRead, response_model=GenreRead,
summary="Создать жанр", summary="Создать жанр",
description="Добавляет жанр книг в систему", 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()) db_genre = Genre(**genre.model_dump())
session.add(db_genre) session.add(db_genre)
session.commit() session.commit()
@@ -30,7 +30,7 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
return GenreRead(**db_genre.model_dump()) return GenreRead(**db_genre.model_dump())
# Read genres # Чтение жанров
@router.get( @router.get(
"/", "/",
response_model=GenreList, response_model=GenreList,
@@ -38,13 +38,14 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
description="Возвращает список всех жанров в системе", description="Возвращает список всех жанров в системе",
) )
def read_genres(session: Session = Depends(get_session)): def read_genres(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка жанров"""
genres = session.exec(select(Genre)).all() genres = session.exec(select(Genre)).all()
return GenreList( return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres) genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
) )
# Read a genre with their books # Чтение жанра с его книгами
@router.get( @router.get(
"/{genre_id}", "/{genre_id}",
response_model=GenreWithBooks, response_model=GenreWithBooks,
@@ -55,6 +56,7 @@ def get_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного жанра"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -71,7 +73,7 @@ def get_genre(
return GenreWithBooks(**genre_data) return GenreWithBooks(**genre_data)
# Update a genre # Обновление жанра
@router.put( @router.put(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
@@ -79,10 +81,12 @@ def get_genre(
description="Обновляет информацию о жанре в системе", description="Обновляет информацию о жанре в системе",
) )
def update_genre( def update_genre(
current_user: RequireAuth,
genre: GenreUpdate, genre: GenreUpdate,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления жанра"""
db_genre = session.get(Genre, genre_id) db_genre = session.get(Genre, genre_id)
if not db_genre: if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -96,7 +100,7 @@ def update_genre(
return GenreRead(**db_genre.model_dump()) return GenreRead(**db_genre.model_dump())
# Delete a genre # Удаление жанра
@router.delete( @router.delete(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
@@ -104,9 +108,11 @@ def update_genre(
description="Удаляет автора из системы", description="Удаляет автора из системы",
) )
def delete_genre( def delete_genre(
current_user: RequireAuth,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления жанра"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
+34 -13
View File
@@ -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 datetime import datetime
from pathlib import Path
from typing import Dict 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 from library_service.settings import get_app
# Загрузка шаблонов
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(tags=["misc"]) router = APIRouter(tags=["misc"])
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
# Форматированная информация о приложении
def get_info(app) -> Dict: def get_info(app) -> Dict:
"""Форматированная информация о приложении"""
return { return {
"status": "ok", "status": "ok",
"app_info": { "app_info": {
@@ -27,29 +28,49 @@ def get_info(app) -> Dict:
} }
# Эндпоинт главной страницы
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)): async def root(request: Request, app=Depends(get_app)):
"""Эндпоинт главной страницы"""
return templates.TemplateResponse(request, "index.html", get_info(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) @router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon(): def redirect_favicon():
"""Редирект иконки вкладки"""
return RedirectResponse("/favicon.svg") return RedirectResponse("/favicon.svg")
# Эндпоинт иконки вкладки
@router.get("/favicon.svg", include_in_schema=False) @router.get("/favicon.svg", include_in_schema=False)
async def favicon(): 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( @router.get(
"/api/info", "/api/info",
summary="Информация о сервисе", summary="Информация о сервисе",
description="Возвращает информацию о системе", description="Возвращает информацию о системе",
) )
async def api_info(app=Depends(get_app)): async def api_info(app=Depends(get_app)):
"""Эндпоинт информации об API"""
return JSONResponse(content=get_info(app)) return JSONResponse(content=get_info(app))
+121 -119
View File
@@ -1,15 +1,82 @@
"""Модуль работы со связями"""
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List, Dict
from library_service.settings import get_session from library_service.auth import RequireAuth
from library_service.models.db import Author, Book, Genre, AuthorBookLink, GenreBookLink from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
from library_service.models.dto import AuthorRead, BookRead, GenreRead from library_service.models.dto import AuthorRead, BookRead, GenreRead
from library_service.settings import get_session
router = APIRouter(tags=["relations"]) 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( @router.post(
"/relationships/author-book", "/relationships/author-book",
response_model=AuthorBookLink, response_model=AuthorBookLink,
@@ -17,33 +84,19 @@ router = APIRouter(tags=["relations"])
description="Добавляет связь между автором и книгой в систему", description="Добавляет связь между автором и книгой в систему",
) )
def add_author_to_book( 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: check_entity_exists(session, Author, author_id, "Author")
raise HTTPException(status_code=404, detail="Author not found") check_entity_exists(session, Book, book_id, "Book")
book = session.get(Book, book_id) return add_relationship(session, AuthorBookLink,
if not book: author_id, "author_id", book_id, "book_id", "Relationship already exists")
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
# Remove author from book
@router.delete( @router.delete(
"/relationships/author-book", "/relationships/author-book",
response_model=Dict[str, str], response_model=Dict[str, str],
@@ -51,23 +104,16 @@ def add_author_to_book(
description="Удаляет связь между автором и книгой в системе", description="Удаляет связь между автором и книгой в системе",
) )
def remove_author_from_book( 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) return remove_relationship(session, AuthorBookLink,
.where(AuthorBookLink.author_id == author_id) author_id, "author_id", book_id, "book_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"}
# Get author's books
@router.get( @router.get(
"/authors/{author_id}/books/", "/authors/{author_id}/books/",
response_model=List[BookRead], response_model=List[BookRead],
@@ -75,18 +121,12 @@ def remove_author_from_book(
description="Возвращает все книги в системе, написанные автором", description="Возвращает все книги в системе, написанные автором",
) )
def get_books_for_author(author_id: int, session: Session = Depends(get_session)): def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
author = session.get(Author, author_id) """Эндпоинт получения книг, написанных автором"""
if not author: return get_related(session,
raise HTTPException(status_code=404, detail="Author not found") Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead)
books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
# Get book's authors
@router.get( @router.get(
"/books/{book_id}/authors/", "/books/{book_id}/authors/",
response_model=List[AuthorRead], response_model=List[AuthorRead],
@@ -94,18 +134,12 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
description="Возвращает всех авторов книги в системе", description="Возвращает всех авторов книги в системе",
) )
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id) """Эндпоинт получения авторов книги"""
if not book: return get_related(session,
raise HTTPException(status_code=404, detail="Book not found") Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead)
authors = session.exec(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
).all()
return [AuthorRead(**author.model_dump()) for author in authors]
# Add genre to book
@router.post( @router.post(
"/relationships/genre-book", "/relationships/genre-book",
response_model=GenreBookLink, response_model=GenreBookLink,
@@ -113,33 +147,19 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
description="Добавляет связь между книгой и жанром в систему", description="Добавляет связь между книгой и жанром в систему",
) )
def add_genre_to_book( 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: check_entity_exists(session, Genre, genre_id, "Genre")
raise HTTPException(status_code=404, detail="Genre not found") check_entity_exists(session, Book, book_id, "Book")
book = session.get(Book, book_id) return add_relationship(session, GenreBookLink,
if not book: genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
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
# Remove author from book
@router.delete( @router.delete(
"/relationships/genre-book", "/relationships/genre-book",
response_model=Dict[str, str], response_model=Dict[str, str],
@@ -147,55 +167,37 @@ def add_genre_to_book(
description="Удаляет связь между жанром и книгой в системе", description="Удаляет связь между жанром и книгой в системе",
) )
def remove_genre_from_book( 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) return remove_relationship(session, GenreBookLink,
.where(GenreBookLink.genre_id == genre_id) genre_id, "genre_id", book_id, "book_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"}
# Get genre's books
@router.get( @router.get(
"/genres/{author_id}/books/", "/genres/{genre_id}/books/",
response_model=List[BookRead], response_model=List[BookRead],
summary="Получить книги, написанные в жанре", summary="Получить книги, написанные в жанре",
description="Возвращает все книги в системе в этом жанре", description="Возвращает все книги в системе в этом жанре",
) )
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
genre = session.get(Genre, genre_id) """Эндпоинт получения книг с жанром"""
if not genre: return get_related(session,
raise HTTPException(status_code=404, detail="Genre not found") Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead)
books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
# Get book's genres
@router.get( @router.get(
"/books/{book_id}/genres/", "/books/{book_id}/genres/",
response_model=List[GenreRead], response_model=List[GenreRead],
summary="Получить жанры книги", summary="Получить жанры книги",
description="Возвращает все жанры книги в системе", description="Возвращает все жанры книги в системе",
) )
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id) """Эндпоинт получения жанров книги"""
if not book: return get_related(session,
raise HTTPException(status_code=404, detail="Book not found") Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead)
genres = session.exec(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
).all()
return [GenreRead(**author.model_dump()) for genre in genres]
+18 -11
View File
@@ -1,59 +1,66 @@
"""Модуль настроек проекта"""
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI from fastapi import FastAPI
from sqlmodel import create_engine, SQLModel, Session from sqlmodel import Session, create_engine
from toml import load from toml import load
load_dotenv() load_dotenv()
with open("pyproject.toml") as f: with open("pyproject.toml", 'r', encoding='utf-8') as f:
config = load(f) config = load(f)
# Dependency to get the FastAPI application instance
def get_app() -> FastAPI: def get_app() -> FastAPI:
"""Dependency, для получение экземплярра FastAPI application"""
return FastAPI( return FastAPI(
title=config["tool"]["poetry"]["name"], title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"], description=config["tool"]["poetry"]["description"],
version=config["tool"]["poetry"]["version"], version=config["tool"]["poetry"]["version"],
openapi_tags=[ openapi_tags=[
{
"name": "authentication",
"description": "Авторизация пользователя."
},
{ {
"name": "authors", "name": "authors",
"description": "Operations with authors.", "description": "Действия с авторами.",
}, },
{ {
"name": "books", "name": "books",
"description": "Operations with books.", "description": "Действия с книгами.",
}, },
{ {
"name": "genres", "name": "genres",
"description": "Operations with genres.", "description": "Действия с жанрами.",
}, },
{ {
"name": "relations", "name": "relations",
"description": "Operations with relations.", "description": "Действия с связями.",
}, },
{ {
"name": "misc", "name": "misc",
"description": "Miscellaneous operations.", "description": "Прочие.",
}, },
], ],
) )
HOST = os.getenv("POSTGRES_HOST")
PORT = os.getenv("POSTGRES_PORT")
USER = os.getenv("POSTGRES_USER") USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD") PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB") DATABASE = os.getenv("POSTGRES_DB")
HOST = os.getenv("POSTGRES_SERVER")
if not USER or not PASSWORD or not DATABASE or not HOST: if not USER or not PASSWORD or not DATABASE or not HOST:
raise ValueError("Missing environment variables") 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) engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
# Dependency to get a database session
def get_session(): def get_session():
"""Dependency, для получение сессии БД"""
with Session(engine) as session: with Session(engine) as session:
yield session yield session
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
fill="#000000"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect
x="10"
y="10"
width="80"
height="80"
rx="4"
ry="4"
fill="#fff"
stroke="#000"
stroke-width="2"
/><rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="66"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="60"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="54"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="48"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" /><rect
x="22"
y="21"
width="2"
height="58"
rx="10"
ry="10"
stroke="#000"
stroke-width="4"
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" /><text
x="50"
y="40"
text-anchor="middle"
dominant-baseline="middle"
alignment-baseline="middle"
stroke="#fff"
stroke-width=".5"
fill="none"
font-size="20"
>『LiB』</text></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+59
View File
@@ -0,0 +1,59 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="66"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="60"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="54"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="48"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" />
<rect
x="22"
y="21"
width="2"
height="58"
rx="10"
ry="10"
stroke="#000"
stroke-width="4"
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" />
<text
x="50"
y="40"
text-anchor="middle"
dominant-baseline="middle"
alignment-baseline="middle"
stroke="#fff"
stroke-width=".5"
fill="none"
font-size="20"
>『LiB』</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+135
View File
@@ -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 = `
<label class="custom-checkbox flex items-center">
<input type="checkbox" />
<span class="checkmark"></span>
${genre.name}
</label>
`;
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}
<button type="button" class="ml-1 inline-flex items-center p-0.5 text-sm text-gray-400 bg-transparent rounded-sm hover:bg-gray-200 hover:text-gray-900" data-author="${author}">
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Remove author</span>
</button>
`;
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();
}
+77
View File
@@ -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);
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 15px 0;
}
a {
display: inline-block;
padding: 8px 15px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
</style>
</head>
<body>
<img src="/favicon.ico" />
<h1>Welcome to {{ app_info.title }}!</h1>
<p>Description: {{ app_info.description }}</p>
<p>Version: {{ app_info.version }}</p>
<p>Current Time: {{ server_time }}</p>
<p>Status: {{ status }}</p>
<ul>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
</ul>
</body>
</html>
+130 -52
View File
@@ -3,58 +3,136 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title> <title>LiB</title>
<style> <script src="https://cdn.tailwindcss.com"></script>
body { <link rel="stylesheet" href="static/styles.css" />
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 15px 0;
}
a {
display: inline-block;
padding: 8px 15px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
</style>
</head> </head>
<body> <body class="flex flex-col min-h-screen bg-gray-100">
<img src="/favicon.ico" /> <!-- Header -->
<h1>Welcome to {{ app_info.title }}!</h1> <header class="bg-gray-500 text-white p-4 shadow-md">
<p>Description: {{ app_info.description }}</p> <div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<p>Version: {{ app_info.version }}</p> <a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<p>Current Time: {{ server_time }}</p> <img class="invert" src="static/logo.svg" />
<p>Status: {{ status }}</p> <h1 class="text-2xl font-bold">LiB</h1>
<ul> </a>
<li><a href="/docs">Swagger UI</a></li> <nav>
<li><a href="/redoc">ReDoc</a></li> <ul class="flex space-x-4">
<li> <li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a> <a href="/" class="hover:text-gray-200">Home</a>
</li> </li>
</ul> <li>
<a href="#" class="hover:text-gray-200">Products</a>
</li>
<li>
<a href="#" class="hover:text-gray-200">About</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<img class="max-w-6 h-auto invert" src="static/avatar.svg" />
</div>
</header>
<!-- Main -->
<div class="flex flex-1 mt-4 p-4">
<aside
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
>
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
<!-- Authors -->
<div class="mb-4">
<h3 class="font-medium mb-2">Авторы</h3>
<div class="relative">
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white"
id="selected-authors-container"
>
<input
type="text"
id="author-search-input"
class="flex-grow outline-none bg-transparent"
placeholder="Начните вводить..."
/>
</div>
<div
id="author-dropdown"
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto"
></div>
</div>
</div>
<!-- Genres -->
<div class="mb-4">
<h3 class="font-medium mb-2">Жанры</h3>
<ul id="genres-list"></ul>
</div>
<!-- Apply -->
<button
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200"
>
Применить фильтры
</button>
</aside>
<!-- Main Area -->
<main class="flex-1">
<!-- Book Card 1 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 1</h3>
<p class="text-gray-700 text-sm">
A short description of the product, highlighting its
key features and benefits.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$29.99</span
>
</div>
<!-- Book Card 2 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
<p class="text-gray-700 text-sm">
Another great product with amazing features. You'll
love it!
</p>
</div>
<span class="text-lg font-semibold text-blue-600"
>$49.99</span
>
</div>
<!-- Book Card 3 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
<p class="text-gray-700 text-sm">
This product is a must-have for every modern home.
High quality and durable.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$19.99</span
>
</div>
</main>
</div>
<!-- Footer -->
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2025 My Awesome Library. All rights reserved.</p>
</div>
</footer>
<script type="text/javascript" src="static/script.js"></script>
</body> </body>
</html> </html>
+2 -3
View File
@@ -1,8 +1,7 @@
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config from alembic import context
from sqlalchemy import pool from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel from sqlmodel import SQLModel
from library_service.settings import POSTGRES_DATABASE_URL from library_service.settings import POSTGRES_DATABASE_URL
+26 -18
View File
@@ -5,41 +5,49 @@ Revises: d266fdc61e99
Create Date: 2025-06-25 11:24:30.229418 Create Date: 2025-06-25 11:24:30.229418
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '9d7a43ac5dfc' revision: str = "9d7a43ac5dfc"
down_revision: Union[str, None] = 'd266fdc61e99' down_revision: Union[str, None] = "d266fdc61e99"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('genre', op.create_table(
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), "genre",
sa.Column('id', sa.Integer(), nullable=False), sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_genre_id'), 'genre', ['id'], unique=False) op.create_index(op.f("ix_genre_id"), "genre", ["id"], unique=False)
op.create_table('genrebooklink', op.create_table(
sa.Column('genre_id', sa.Integer(), nullable=False), "genrebooklink",
sa.Column('book_id', sa.Integer(), nullable=False), sa.Column("genre_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ), sa.Column("book_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ), sa.ForeignKeyConstraint(
sa.PrimaryKeyConstraint('genre_id', 'book_id') ["book_id"],
["book.id"],
),
sa.ForeignKeyConstraint(
["genre_id"],
["genre.id"],
),
sa.PrimaryKeyConstraint("genre_id", "book_id"),
) )
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('genrebooklink') op.drop_table("genrebooklink")
op.drop_index(op.f('ix_genre_id'), table_name='genre') op.drop_index(op.f("ix_genre_id"), table_name="genre")
op.drop_table('genre') op.drop_table("genre")
# ### end Alembic commands ### # ### end Alembic commands ###
+82
View File
@@ -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 ###
+35 -26
View File
@@ -1,19 +1,19 @@
"""init """init
Revision ID: d266fdc61e99 Revision ID: d266fdc61e99
Revises: Revises:
Create Date: 2025-05-27 18:04:22.279035 Create Date: 2025-05-27 18:04:22.279035
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'd266fdc61e99' revision: str = "d266fdc61e99"
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('author', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), "author",
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id') 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_index(op.f("ix_author_id"), "author", ["id"], unique=False)
op.create_table('book', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), "book",
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id') 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_index(op.f("ix_book_id"), "book", ["id"], unique=False)
op.create_table('authorbooklink', op.create_table(
sa.Column('author_id', sa.Integer(), nullable=False), "authorbooklink",
sa.Column('book_id', sa.Integer(), nullable=False), sa.Column("author_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ), sa.Column("book_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ), sa.ForeignKeyConstraint(
sa.PrimaryKeyConstraint('author_id', 'book_id') ["author_id"],
["author.id"],
),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.PrimaryKeyConstraint("author_id", "book_id"),
) )
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('authorbooklink') op.drop_table("authorbooklink")
op.drop_index(op.f('ix_book_id'), table_name='book') op.drop_index(op.f("ix_book_id"), table_name="book")
op.drop_table('book') op.drop_table("book")
op.drop_index(op.f('ix_author_id'), table_name='author') op.drop_index(op.f("ix_author_id"), table_name="author")
op.drop_table('author') op.drop_table("author")
# ### end Alembic commands ### # ### end Alembic commands ###
Generated
+608 -114
View File
@@ -1,5 +1,17 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. # 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]] [[package]]
name = "alembic" name = "alembic"
version = "1.16.2" 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\""] 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)"] 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]] [[package]]
name = "black" name = "black"
version = "25.1.0" version = "25.1.0"
@@ -110,6 +191,103 @@ files = [
{file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, {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]] [[package]]
name = "click" name = "click"
version = "8.2.1" version = "8.2.1"
@@ -138,6 +316,99 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {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]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.7.0" version = "2.7.0"
@@ -159,6 +430,25 @@ idna = ["idna (>=3.7)"]
trio = ["trio (>=0.23)"] trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"] 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]] [[package]]
name = "email-validator" name = "email-validator"
version = "2.2.0" version = "2.2.0"
@@ -439,6 +729,22 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, {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]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" version = "2.2.0"
@@ -585,6 +891,18 @@ files = [
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, {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]] [[package]]
name = "mdurl" name = "mdurl"
version = "0.1.2" version = "0.1.2"
@@ -703,6 +1021,27 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, {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]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.12.1" version = "0.12.1"
@@ -826,23 +1165,49 @@ files = [
{file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, {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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.7" version = "2.12.5"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
] ]
[package.dependencies] [package.dependencies]
annotated-types = ">=0.6.0" annotated-types = ">=0.6.0"
pydantic-core = "2.33.2" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
typing-extensions = ">=4.12.2" pydantic-core = "2.41.5"
typing-inspection = ">=0.4.0" typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras] [package.extras]
email = ["email-validator (>=2.0.0)"] email = ["email-validator (>=2.0.0)"]
@@ -850,115 +1215,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.33.2" version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
{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.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
{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.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
{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.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, {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] [package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" typing-extensions = ">=4.14.1"
[[package]] [[package]]
name = "pydantic-extra-types" name = "pydantic-extra-types"
@@ -1023,6 +1410,31 @@ files = [
[package.extras] [package.extras]
windows-terminal = ["colorama (>=0.4.6)"] 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]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.1" version = "8.4.1"
@@ -1045,6 +1457,25 @@ pygments = ">=2.7.2"
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "0.21.1" version = "0.21.1"
@@ -1060,6 +1491,30 @@ files = [
[package.extras] [package.extras]
cli = ["click (>=5.0)"] 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]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.20" version = "0.0.20"
@@ -1171,6 +1626,21 @@ click = ">=8.1.7"
rich = ">=13.7.1" rich = ">=13.7.1"
typing-extensions = ">=4.12.2" 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]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@@ -1183,6 +1653,18 @@ files = [
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, {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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@@ -1337,6 +1819,18 @@ files = [
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, {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]] [[package]]
name = "typer" name = "typer"
version = "0.16.0" version = "0.16.0"
@@ -1357,26 +1851,26 @@ typing-extensions = ">=3.7.4.3"
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.0" version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
[[package]] [[package]]
name = "typing-inspection" name = "typing-inspection"
version = "0.4.1" version = "0.4.2"
description = "Runtime typing introspection tools" description = "Runtime typing introspection tools"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
] ]
[package.dependencies] [package.dependencies]
@@ -1750,4 +2244,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "^3.13" python-versions = "^3.13"
content-hash = "a3555dac28547317a5d8d75507b5923d03b58412a988a260b627c079782bc15c" content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e"
+8 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "LibraryAPI" name = "LibraryAPI"
version = "0.1.3" version = "0.2.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"] authors = ["wowlikon"]
readme = "README.md" readme = "README.md"
@@ -16,10 +16,17 @@ sqlmodel = "^0.0.24"
uvicorn = "^0.34.3" uvicorn = "^0.34.3"
jinja2 = "^3.1.6" jinja2 = "^3.1.6"
toml = "^0.10.2" 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] [tool.poetry.group.dev.dependencies]
black = "^25.1.0" black = "^25.1.0"
pytest = "^8.4.1" pytest = "^8.4.1"
isort = "^7.0.0"
pytest-asyncio = "^1.3.0"
pylint = "^4.0.4"
[tool.poetry.requires-plugins] [tool.poetry.requires-plugins]
poetry-plugin-export = ">=1.8" poetry-plugin-export = ">=1.8"
+5 -4
View File
@@ -1,23 +1,24 @@
from fastapi import FastAPI from fastapi import FastAPI
from tests.mock_routers import books, authors, genres, relationships
from library_service.routers.misc import router as misc_router from library_service.routers.misc import router as misc_router
from tests.mock_routers import authors, books, genres, relationships
def create_mock_app() -> FastAPI: def create_mock_app() -> FastAPI:
"""Create FastAPI app with mock routers for testing""" """Создание FastAPI app с моками роутеров для тестов"""
app = FastAPI( app = FastAPI(
title="Library API Test", title="Library API Test",
description="Library API for testing without database", description="Library API for testing without database",
version="1.0.0", version="1.0.0",
) )
# Include mock routers # Подключение мок-роутеров
app.include_router(books.router) app.include_router(books.router)
app.include_router(authors.router) app.include_router(authors.router)
app.include_router(genres.router) app.include_router(genres.router)
app.include_router(relationships.router) app.include_router(relationships.router)
# Include real misc router (it doesn't use database) # Подключение реального misc роутера
app.include_router(misc_router) app.include_router(misc_router)
return app return app
+1
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
+1
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/books", tags=["books"]) router = APIRouter(prefix="/books", tags=["books"])
+1
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/genres", tags=["genres"]) router = APIRouter(prefix="/genres", tags=["genres"])
+1 -1
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
router = APIRouter(tags=["relations"]) router = APIRouter(tags=["relations"])
@@ -36,5 +37,4 @@ def get_authors_for_book(book_id: int):
@router.post("/relationships/genre-book") @router.post("/relationships/genre-book")
def add_genre_to_book(genre_id: int, book_id: int): 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} return {"genre_id": genre_id, "book_id": book_id}
+6 -15
View File
@@ -1,4 +1,5 @@
from typing import Optional, List, Any from typing import Any, List
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
@@ -8,20 +9,13 @@ class MockSession:
def __init__(self): def __init__(self):
self.storage = mock_storage self.storage = mock_storage
def add(self, obj: Any): def add(self, obj: Any): ...
"""Mock add - not needed for our implementation"""
pass
def commit(self): def commit(self): ...
"""Mock commit - not needed for our implementation"""
pass
def refresh(self, obj: Any): def refresh(self, obj: Any): ...
"""Mock refresh - not needed for our implementation"""
pass
def get(self, model_class, pk: int): def get(self, model_class, pk: int):
"""Mock get method to retrieve object by primary key"""
if hasattr(model_class, "__name__"): if hasattr(model_class, "__name__"):
model_name = model_class.__name__.lower() model_name = model_class.__name__.lower()
else: else:
@@ -35,12 +29,9 @@ class MockSession:
return self.storage.get_genre(pk) return self.storage.get_genre(pk)
return None return None
def delete(self, obj: Any): def delete(self, obj: Any): ...
"""Mock delete - handled in storage methods"""
pass
def exec(self, statement): def exec(self, statement):
"""Mock exec method for queries"""
return MockResult([]) return MockResult([])
+14 -16
View File
@@ -1,4 +1,4 @@
from typing import Dict, List, Optional from typing import Dict, List
class MockStorage: class MockStorage:
@@ -15,7 +15,7 @@ class MockStorage:
self.genre_id_counter = 1 self.genre_id_counter = 1
def clear_all(self): def clear_all(self):
"""Clear all data""" """Очистка всех данных"""
self.books.clear() self.books.clear()
self.authors.clear() self.authors.clear()
self.genres.clear() self.genres.clear()
@@ -33,7 +33,7 @@ class MockStorage:
self.book_id_counter += 1 self.book_id_counter += 1
return book 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) return self.books.get(book_id)
def get_all_books(self) -> List[dict]: def get_all_books(self) -> List[dict]:
@@ -42,9 +42,9 @@ class MockStorage:
def update_book( def update_book(
self, self,
book_id: int, book_id: int,
title: Optional[str] = None, title: str | None = None,
description: Optional[str] = None, description: str | None = None,
) -> Optional[dict]: ) -> dict | None:
if book_id not in self.books: if book_id not in self.books:
return None return None
book = self.books[book_id] book = self.books[book_id]
@@ -54,7 +54,7 @@ class MockStorage:
book["description"] = description book["description"] = description
return book 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: if book_id not in self.books:
return None return None
book = self.books.pop(book_id) book = self.books.pop(book_id)
@@ -74,15 +74,15 @@ class MockStorage:
self.author_id_counter += 1 self.author_id_counter += 1
return author 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) return self.authors.get(author_id)
def get_all_authors(self) -> List[dict]: def get_all_authors(self) -> List[dict]:
return list(self.authors.values()) return list(self.authors.values())
def update_author( def update_author(
self, author_id: int, name: Optional[str] = None self, author_id: int, name: str | None = None
) -> Optional[dict]: ) -> dict | None:
if author_id not in self.authors: if author_id not in self.authors:
return None return None
author = self.authors[author_id] author = self.authors[author_id]
@@ -90,7 +90,7 @@ class MockStorage:
author["name"] = name author["name"] = name
return author 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: if author_id not in self.authors:
return None return None
author = self.authors.pop(author_id) author = self.authors.pop(author_id)
@@ -107,15 +107,13 @@ class MockStorage:
self.genre_id_counter += 1 self.genre_id_counter += 1
return genre 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) return self.genres.get(genre)
def get_all_authors(self) -> List[dict]: def get_all_authors(self) -> List[dict]:
return list(self.authors.values()) return list(self.authors.values())
def update_genre( def update_genre(self, genre_id: int, name: str | None = None) -> dict | None:
self, genre_id: int, name: Optional[str] = None
) -> Optional[dict]:
if genre_id not in self.genres: if genre_id not in self.genres:
return None return None
genre = self.genres[genre_id] genre = self.genres[genre_id]
@@ -123,7 +121,7 @@ class MockStorage:
genre["name"] = name genre["name"] = name
return genre 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: if genre_id not in self.genres:
return None return None
genre = self.genres.pop(genre_id) genre = self.genres.pop(genre_id)
+1 -7
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.mock_app import mock_app from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_database(): def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all() mock_storage.clear_all()
yield yield
mock_storage.clear_all() mock_storage.clear_all()
@@ -29,7 +29,6 @@ def test_create_author():
def test_list_authors(): def test_list_authors():
# First create an author
client.post("/authors", json={"name": "Test Author"}) client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors") response = client.get("/authors")
@@ -42,7 +41,6 @@ def test_list_authors():
def test_get_existing_author(): def test_get_existing_author():
# First create an author
client.post("/authors", json={"name": "Test Author"}) client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors/1") response = client.get("/authors/1")
@@ -63,7 +61,6 @@ def test_get_not_existing_author():
def test_update_author(): def test_update_author():
# First create an author
client.post("/authors", json={"name": "Test Author"}) client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors/1") response = client.get("/authors/1")
@@ -84,10 +81,7 @@ def test_update_not_existing_author():
def test_delete_author(): def test_delete_author():
# First create an author
client.post("/authors", json={"name": "Test Author"}) client.post("/authors", json={"name": "Test Author"})
# Update it first
client.put("/authors/1", json={"name": "Updated Author"}) client.put("/authors/1", json={"name": "Updated Author"})
response = client.get("/authors/1") response = client.get("/authors/1")
+6 -18
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.mock_app import mock_app from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_database(): def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all() mock_storage.clear_all()
yield yield
mock_storage.clear_all() mock_storage.clear_all()
@@ -35,9 +35,7 @@ def test_create_book():
def test_list_books(): 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") response = client.get("/books")
@@ -50,9 +48,7 @@ def test_list_books():
def test_get_existing_book(): 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") response = client.get("/books/1")
@@ -74,9 +70,7 @@ def test_get_not_existing_book():
def test_update_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") response = client.get("/books/1")
@@ -102,14 +96,8 @@ def test_update_not_existing_book():
def test_delete_book(): def test_delete_book():
# First create a book client.post("/books", json={"title": "Test Book", "description": "Test Description"})
client.post( client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"}
"/books", json={"title": "Test Book", "description": "Test Description"}
)
# Update it first
client.put(
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
) )
response = client.get("/books/1") response = client.get("/books/1")
+10 -22
View File
@@ -1,6 +1,8 @@
import pytest
from datetime import datetime from datetime import datetime
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.mock_app import mock_app from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
@@ -9,20 +11,15 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_database(): def setup_database():
"""Setup and cleanup mock database for each test"""
# Clear data before each test
mock_storage.clear_all() mock_storage.clear_all()
yield yield
# Clear data after each test (optional, but good practice)
mock_storage.clear_all() mock_storage.clear_all()
# Test the main page of the application
def test_main_page(): def test_main_page():
response = client.get("/") # Send GET request to the main page response = client.get("/api")
try: try:
content = response.content.decode("utf-8") # Decode response content content = response.content.decode("utf-8")
# Find indices of key elements in the content
title_idx = content.index("Welcome to ") title_idx = content.index("Welcome to ")
description_idx = content.index("Description: ") description_idx = content.index("Description: ")
version_idx = content.index("Version: ") version_idx = content.index("Version: ")
@@ -38,25 +35,16 @@ def test_main_page():
assert content[time_idx + 1] != "<", "Time not provided" assert content[time_idx + 1] != "<", "Time not provided"
assert content[status_idx + 1] != "<", "Status not provided" assert content[status_idx + 1] != "<", "Status not provided"
except Exception as e: except Exception as e:
print(f"Error: {e}") # Print error if an exception occurs print(f"Error: {e}")
assert False, "Unexpected error" # Force test failure on unexpected error assert False, "Unexpected error"
# Test application info endpoint
def test_app_info_test(): 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.status_code == 200, "Invalid response status"
assert response.json()["status"] == "ok", "Status not ok" assert response.json()["status"] == "ok", "Status not ok"
assert response.json()["app_info"]["title"] != "", "Title not provided" assert response.json()["app_info"]["title"] != "", "Title not provided"
assert response.json()["app_info"]["description"] != "", "Description not provided" assert response.json()["app_info"]["description"] != "", "Description not provided"
assert response.json()["app_info"]["version"] != "", "Version 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 ( assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
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"
+5 -16
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.mock_app import mock_app from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_database(): def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all() mock_storage.clear_all()
yield yield
mock_storage.clear_all() mock_storage.clear_all()
@@ -30,28 +30,18 @@ def make_genrebook_relationship(genre_id, book_id):
def test_prepare_data(): 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( assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200)
"/books", json={"title": "Test Book 1", "description": "Test Description 1"} assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200)
).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 1"}).status_code == 200
assert client.post("/authors", json={"name": "Test Author 2"}).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 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 1"}).status_code == 200
assert client.post("/genres", json={"name": "Test Genre 2"}).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 assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
# Create relationships
make_authorbook_relationship(1, 1) make_authorbook_relationship(1, 1)
make_authorbook_relationship(2, 1) make_authorbook_relationship(2, 1)
make_authorbook_relationship(1, 2) make_authorbook_relationship(1, 2)
@@ -63,8 +53,8 @@ def test_prepare_data():
make_genrebook_relationship(2, 3) make_genrebook_relationship(2, 3)
make_genrebook_relationship(3, 3) make_genrebook_relationship(3, 3)
def test_get_book_authors(): def test_get_book_authors():
# Setup test data
test_prepare_data() test_prepare_data()
response1 = client.get("/books/1/authors") response1 = client.get("/books/1/authors")
@@ -91,7 +81,6 @@ def test_get_book_authors():
def test_get_author_books(): def test_get_author_books():
# Setup test data
test_prepare_data() test_prepare_data()
response1 = client.get("/authors/1/books") response1 = client.get("/authors/1/books")