diff --git a/.env b/.env
index 5685a36..47af945 100644
--- a/.env
+++ b/.env
@@ -1,4 +1,5 @@
+POSTGRES_HOST = "localhost"
+POSTGRES_PORT = "5432"
POSTGRES_USER = "postgres"
POSTGRES_PASSWORD = "postgres"
-POSTGRES_DB = "postgres"
-POSTGRES_SERVER = "db"
+POSTGRES_DB = "lib"
diff --git a/docker-compose.yml b/docker-compose.yml
index 44cb0e8..cd46558 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,8 +17,8 @@ services:
- .:/code
ports:
- "8000:8000"
- depends_on:
- - db
+ # depends_on:
+ # - db
tests:
container_name: tests
diff --git a/library_service/auth.py b/library_service/auth.py
new file mode 100644
index 0000000..9fffee1
--- /dev/null
+++ b/library_service/auth.py
@@ -0,0 +1,210 @@
+"""Модуль авторизации и аутентификации"""
+import os
+from datetime import datetime, timedelta, timezone
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+from sqlmodel import Session, select
+
+from library_service.models.db import Role, User
+from library_service.models.dto import TokenData
+from library_service.settings import get_session
+
+
+# Конфигурация из переменных окружения
+ALGORITHM = os.getenv("ALGORITHM", "HS256")
+SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
+ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
+REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
+
+# Хэширование паролей
+pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
+
+# OAuth2 схема
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Проверка пароль по его хешу."""
+ return pwd_context.verify(plain_password, hashed_password)
+
+
+def get_password_hash(password: str) -> str:
+ """Хэширование пароля."""
+ return pwd_context.hash(password)
+
+
+def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
+ """Создание JWT access токена."""
+ to_encode = data.copy()
+ if expires_delta:
+ expire = datetime.now(timezone.utc) + expires_delta
+ else:
+ expire = datetime.now(timezone.utc) + timedelta(
+ minutes=ACCESS_TOKEN_EXPIRE_MINUTES
+ )
+ to_encode.update({"exp": expire, "type": "access"})
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+ return encoded_jwt
+
+
+def create_refresh_token(data: dict) -> str:
+ """Создание JWT refresh токена."""
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
+ to_encode.update({"exp": expire, "type": "refresh"})
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+ return encoded_jwt
+
+
+def decode_token(token: str) -> TokenData:
+ """Декодирование и проверка JWT токенов."""
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ username: str = payload.get("sub")
+ user_id: int = payload.get("user_id")
+ if username is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return TokenData(username=username, user_id=user_id)
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+def authenticate_user(session: Session, username: str, password: str) -> User | None:
+ """Аутентификация пользователя по имени пользователя и паролю."""
+ statement = select(User).where(User.username == username)
+ user = session.exec(statement).first()
+ if not user or not verify_password(password, user.hashed_password):
+ return None
+ return user
+
+
+def get_current_user(
+ token: Annotated[str, Depends(oauth2_scheme)],
+ session: Session = Depends(get_session),
+) -> User:
+ """Получить текущего авторизованного пользователя."""
+ token_data = decode_token(token)
+
+ user = session.get(User, token_data.user_id)
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return user
+
+
+def get_current_active_user(
+ current_user: Annotated[User, Depends(get_current_user)],
+) -> User:
+ """Получить текущего активного пользователя."""
+ if not current_user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
+ )
+ return current_user
+
+
+def require_role(role_name: str):
+ """Dependency, требующая выполнения определенной роли."""
+
+ def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
+ user_roles = [role.name for role in current_user.roles]
+ if role_name not in user_roles:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"Role '{role_name}' required",
+ )
+ return current_user
+
+ return role_checker
+
+
+# Создание dependencies
+RequireAuth = Annotated[User, Depends(get_current_active_user)]
+RequireAdmin = Annotated[User, Depends(require_role("admin"))]
+RequireModerator = Annotated[User, Depends(require_role("moderator"))]
+
+
+def seed_roles(session: Session) -> dict[str, Role]:
+ """Создаёт роли по умолчанию, если их нет."""
+ default_roles = [
+ {"name": "admin", "description": "Администратор системы"},
+ {"name": "moderator", "description": "Модератор"},
+ {"name": "user", "description": "Обычный пользователь"},
+ ]
+
+ roles = {}
+ for role_data in default_roles:
+ existing = session.exec(
+ select(Role).where(Role.name == role_data["name"])
+ ).first()
+
+ if existing:
+ roles[role_data["name"]] = existing
+ else:
+ role = Role(**role_data)
+ session.add(role)
+ session.commit()
+ session.refresh(role)
+ roles[role_data["name"]] = role
+ print(f"[+] Created role: {role_data['name']}")
+
+ return roles
+
+
+def seed_admin(session: Session, admin_role: Role) -> User | None:
+ """Создаёт администратора по умолчанию, если нет ни одного."""
+ existing_admins = session.exec(
+ select(User).join(User.roles).where(Role.name == "admin")
+ ).all()
+
+ if existing_admins:
+ print(f"[*] Admin already exists: {existing_admins[0].username}")
+ return None
+
+ admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
+ admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
+ admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
+
+ if not admin_password:
+ import secrets
+ admin_password = secrets.token_urlsafe(16)
+ print(f"[!] Generated admin password: {admin_password}")
+ print("[!] Please save this password and set DEFAULT_ADMIN_PASSWORD env var")
+
+ admin_user = User(
+ username=admin_username,
+ email=admin_email,
+ full_name="Системный администратор",
+ hashed_password=get_password_hash(admin_password),
+ is_active=True,
+ is_verified=True,
+ )
+ admin_user.roles.append(admin_role)
+
+ session.add(admin_user)
+ session.commit()
+ session.refresh(admin_user)
+
+ print(f"[+] Created admin user: {admin_username}")
+ return admin_user
+
+
+def run_seeds(session: Session) -> None:
+ """Запускаем создание ролей и администратора."""
+ roles = seed_roles(session)
+ seed_admin(session, roles["admin"])
diff --git a/library_service/favicon.svg b/library_service/favicon.svg
deleted file mode 100644
index 99e7bd4..0000000
--- a/library_service/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/library_service/main.py b/library_service/main.py
index 61307c5..6fa9b1e 100644
--- a/library_service/main.py
+++ b/library_service/main.py
@@ -1,12 +1,12 @@
+"""Основной модуль"""
+from contextlib import asynccontextmanager
+
from alembic import command
from alembic.config import Config
-from contextlib import asynccontextmanager
from fastapi import FastAPI
-from toml import load
-from .settings import engine, get_app
from .routers import api_router
-from .routers.misc import get_info
+from .settings import engine, get_app
app = get_app()
alembic_cfg = Config("alembic.ini")
@@ -14,6 +14,7 @@ alembic_cfg = Config("alembic.ini")
@asynccontextmanager
async def lifespan(app: FastAPI):
+ """Жизененый цикл сервиса"""
print("[+] Initializing...")
# Настройка базы данных
diff --git a/library_service/models/__init__.py b/library_service/models/__init__.py
index 4794be4..28ca7ae 100644
--- a/library_service/models/__init__.py
+++ b/library_service/models/__init__.py
@@ -1,2 +1,3 @@
-from .dto import *
+"""Модуль моделей"""
from .db import *
+from .dto import *
diff --git a/library_service/models/db/__init__.py b/library_service/models/db/__init__.py
index aeb3699..8c04171 100644
--- a/library_service/models/db/__init__.py
+++ b/library_service/models/db/__init__.py
@@ -1,25 +1,22 @@
+"""Модуль моделей для базы данных"""
from .author import Author
from .book import Book
from .genre import Genre
+from .role import Role
+from .user import User
from .links import (
AuthorBookLink,
GenreBookLink,
- AuthorWithBooks,
- BookWithAuthors,
- GenreWithBooks,
- BookWithGenres,
- BookWithAuthorsAndGenres,
+ UserRoleLink
)
__all__ = [
"Author",
"Book",
"Genre",
+ "Role",
+ "User",
"AuthorBookLink",
- "AuthorWithBooks",
- "BookWithAuthors",
"GenreBookLink",
- "GenreWithBooks",
- "BookWithGenres",
- "BookWithAuthorsAndGenres",
+ "UserRoleLink",
]
diff --git a/library_service/models/db/author.py b/library_service/models/db/author.py
index ea07fbc..1cad188 100644
--- a/library_service/models/db/author.py
+++ b/library_service/models/db/author.py
@@ -1,14 +1,18 @@
-from typing import List, Optional, TYPE_CHECKING
-from sqlmodel import SQLModel, Field, Relationship
-from ..dto.author import AuthorBase
-from .links import AuthorBookLink
+"""Модуль DB-моделей авторов"""
+from typing import TYPE_CHECKING, List
+
+from sqlmodel import Field, Relationship
+
+from library_service.models.dto.author import AuthorBase
+from library_service.models.db.links import AuthorBookLink
if TYPE_CHECKING:
from .book import Book
class Author(AuthorBase, table=True):
- id: Optional[int] = Field(default=None, primary_key=True, index=True)
+ """Модель автора в базе данных"""
+ id: int | None = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="authors", link_model=AuthorBookLink
)
diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py
index cf09158..55218f3 100644
--- a/library_service/models/db/book.py
+++ b/library_service/models/db/book.py
@@ -1,7 +1,10 @@
-from typing import List, Optional, TYPE_CHECKING
-from sqlmodel import SQLModel, Field, Relationship
-from ..dto.book import BookBase
-from .links import AuthorBookLink, GenreBookLink
+"""Модуль DB-моделей книг"""
+from typing import TYPE_CHECKING, List
+
+from sqlmodel import Field, Relationship
+
+from library_service.models.dto.book import BookBase
+from library_service.models.db.links import AuthorBookLink, GenreBookLink
if TYPE_CHECKING:
from .author import Author
@@ -9,7 +12,8 @@ if TYPE_CHECKING:
class Book(BookBase, table=True):
- id: Optional[int] = Field(default=None, primary_key=True, index=True)
+ """Модель книги в базе данных"""
+ id: int | None = Field(default=None, primary_key=True, index=True)
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
)
diff --git a/library_service/models/db/genre.py b/library_service/models/db/genre.py
index 120beeb..d8a3cfa 100644
--- a/library_service/models/db/genre.py
+++ b/library_service/models/db/genre.py
@@ -1,14 +1,18 @@
-from typing import List, Optional, TYPE_CHECKING
-from sqlmodel import SQLModel, Field, Relationship
-from ..dto.genre import GenreBase
-from .links import GenreBookLink
+"""Модуль DB-моделей жанров"""
+from typing import TYPE_CHECKING, List
+
+from sqlmodel import Field, Relationship
+
+from library_service.models.dto.genre import GenreBase
+from library_service.models.db.links import GenreBookLink
if TYPE_CHECKING:
from .book import Book
class Genre(GenreBase, table=True):
- id: Optional[int] = Field(default=None, primary_key=True, index=True)
+ """Модель жанра в базе данных"""
+ id: int | None = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="genres", link_model=GenreBookLink
)
diff --git a/library_service/models/db/links.py b/library_service/models/db/links.py
index 78f5553..1a9bc2c 100644
--- a/library_service/models/db/links.py
+++ b/library_service/models/db/links.py
@@ -1,12 +1,9 @@
+"""Модуль связей между сущностями в БД"""
from sqlmodel import SQLModel, Field
-from typing import List
-
-from library_service.models.dto.author import AuthorRead
-from library_service.models.dto.book import BookRead
-from library_service.models.dto.genre import GenreRead
class AuthorBookLink(SQLModel, table=True):
+ """Модель связи автора и книги"""
author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True
)
@@ -14,26 +11,14 @@ class AuthorBookLink(SQLModel, table=True):
class GenreBookLink(SQLModel, table=True):
+ """Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
-class AuthorWithBooks(AuthorRead):
- books: List[BookRead] = Field(default_factory=list)
+class UserRoleLink(SQLModel, table=True):
+ """Модель связи роли и пользователя"""
+ __tablename__ = "user_roles"
-
-class BookWithAuthors(BookRead):
- authors: List[AuthorRead] = Field(default_factory=list)
-
-
-class BookWithGenres(BookRead):
- genres: List[GenreRead] = Field(default_factory=list)
-
-
-class GenreWithBooks(GenreRead):
- books: List[BookRead] = Field(default_factory=list)
-
-
-class BookWithAuthorsAndGenres(BookRead):
- authors: List[AuthorRead] = Field(default_factory=list)
- genres: List[GenreRead] = Field(default_factory=list)
+ user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
+ role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
diff --git a/library_service/models/db/role.py b/library_service/models/db/role.py
new file mode 100644
index 0000000..4aab599
--- /dev/null
+++ b/library_service/models/db/role.py
@@ -0,0 +1,20 @@
+"""Модуль DB-моделей ролей"""
+from typing import TYPE_CHECKING, List
+
+from sqlmodel import Field, Relationship
+
+from library_service.models.dto.role import RoleBase
+from library_service.models.db.links import UserRoleLink
+
+if TYPE_CHECKING:
+ from .user import User
+
+
+class Role(RoleBase, table=True):
+ """Модель роли в базе данных"""
+ __tablename__ = "roles"
+
+ id: int | None = Field(default=None, primary_key=True, index=True)
+
+ # Связи
+ users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
diff --git a/library_service/models/db/user.py b/library_service/models/db/user.py
new file mode 100644
index 0000000..ff1e3f9
--- /dev/null
+++ b/library_service/models/db/user.py
@@ -0,0 +1,28 @@
+"""Модуль DB-моделей пользователей"""
+from datetime import datetime
+from typing import TYPE_CHECKING, List
+
+from sqlmodel import Field, Relationship
+
+from library_service.models.dto.user import UserBase
+from library_service.models.db.links import UserRoleLink
+
+if TYPE_CHECKING:
+ from .role import Role
+
+
+class User(UserBase, table=True):
+ """Модель пользователя в базе данных"""
+ __tablename__ = "users"
+
+ id: int | None = Field(default=None, primary_key=True, index=True)
+ hashed_password: str = Field(nullable=False)
+ is_active: bool = Field(default=True)
+ is_verified: bool = Field(default=False)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ updated_at: datetime | None = Field(
+ default=None, sa_column_kwargs={"onupdate": datetime.utcnow}
+ )
+
+ # Связи
+ roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py
index e47f418..ad17b84 100644
--- a/library_service/models/dto/__init__.py
+++ b/library_service/models/dto/__init__.py
@@ -1,7 +1,12 @@
-from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
-from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
-
-from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
+"""Модуль DTO-моделей"""
+from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
+from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
+from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
+from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
+from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate
+from .token import Token, TokenData
+from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
+ BookWithAuthorsAndGenres, BookFilteredList)
__all__ = [
"AuthorBase",
@@ -14,9 +19,22 @@ __all__ = [
"BookUpdate",
"BookRead",
"BookList",
+ "BookFilteredList",
"GenreBase",
"GenreCreate",
"GenreUpdate",
"GenreRead",
"GenreList",
+ "RoleBase",
+ "RoleCreate",
+ "RoleUpdate",
+ "RoleRead",
+ "RoleList",
+ "Token",
+ "TokenData",
+ "UserBase",
+ "UserCreate",
+ "UserRead",
+ "UserUpdate",
+ "UserLogin",
]
diff --git a/library_service/models/dto/author.py b/library_service/models/dto/author.py
index 59ca6e0..877925d 100644
--- a/library_service/models/dto/author.py
+++ b/library_service/models/dto/author.py
@@ -1,9 +1,12 @@
-from sqlmodel import SQLModel
+"""Модуль DTO-моделей авторов"""
+from typing import List
+
from pydantic import ConfigDict
-from typing import Optional, List
+from sqlmodel import SQLModel
class AuthorBase(SQLModel):
+ """Базовая модель автора"""
name: str
model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class AuthorBase(SQLModel):
class AuthorCreate(AuthorBase):
+ """Модель автора для создания"""
pass
class AuthorUpdate(SQLModel):
- name: Optional[str] = None
+ """Модель автора для обновления"""
+ name: str | None = None
class AuthorRead(AuthorBase):
+ """Модель автора для чтения"""
id: int
class AuthorList(SQLModel):
+ """Список авторов"""
authors: List[AuthorRead]
total: int
diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py
index 5667749..546cfea 100644
--- a/library_service/models/dto/book.py
+++ b/library_service/models/dto/book.py
@@ -1,9 +1,15 @@
-from sqlmodel import SQLModel
+"""Модуль DTO-моделей книг"""
+from typing import List, TYPE_CHECKING
+
from pydantic import ConfigDict
-from typing import Optional, List
+from sqlmodel import SQLModel
+
+if TYPE_CHECKING:
+ from .combined import BookWithAuthorsAndGenres
class BookBase(SQLModel):
+ """Базовая модель книги"""
title: str
description: str
@@ -15,18 +21,22 @@ class BookBase(SQLModel):
class BookCreate(BookBase):
+ """Модель книги для создания"""
pass
class BookUpdate(SQLModel):
- title: Optional[str] = None
- description: Optional[str] = None
+ """Модель книги для обновления"""
+ title: str | None = None
+ description: str | None = None
class BookRead(BookBase):
+ """Модель книги для чтения"""
id: int
class BookList(SQLModel):
+ """Список книг"""
books: List[BookRead]
total: int
diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/combined.py
new file mode 100644
index 0000000..ae0ba5c
--- /dev/null
+++ b/library_service/models/dto/combined.py
@@ -0,0 +1,53 @@
+"""Модуль объединёных объектов"""
+from typing import List
+from sqlmodel import SQLModel, Field
+
+from .author import AuthorRead
+from .genre import GenreRead
+from .book import BookRead
+
+
+class AuthorWithBooks(SQLModel):
+ """Модель автора с книгами"""
+ id: int
+ name: str
+ bio: str
+ books: List[BookRead] = Field(default_factory=list)
+
+
+class GenreWithBooks(SQLModel):
+ """Модель жанра с книгами"""
+ id: int
+ name: str
+ books: List[BookRead] = Field(default_factory=list)
+
+
+class BookWithAuthors(SQLModel):
+ """Модель книги с авторами"""
+ id: int
+ title: str
+ description: str
+ authors: List[AuthorRead] = Field(default_factory=list)
+
+
+class BookWithGenres(SQLModel):
+ """Модель книги с жанрами"""
+ id: int
+ title: str
+ description: str
+ genres: List[GenreRead] = Field(default_factory=list)
+
+
+class BookWithAuthorsAndGenres(SQLModel):
+ """Модель с авторами и жанрами"""
+ id: int
+ title: str
+ description: str
+ authors: List[AuthorRead] = Field(default_factory=list)
+ genres: List[GenreRead] = Field(default_factory=list)
+
+
+class BookFilteredList(SQLModel):
+ """Список книг с фильтрацией"""
+ books: List[BookWithAuthorsAndGenres]
+ total: int
diff --git a/library_service/models/dto/genre.py b/library_service/models/dto/genre.py
index 48856d2..643207a 100644
--- a/library_service/models/dto/genre.py
+++ b/library_service/models/dto/genre.py
@@ -1,9 +1,12 @@
-from sqlmodel import SQLModel
+"""Модуль DTO-моделей жанров"""
+from typing import List
+
from pydantic import ConfigDict
-from typing import Optional, List
+from sqlmodel import SQLModel
class GenreBase(SQLModel):
+ """Базовая модель жанра"""
name: str
model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase):
+ """Модель жанра для создания"""
pass
class GenreUpdate(SQLModel):
- name: Optional[str] = None
+ """Модель жанра для обновления"""
+ name: str | None = None
class GenreRead(GenreBase):
+ """Модель жанра для чтения"""
id: int
class GenreList(SQLModel):
+ """Списко жанров"""
genres: List[GenreRead]
total: int
diff --git a/library_service/models/dto/role.py b/library_service/models/dto/role.py
new file mode 100644
index 0000000..6a0326f
--- /dev/null
+++ b/library_service/models/dto/role.py
@@ -0,0 +1,31 @@
+"""Модуль DTO-моделей ролей"""
+from typing import List
+
+from sqlmodel import SQLModel
+
+
+class RoleBase(SQLModel):
+ """Базовая модель роли"""
+ name: str
+ description: str | None = None
+
+
+class RoleCreate(RoleBase):
+ """Модель роли для создания"""
+ pass
+
+
+class RoleUpdate(SQLModel):
+ """Модель роли для обновления"""
+ name: str | None = None
+
+
+class RoleRead(RoleBase):
+ """Модель роли для чтения"""
+ id: int
+
+
+class RoleList(SQLModel):
+ """Список ролей"""
+ roles: List[RoleRead]
+ total: int
diff --git a/library_service/models/dto/token.py b/library_service/models/dto/token.py
new file mode 100644
index 0000000..903bc93
--- /dev/null
+++ b/library_service/models/dto/token.py
@@ -0,0 +1,15 @@
+"""Модуль DTO-моделей токенов"""
+from sqlmodel import SQLModel
+
+
+class Token(SQLModel):
+ """Модель токена"""
+ access_token: str
+ token_type: str = "bearer"
+ refresh_token: str | None = None
+
+
+class TokenData(SQLModel):
+ """Модель содержимого токена"""
+ username: str | None = None
+ user_id: int | None = None
diff --git a/library_service/models/dto/user.py b/library_service/models/dto/user.py
new file mode 100644
index 0000000..ea70179
--- /dev/null
+++ b/library_service/models/dto/user.py
@@ -0,0 +1,61 @@
+"""Модуль DTO-моделей пользователей"""
+import re
+from typing import List
+
+from pydantic import ConfigDict, EmailStr, field_validator
+from sqlmodel import Field, SQLModel
+
+
+class UserBase(SQLModel):
+ """Базовая модель пользователя"""
+ username: str = Field(min_length=3, max_length=50, index=True, unique=True)
+ email: EmailStr = Field(index=True, unique=True)
+ full_name: str | None = Field(default=None, max_length=100)
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "username": "johndoe",
+ "email": "john@example.com",
+ "full_name": "John Doe",
+ }
+ }
+ )
+
+
+class UserCreate(UserBase):
+ """Модель пользователя для создания"""
+ password: str = Field(min_length=8, max_length=100)
+
+ @field_validator("password")
+ @classmethod
+ def validate_password(cls, v: str) -> str:
+ """Валидация пароля"""
+ if not re.search(r"[A-Z]", v):
+ raise ValueError("Пароль должен содержать символы в верхнем регистре")
+ if not re.search(r"[a-z]", v):
+ raise ValueError("Пароль должен содержать символы в нижнем регистре")
+ if not re.search(r"\d", v):
+ raise ValueError("пароль должен содержать цифры")
+ return v
+
+
+class UserLogin(SQLModel):
+ """Модель аутентификации для пользователя"""
+ username: str
+ password: str
+
+
+class UserRead(UserBase):
+ """Модель пользователя для чтения"""
+ id: int
+ is_active: bool
+ is_verified: bool
+ roles: List[str] = []
+
+
+class UserUpdate(SQLModel):
+ """Модель пользователя для обновления"""
+ email: EmailStr | None = None
+ full_name: str | None = None
+ password: str | None = None
diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py
index a94f037..7661a50 100644
--- a/library_service/routers/__init__.py
+++ b/library_service/routers/__init__.py
@@ -1,5 +1,7 @@
+"""Модуль объединения роутеров"""
from fastapi import APIRouter
+from .auth import router as auth_router
from .authors import router as authors_router
from .books import router as books_router
from .genres import router as genres_router
@@ -9,6 +11,7 @@ from .misc import router as misc_router
api_router = APIRouter()
# Подключение всех маршрутов
+api_router.include_router(auth_router)
api_router.include_router(authors_router)
api_router.include_router(books_router)
api_router.include_router(genres_router)
diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py
new file mode 100644
index 0000000..5199be1
--- /dev/null
+++ b/library_service/routers/auth.py
@@ -0,0 +1,156 @@
+"""Модуль работы с авторизацией и аутентификацией пользователей"""
+from datetime import timedelta
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordRequestForm
+from sqlmodel import Session, select
+
+from library_service.models.db import Role, User
+from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate
+from library_service.settings import get_session
+from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
+ RequireAuth, authenticate_user, get_password_hash,
+ create_access_token, create_refresh_token)
+
+router = APIRouter(prefix="/auth", tags=["authentication"])
+
+
+@router.post(
+ "/register",
+ response_model=UserRead,
+ status_code=status.HTTP_201_CREATED,
+ summary="Регистрация нового пользователя",
+ description="Создает нового пользователя в системе",
+)
+def register(user_data: UserCreate, session: Session = Depends(get_session)):
+ """Эндпоинт регистрации пользователя"""
+ # Проверка если username существует
+ existing_user = session.exec(
+ select(User).where(User.username == user_data.username)
+ ).first()
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Username already registered",
+ )
+
+ # Проверка если email существует
+ existing_email = session.exec(
+ select(User).where(User.email == user_data.email)
+ ).first()
+ if existing_email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
+ )
+
+ # Создание пользователя
+ db_user = User(
+ **user_data.model_dump(exclude={"password"}),
+ hashed_password=get_password_hash(user_data.password)
+ )
+
+ # Назначение роли по умолчанию
+ default_role = session.exec(select(Role).where(Role.name == "user")).first()
+ if default_role:
+ db_user.roles.append(default_role)
+
+ session.add(db_user)
+ session.commit()
+ session.refresh(db_user)
+
+ return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles])
+
+
+@router.post(
+ "/token",
+ response_model=Token,
+ summary="Получение токена",
+ description="Аутентификация и получение JWT токена",
+)
+def login(
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
+ session: Session = Depends(get_session),
+):
+ """Эндпоинт аутентификации и получения JWT токена"""
+ user = authenticate_user(session, form_data.username, form_data.password)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+ access_token = create_access_token(
+ data={"sub": user.username, "user_id": user.id},
+ expires_delta=access_token_expires,
+ )
+ refresh_token = create_refresh_token(
+ data={"sub": user.username, "user_id": user.id}
+ )
+
+ return Token(
+ access_token=access_token, refresh_token=refresh_token, token_type="bearer"
+ )
+
+
+@router.get(
+ "/me",
+ response_model=UserRead,
+ summary="Текущий пользователь",
+ description="Получить информацию о текущем авторизованном пользователе",
+)
+def read_users_me(current_user: RequireAuth):
+ """Эндпоинт получения информации о себе"""
+ return UserRead(
+ **current_user.model_dump(), roles=[role.name for role in current_user.roles]
+ )
+
+
+@router.put(
+ "/me",
+ response_model=UserRead,
+ summary="Обновить профиль",
+ description="Обновить информацию текущего пользователя",
+)
+def update_user_me(
+ user_update: UserUpdate,
+ current_user: RequireAuth,
+ session: Session = Depends(get_session),
+):
+ """Эндпоинт обновления пользователя"""
+ if user_update.email:
+ current_user.email = user_update.email
+ if user_update.full_name:
+ current_user.full_name = user_update.full_name
+ if user_update.password:
+ current_user.hashed_password = get_password_hash(user_update.password)
+
+ session.add(current_user)
+ session.commit()
+ session.refresh(current_user)
+
+ return UserRead(
+ **current_user.model_dump(), roles=[role.name for role in current_user.roles]
+ )
+
+
+@router.get(
+ "/users",
+ response_model=list[UserRead],
+ summary="Список пользователей",
+ description="Получить список всех пользователей (только для админов)",
+)
+def read_users(
+ admin: RequireAdmin,
+ session: Session = Depends(get_session),
+ skip: int = 0,
+ limit: int = 100,
+):
+ """Эндпоинт получения списка всех пользователей"""
+ users = session.exec(select(User).offset(skip).limit(limit)).all()
+ return [
+ UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
+ for user in users
+ ]
diff --git a/library_service/routers/authors.py b/library_service/routers/authors.py
index 6eef937..19e3283 100644
--- a/library_service/routers/authors.py
+++ b/library_service/routers/authors.py
@@ -1,28 +1,28 @@
-from fastapi import APIRouter, Path, Depends, HTTPException
+"""Модуль работы с авторами"""
+from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select
+from library_service.auth import RequireAuth
from library_service.settings import get_session
-from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
-from library_service.models.dto import (
- AuthorCreate,
- AuthorUpdate,
- AuthorRead,
- AuthorList,
- BookRead,
-)
-
+from library_service.models.db import Author, AuthorBookLink, Book
+from library_service.models.dto import (BookRead, AuthorWithBooks,
+ AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
router = APIRouter(prefix="/authors", tags=["authors"])
-# Create an author
@router.post(
"/",
response_model=AuthorRead,
summary="Создать автора",
description="Добавляет автора в систему",
)
-def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
+def create_author(
+ current_user: RequireAuth,
+ author: AuthorCreate,
+ session: Session = Depends(get_session),
+):
+ """Эндпоинт создания автора"""
db_author = Author(**author.model_dump())
session.add(db_author)
session.commit()
@@ -30,7 +30,6 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
return AuthorRead(**db_author.model_dump())
-# Read authors
@router.get(
"/",
response_model=AuthorList,
@@ -38,6 +37,7 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
description="Возвращает список всех авторов в системе",
)
def read_authors(session: Session = Depends(get_session)):
+ """Эндпоинт чтения списка авторов"""
authors = session.exec(select(Author)).all()
return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors],
@@ -45,7 +45,6 @@ def read_authors(session: Session = Depends(get_session)):
)
-# Read an author with their books
@router.get(
"/{author_id}",
response_model=AuthorWithBooks,
@@ -56,6 +55,7 @@ def get_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт чтения конкретного автора"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
@@ -72,7 +72,6 @@ def get_author(
return AuthorWithBooks(**author_data)
-# Update an author
@router.put(
"/{author_id}",
response_model=AuthorRead,
@@ -80,10 +79,12 @@ def get_author(
description="Обновляет информацию об авторе в системе",
)
def update_author(
+ current_user: RequireAuth,
author: AuthorUpdate,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт обновления автора"""
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(status_code=404, detail="Author not found")
@@ -97,7 +98,6 @@ def update_author(
return AuthorRead(**db_author.model_dump())
-# Delete an author
@router.delete(
"/{author_id}",
response_model=AuthorRead,
@@ -105,9 +105,11 @@ def update_author(
description="Удаляет автора из системы",
)
def delete_author(
+ current_user: RequireAuth,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт удаления автора"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
diff --git a/library_service/routers/books.py b/library_service/routers/books.py
index c8df20f..80c9d6d 100644
--- a/library_service/routers/books.py
+++ b/library_service/routers/books.py
@@ -1,29 +1,32 @@
-from fastapi import APIRouter, Path, Depends, HTTPException
-from sqlmodel import Session, select
+"""Модуль работы с книгами"""
+from typing import List
-from library_service.models.db.links import BookWithAuthorsAndGenres
+from fastapi import APIRouter, Depends, HTTPException, Path, Query
+from sqlmodel import Session, select, col, func
+
+from library_service.auth import RequireAuth
from library_service.settings import get_session
-from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
-from library_service.models.dto import (
- AuthorRead,
- BookList,
- BookRead,
- BookCreate,
- BookUpdate,
+from library_service.models.db import Author, AuthorBookLink, Book
+from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate
+from library_service.models.dto.combined import (
+ BookWithAuthorsAndGenres,
+ BookFilteredList
)
router = APIRouter(prefix="/books", tags=["books"])
-# Create a book
@router.post(
"/",
response_model=Book,
summary="Создать книгу",
description="Добавляет книгу в систему",
)
-def create_book(book: BookCreate, session: Session = Depends(get_session)):
+def create_book(
+ current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session)
+):
+ """Эндпоинт создания книги"""
db_book = Book(**book.model_dump())
session.add(db_book)
session.commit()
@@ -31,7 +34,6 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
return BookRead(**db_book.model_dump())
-# Read books
@router.get(
"/",
response_model=BookList,
@@ -39,13 +41,13 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
description="Возвращает список всех книг в системе",
)
def read_books(session: Session = Depends(get_session)):
+ """Эндпоинт чтения списка книг"""
books = session.exec(select(Book)).all()
return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
)
-# Read a book with their authors and genres
@router.get(
"/{book_id}",
response_model=BookWithAuthorsAndGenres,
@@ -56,6 +58,7 @@ def get_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт чтения конкретной книги"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -76,10 +79,9 @@ def get_book(
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
- return BookWithAuthors(**book_data)
+ return BookWithAuthorsAndGenres(**book_data)
-# Update a book
@router.put(
"/{book_id}",
response_model=Book,
@@ -87,10 +89,12 @@ def get_book(
description="Обновляет информацию о книге в системе",
)
def update_book(
+ current_user: RequireAuth,
book: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт обновления книги"""
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -102,7 +106,6 @@ def update_book(
return db_book
-# Delete a book
@router.delete(
"/{book_id}",
response_model=BookRead,
@@ -110,9 +113,11 @@ def update_book(
description="Удаляет книгу их системы",
)
def delete_book(
+ current_user: RequireAuth,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт удаления книги"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -122,3 +127,51 @@ def delete_book(
session.delete(book)
session.commit()
return book_read
+
+
+@router.get(
+ "/filter",
+ response_model=BookFilteredList,
+ summary="Фильтрация книг",
+ description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
+)
+def filter_books(
+ session: Session = Depends(get_session),
+ q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
+ author_ids: List[int] | None = Query(None, description="Список ID авторов"),
+ genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
+ page: int = Query(1, gt=0, description="Номер страницы"),
+ size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
+):
+ """Эндпоинт получения отфильтрованного списка книг"""
+ statement = select(Book).distinct()
+
+ if q:
+ statement = statement.where(
+ (col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
+ )
+
+ if author_ids:
+ statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
+
+ if genre_ids:
+ statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
+
+ total_statement = select(func.count()).select_from(statement.subquery())
+ total = session.exec(total_statement).one()
+
+ offset = (page - 1) * size
+ statement = statement.offset(offset).limit(size)
+ results = session.exec(statement).all()
+
+ books_with_data = []
+ for db_book in results:
+ books_with_data.append(
+ BookWithAuthorsAndGenres(
+ **db_book.model_dump(),
+ authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
+ genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
+ )
+ )
+
+ return BookFilteredList(books=books_with_data, total=total)
diff --git a/library_service/routers/genres.py b/library_service/routers/genres.py
index e1e352b..8ff9fe4 100644
--- a/library_service/routers/genres.py
+++ b/library_service/routers/genres.py
@@ -1,28 +1,28 @@
-from fastapi import APIRouter, Path, Depends, HTTPException
+"""Модуль работы с жанрами"""
+from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select
+from library_service.auth import RequireAuth
+from library_service.models.db import Book, Genre, GenreBookLink
+from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.settings import get_session
-from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
-from library_service.models.dto import (
- GenreCreate,
- GenreUpdate,
- GenreRead,
- GenreList,
- BookRead,
-)
-
router = APIRouter(prefix="/genres", tags=["genres"])
-# Create a genre
+# Создание жанра
@router.post(
"/",
response_model=GenreRead,
summary="Создать жанр",
description="Добавляет жанр книг в систему",
)
-def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
+def create_genre(
+ current_user: RequireAuth,
+ genre: GenreCreate,
+ session: Session = Depends(get_session),
+):
+ """Эндпоинт создания жанра"""
db_genre = Genre(**genre.model_dump())
session.add(db_genre)
session.commit()
@@ -30,7 +30,7 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
return GenreRead(**db_genre.model_dump())
-# Read genres
+# Чтение жанров
@router.get(
"/",
response_model=GenreList,
@@ -38,13 +38,14 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
description="Возвращает список всех жанров в системе",
)
def read_genres(session: Session = Depends(get_session)):
+ """Эндпоинт чтения списка жанров"""
genres = session.exec(select(Genre)).all()
return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
)
-# Read a genre with their books
+# Чтение жанра с его книгами
@router.get(
"/{genre_id}",
response_model=GenreWithBooks,
@@ -55,6 +56,7 @@ def get_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт чтения конкретного жанра"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
@@ -71,7 +73,7 @@ def get_genre(
return GenreWithBooks(**genre_data)
-# Update a genre
+# Обновление жанра
@router.put(
"/{genre_id}",
response_model=GenreRead,
@@ -79,10 +81,12 @@ def get_genre(
description="Обновляет информацию о жанре в системе",
)
def update_genre(
+ current_user: RequireAuth,
genre: GenreUpdate,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт обновления жанра"""
db_genre = session.get(Genre, genre_id)
if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found")
@@ -96,7 +100,7 @@ def update_genre(
return GenreRead(**db_genre.model_dump())
-# Delete a genre
+# Удаление жанра
@router.delete(
"/{genre_id}",
response_model=GenreRead,
@@ -104,9 +108,11 @@ def update_genre(
description="Удаляет автора из системы",
)
def delete_genre(
+ current_user: RequireAuth,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
+ """Эндпоинт удаления жанра"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py
index 4f0a7ed..b00687d 100644
--- a/library_service/routers/misc.py
+++ b/library_service/routers/misc.py
@@ -1,21 +1,22 @@
-from fastapi import APIRouter, Path, Request
-from fastapi.params import Depends
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
-from pathlib import Path
+"""Модуль прочих эндпоинтов"""
from datetime import datetime
+from pathlib import Path
from typing import Dict
+from fastapi import APIRouter, Request
+from fastapi.params import Depends
+from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+
from library_service.settings import get_app
-# Загрузка шаблонов
-templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(tags=["misc"])
+templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
-# Форматированная информация о приложении
def get_info(app) -> Dict:
+ """Форматированная информация о приложении"""
return {
"status": "ok",
"app_info": {
@@ -27,29 +28,49 @@ def get_info(app) -> Dict:
}
-# Эндпоинт главной страницы
@router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
+ """Эндпоинт главной страницы"""
return templates.TemplateResponse(request, "index.html", get_info(app))
-# Редирект иконки вкладки
+@router.get("/api", include_in_schema=False)
+async def root(request: Request, app=Depends(get_app)):
+ """Страница с сылками на документацию API"""
+ return templates.TemplateResponse(request, "api.html", get_info(app))
+
+
@router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon():
+ """Редирект иконки вкладки"""
return RedirectResponse("/favicon.svg")
-# Эндпоинт иконки вкладки
@router.get("/favicon.svg", include_in_schema=False)
async def favicon():
- return FileResponse("library_service/favicon.svg", media_type="image/svg+xml")
+ """Эндпоинт иконки вкладки"""
+ return FileResponse(
+ "library_service/static/favicon.svg", media_type="image/svg+xml"
+ )
+
+
+@router.get("/static/{path:path}", include_in_schema=False)
+async def serve_static(path: str):
+ """Статические файлы"""
+ static_dir = Path(__file__).parent.parent / "static"
+ file_path = static_dir / path
+
+ if not file_path.is_file() or not file_path.is_relative_to(static_dir):
+ return JSONResponse(status_code=404, content={"error": "File not found"})
+
+ return FileResponse(file_path)
-# Эндпоинт информации об API
@router.get(
"/api/info",
summary="Информация о сервисе",
description="Возвращает информацию о системе",
)
async def api_info(app=Depends(get_app)):
+ """Эндпоинт информации об API"""
return JSONResponse(content=get_info(app))
diff --git a/library_service/routers/relationships.py b/library_service/routers/relationships.py
index 4e808de..52dd816 100644
--- a/library_service/routers/relationships.py
+++ b/library_service/routers/relationships.py
@@ -1,15 +1,82 @@
+"""Модуль работы со связями"""
+from typing import Dict, List
+
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
-from typing import List, Dict
-from library_service.settings import get_session
-from library_service.models.db import Author, Book, Genre, AuthorBookLink, GenreBookLink
+from library_service.auth import RequireAuth
+from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
from library_service.models.dto import AuthorRead, BookRead, GenreRead
+from library_service.settings import get_session
+
router = APIRouter(tags=["relations"])
-# Add author to book
+def check_entity_exists(session, model, entity_id, entity_name):
+ """Проверка существования связи между сущностями в БД"""
+ entity = session.get(model, entity_id)
+ if not entity:
+ raise HTTPException(status_code=404, detail=f"{entity_name} not found")
+ return entity
+
+
+def add_relationship(session, link_model, id1, field1, id2, field2, detail):
+ """Создание связи между сущностями в БД"""
+ existing_link = session.exec(
+ select(link_model)
+ .where(getattr(link_model, field1) == id1)
+ .where(getattr(link_model, field2) == id2)
+ ).first()
+
+ if existing_link:
+ raise HTTPException(status_code=400, detail=detail)
+
+ link = link_model(**{field1: id1, field2: id2})
+ session.add(link)
+ session.commit()
+ session.refresh(link)
+ return link
+
+
+def remove_relationship(session, link_model, id1, field1, id2, field2):
+ """Удаление связи между сущностями в БД"""
+ link = session.exec(
+ select(link_model)
+ .where(getattr(link_model, field1) == id1)
+ .where(getattr(link_model, field2) == id2)
+ ).first()
+
+ if not link:
+ raise HTTPException(status_code=404, detail="Relationship not found")
+
+ session.delete(link)
+ session.commit()
+ return {"message": "Relationship removed successfully"}
+
+
+def get_related(
+ session,
+ main_model,
+ main_id,
+ main_name,
+ related_model,
+ link_model,
+ link_main_field,
+ link_related_field,
+ read_model
+ ):
+ """Получение связанных в БД сущностей"""
+ check_entity_exists(session, main_model, main_id, main_name)
+
+ related = session.exec(
+ select(related_model).join(link_model)
+ .where(getattr(link_model, link_main_field) == main_id)
+ ).all()
+
+ return [read_model(**obj.model_dump()) for obj in related]
+
+
@router.post(
"/relationships/author-book",
response_model=AuthorBookLink,
@@ -17,33 +84,19 @@ router = APIRouter(tags=["relations"])
description="Добавляет связь между автором и книгой в систему",
)
def add_author_to_book(
- author_id: int, book_id: int, session: Session = Depends(get_session)
+ current_user: RequireAuth,
+ author_id: int,
+ book_id: int,
+ session: Session = Depends(get_session),
):
- author = session.get(Author, author_id)
- if not author:
- raise HTTPException(status_code=404, detail="Author not found")
+ """Эндпоинт добавления автора к книге"""
+ check_entity_exists(session, Author, author_id, "Author")
+ check_entity_exists(session, Book, book_id, "Book")
- book = session.get(Book, book_id)
- if not book:
- raise HTTPException(status_code=404, detail="Book not found")
-
- existing_link = session.exec(
- select(AuthorBookLink)
- .where(AuthorBookLink.author_id == author_id)
- .where(AuthorBookLink.book_id == book_id)
- ).first()
-
- if existing_link:
- raise HTTPException(status_code=400, detail="Relationship already exists")
-
- link = AuthorBookLink(author_id=author_id, book_id=book_id)
- session.add(link)
- session.commit()
- session.refresh(link)
- return link
+ return add_relationship(session, AuthorBookLink,
+ author_id, "author_id", book_id, "book_id", "Relationship already exists")
-# Remove author from book
@router.delete(
"/relationships/author-book",
response_model=Dict[str, str],
@@ -51,23 +104,16 @@ def add_author_to_book(
description="Удаляет связь между автором и книгой в системе",
)
def remove_author_from_book(
- author_id: int, book_id: int, session: Session = Depends(get_session)
+ current_user: RequireAuth,
+ author_id: int,
+ book_id: int,
+ session: Session = Depends(get_session),
):
- link = session.exec(
- select(AuthorBookLink)
- .where(AuthorBookLink.author_id == author_id)
- .where(AuthorBookLink.book_id == book_id)
- ).first()
-
- if not link:
- raise HTTPException(status_code=404, detail="Relationship not found")
-
- session.delete(link)
- session.commit()
- return {"message": "Relationship removed successfully"}
+ """Эндпоинт удаления автора из книги"""
+ return remove_relationship(session, AuthorBookLink,
+ author_id, "author_id", book_id, "book_id")
-# Get author's books
@router.get(
"/authors/{author_id}/books/",
response_model=List[BookRead],
@@ -75,18 +121,12 @@ def remove_author_from_book(
description="Возвращает все книги в системе, написанные автором",
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
- author = session.get(Author, author_id)
- if not author:
- raise HTTPException(status_code=404, detail="Author not found")
-
- books = session.exec(
- select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
- ).all()
-
- return [BookRead(**book.model_dump()) for book in books]
+ """Эндпоинт получения книг, написанных автором"""
+ return get_related(session,
+ Author, author_id, "Author", Book,
+ AuthorBookLink, "author_id", "book_id", BookRead)
-# Get book's authors
@router.get(
"/books/{book_id}/authors/",
response_model=List[AuthorRead],
@@ -94,18 +134,12 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
description="Возвращает всех авторов книги в системе",
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
- book = session.get(Book, book_id)
- if not book:
- raise HTTPException(status_code=404, detail="Book not found")
-
- authors = session.exec(
- select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
- ).all()
-
- return [AuthorRead(**author.model_dump()) for author in authors]
+ """Эндпоинт получения авторов книги"""
+ return get_related(session,
+ Book, book_id, "Book", Author,
+ AuthorBookLink, "book_id", "author_id", AuthorRead)
-# Add genre to book
@router.post(
"/relationships/genre-book",
response_model=GenreBookLink,
@@ -113,33 +147,19 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
description="Добавляет связь между книгой и жанром в систему",
)
def add_genre_to_book(
- genre_id: int, book_id: int, session: Session = Depends(get_session)
+ current_user: RequireAuth,
+ genre_id: int,
+ book_id: int,
+ session: Session = Depends(get_session),
):
- genre = session.get(Genre, genre_id)
- if not genre:
- raise HTTPException(status_code=404, detail="Genre not found")
+ """Эндпоинт добавления жанра к книге"""
+ check_entity_exists(session, Genre, genre_id, "Genre")
+ check_entity_exists(session, Book, book_id, "Book")
- book = session.get(Book, book_id)
- if not book:
- raise HTTPException(status_code=404, detail="Book not found")
-
- existing_link = session.exec(
- select(GenreBookLink)
- .where(GenreBookLink.genre_id == genre_id)
- .where(GenreBookLink.book_id == book_id)
- ).first()
-
- if existing_link:
- raise HTTPException(status_code=400, detail="Relationship already exists")
-
- link = GenreBookLink(genre_id=genre_id, book_id=book_id)
- session.add(link)
- session.commit()
- session.refresh(link)
- return link
+ return add_relationship(session, GenreBookLink,
+ genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
-# Remove author from book
@router.delete(
"/relationships/genre-book",
response_model=Dict[str, str],
@@ -147,55 +167,37 @@ def add_genre_to_book(
description="Удаляет связь между жанром и книгой в системе",
)
def remove_genre_from_book(
- genre_id: int, book_id: int, session: Session = Depends(get_session)
+ current_user: RequireAuth,
+ genre_id: int,
+ book_id: int,
+ session: Session = Depends(get_session),
):
- link = session.exec(
- select(GenreBookLink)
- .where(GenreBookLink.genre_id == genre_id)
- .where(GenreBookLink.book_id == book_id)
- ).first()
-
- if not link:
- raise HTTPException(status_code=404, detail="Relationship not found")
-
- session.delete(link)
- session.commit()
- return {"message": "Relationship removed successfully"}
+ """Эндпоинт удаления жанра из книги"""
+ return remove_relationship(session, GenreBookLink,
+ genre_id, "genre_id", book_id, "book_id")
-# Get genre's books
@router.get(
- "/genres/{author_id}/books/",
+ "/genres/{genre_id}/books/",
response_model=List[BookRead],
summary="Получить книги, написанные в жанре",
description="Возвращает все книги в системе в этом жанре",
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
- genre = session.get(Genre, genre_id)
- if not genre:
- raise HTTPException(status_code=404, detail="Genre not found")
-
- books = session.exec(
- select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
- ).all()
-
- return [BookRead(**book.model_dump()) for book in books]
+ """Эндпоинт получения книг с жанром"""
+ return get_related(session,
+ Genre, genre_id, "Genre", Book,
+ GenreBookLink, "genre_id", "book_id", BookRead)
-# Get book's genres
@router.get(
"/books/{book_id}/genres/",
response_model=List[GenreRead],
summary="Получить жанры книги",
description="Возвращает все жанры книги в системе",
)
-def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
- book = session.get(Book, book_id)
- if not book:
- raise HTTPException(status_code=404, detail="Book not found")
-
- genres = session.exec(
- select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
- ).all()
-
- return [GenreRead(**author.model_dump()) for genre in genres]
+def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
+ """Эндпоинт получения жанров книги"""
+ return get_related(session,
+ Book, book_id, "Book", Genre,
+ GenreBookLink, "book_id", "genre_id", GenreRead)
diff --git a/library_service/settings.py b/library_service/settings.py
index 7994cef..a6585b2 100644
--- a/library_service/settings.py
+++ b/library_service/settings.py
@@ -1,59 +1,66 @@
+"""Модуль настроек проекта"""
import os
+
from dotenv import load_dotenv
from fastapi import FastAPI
-from sqlmodel import create_engine, SQLModel, Session
+from sqlmodel import Session, create_engine
from toml import load
load_dotenv()
-with open("pyproject.toml") as f:
+with open("pyproject.toml", 'r', encoding='utf-8') as f:
config = load(f)
-# Dependency to get the FastAPI application instance
def get_app() -> FastAPI:
+ """Dependency, для получение экземплярра FastAPI application"""
return FastAPI(
title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"],
version=config["tool"]["poetry"]["version"],
openapi_tags=[
+ {
+ "name": "authentication",
+ "description": "Авторизация пользователя."
+ },
{
"name": "authors",
- "description": "Operations with authors.",
+ "description": "Действия с авторами.",
},
{
"name": "books",
- "description": "Operations with books.",
+ "description": "Действия с книгами.",
},
{
"name": "genres",
- "description": "Operations with genres.",
+ "description": "Действия с жанрами.",
},
{
"name": "relations",
- "description": "Operations with relations.",
+ "description": "Действия с связями.",
},
{
"name": "misc",
- "description": "Miscellaneous operations.",
+ "description": "Прочие.",
},
],
)
+HOST = os.getenv("POSTGRES_HOST")
+PORT = os.getenv("POSTGRES_PORT")
USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB")
-HOST = os.getenv("POSTGRES_SERVER")
if not USER or not PASSWORD or not DATABASE or not HOST:
raise ValueError("Missing environment variables")
-POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
+POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
-# Dependency to get a database session
def get_session():
+ """Dependency, для получение сессии БД"""
with Session(engine) as session:
yield session
diff --git a/library_service/static/avatar.svg b/library_service/static/avatar.svg
new file mode 100644
index 0000000..4168b04
--- /dev/null
+++ b/library_service/static/avatar.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/library_service/static/dited.regular.ttf b/library_service/static/dited.regular.ttf
new file mode 100644
index 0000000..e0633b3
Binary files /dev/null and b/library_service/static/dited.regular.ttf differ
diff --git a/library_service/static/favicon.svg b/library_service/static/favicon.svg
new file mode 100644
index 0000000..d1fc9b0
--- /dev/null
+++ b/library_service/static/favicon.svg
@@ -0,0 +1,62 @@
+
diff --git a/library_service/static/logo.svg b/library_service/static/logo.svg
new file mode 100644
index 0000000..6fe7c5d
--- /dev/null
+++ b/library_service/static/logo.svg
@@ -0,0 +1,59 @@
+
diff --git a/library_service/static/novem.regular.ttf b/library_service/static/novem.regular.ttf
new file mode 100644
index 0000000..9781090
Binary files /dev/null and b/library_service/static/novem.regular.ttf differ
diff --git a/library_service/static/script.js b/library_service/static/script.js
new file mode 100644
index 0000000..4cb272f
--- /dev/null
+++ b/library_service/static/script.js
@@ -0,0 +1,135 @@
+// Load authors and genres asynchronously
+Promise.all([
+ fetch("/authors").then((response) => response.json()),
+ fetch("/genres").then((response) => response.json()),
+])
+ .then(([authorsData, genresData]) => {
+ // Populate authors dropdown
+ const dropdown = document.getElementById("author-dropdown");
+ authorsData.authors.forEach((author) => {
+ const div = document.createElement("div");
+ div.className = "p-2 hover:bg-gray-100 cursor-pointer";
+ div.setAttribute("data-value", author.name);
+ div.textContent = author.name;
+ dropdown.appendChild(div);
+ });
+
+ // Populate genres list
+ const list = document.getElementById("genres-list");
+ genresData.genres.forEach((genre) => {
+ const li = document.createElement("li");
+ li.className = "mb-1";
+ li.innerHTML = `
+
+ `;
+ list.appendChild(li);
+ });
+
+ initializeAuthorDropdown();
+ })
+ .catch((error) => console.error("Error loading data:", error));
+
+function initializeAuthorDropdown() {
+ const authorSearchInput = document.getElementById("author-search-input");
+ const authorDropdown = document.getElementById("author-dropdown");
+ const selectedAuthorsContainer = document.getElementById(
+ "selected-authors-container",
+ );
+ const dropdownItems = authorDropdown.querySelectorAll("[data-value]");
+ let selectedAuthors = new Set();
+
+ // Function to update highlights in dropdown
+ const updateDropdownHighlights = () => {
+ dropdownItems.forEach((item) => {
+ const value = item.dataset.value;
+ if (selectedAuthors.has(value)) {
+ item.classList.add("bg-gray-200");
+ } else {
+ item.classList.remove("bg-gray-200");
+ }
+ });
+ };
+
+ // Function to render selected authors
+ const renderSelectedAuthors = () => {
+ Array.from(selectedAuthorsContainer.children).forEach((child) => {
+ if (child.id !== "author-search-input") {
+ child.remove();
+ }
+ });
+
+ selectedAuthors.forEach((author) => {
+ const authorChip = document.createElement("span");
+ authorChip.className =
+ "flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full";
+ authorChip.innerHTML = `
+ ${author}
+
+ `;
+ selectedAuthorsContainer.insertBefore(authorChip, authorSearchInput);
+ });
+ updateDropdownHighlights();
+ };
+
+ // Handle input focus to show dropdown
+ authorSearchInput.addEventListener("focus", () => {
+ authorDropdown.classList.remove("hidden");
+ });
+
+ // Handle input for filtering
+ authorSearchInput.addEventListener("input", () => {
+ const query = authorSearchInput.value.toLowerCase();
+ dropdownItems.forEach((item) => {
+ const text = item.textContent.toLowerCase();
+ item.style.display = text.includes(query) ? "block" : "none";
+ });
+ authorDropdown.classList.remove("hidden");
+ });
+
+ // Handle clicks outside to hide dropdown
+ document.addEventListener("click", (event) => {
+ if (
+ !selectedAuthorsContainer.contains(event.target) &&
+ !authorDropdown.contains(event.target)
+ ) {
+ authorDropdown.classList.add("hidden");
+ }
+ });
+
+ // Handle author selection from dropdown
+ authorDropdown.addEventListener("click", (event) => {
+ const selectedValue = event.target.dataset.value;
+ if (selectedValue) {
+ if (selectedAuthors.has(selectedValue)) {
+ selectedAuthors.delete(selectedValue);
+ } else {
+ selectedAuthors.add(selectedValue);
+ }
+ authorSearchInput.value = "";
+ renderSelectedAuthors();
+ authorSearchInput.focus();
+ }
+ });
+
+ // Handle removing selected author chip
+ selectedAuthorsContainer.addEventListener("click", (event) => {
+ if (event.target.closest("button")) {
+ const authorToRemove = event.target.closest("button").dataset.author;
+ selectedAuthors.delete(authorToRemove);
+ renderSelectedAuthors();
+ authorSearchInput.focus();
+ }
+ });
+
+ // Initial render and highlights (without auto-focus)
+ renderSelectedAuthors();
+}
diff --git a/library_service/static/styles.css b/library_service/static/styles.css
new file mode 100644
index 0000000..5a8b0a3
--- /dev/null
+++ b/library_service/static/styles.css
@@ -0,0 +1,77 @@
+@font-face {
+ font-family: "Novem";
+ src: url("novem.regular.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Dited";
+ src: url("dited.regular.ttf") format("truetype");
+}
+
+h1 {
+ font-family: "Novem", sans-serif;
+ letter-spacing: 10px;
+}
+
+nav ul li a {
+ font-family: "Dited", sans-serif;
+ letter-spacing: 2.5px;
+ font-size: large;
+}
+
+/* Custom checkbox styles */
+.custom-checkbox {
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+ font-size: 14px;
+ user-select: none;
+}
+
+.custom-checkbox input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+}
+
+.checkmark {
+ height: 18px;
+ width: 18px;
+ background-color: #fff;
+ border: 2px solid #d1d5db; /* gray-300 */
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ display: inline-block;
+ margin-right: 8px;
+}
+
+.custom-checkbox:hover input ~ .checkmark {
+ border-color: #6b7280; /* gray-500 */
+}
+
+.custom-checkbox input:checked ~ .checkmark {
+ background-color: #6b7280; /* gray-500 */
+ border-color: #6b7280;
+}
+
+.checkmark:after {
+ content: "";
+ position: absolute;
+ display: none;
+}
+
+.custom-checkbox input:checked ~ .checkmark:after {
+ display: block;
+}
+
+.custom-checkbox .checkmark:after {
+ left: 6.5px;
+ top: 6px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
diff --git a/library_service/templates/api.html b/library_service/templates/api.html
new file mode 100644
index 0000000..a1307fa
--- /dev/null
+++ b/library_service/templates/api.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {{ app_info.title }}
+
+
+
+
+ Welcome to {{ app_info.title }}!
+ Description: {{ app_info.description }}
+ Version: {{ app_info.version }}
+ Current Time: {{ server_time }}
+ Status: {{ status }}
+
+
+
diff --git a/library_service/templates/index.html b/library_service/templates/index.html
index a1307fa..c9beb6d 100644
--- a/library_service/templates/index.html
+++ b/library_service/templates/index.html
@@ -3,58 +3,136 @@
- {{ app_info.title }}
-
+ LiB
+
+
-
-
- Welcome to {{ app_info.title }}!
- Description: {{ app_info.description }}
- Version: {{ app_info.version }}
- Current Time: {{ server_time }}
- Status: {{ status }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Product Title 1
+
+ A short description of the product, highlighting its
+ key features and benefits.
+
+
+
$29.99
+
+
+
+
+
+
Product Title 2
+
+ Another great product with amazing features. You'll
+ love it!
+
+
+
$49.99
+
+
+
+
+
+
Product Title 3
+
+ This product is a must-have for every modern home.
+ High quality and durable.
+
+
+
$19.99
+
+
+
+
+
+
+
diff --git a/migrations/env.py b/migrations/env.py
index c4e5b77..bcc58f9 100644
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -1,8 +1,7 @@
from logging.config import fileConfig
-from alembic import context
-from sqlalchemy import engine_from_config
-from sqlalchemy import pool
+from alembic import context
+from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
from library_service.settings import POSTGRES_DATABASE_URL
diff --git a/migrations/versions/9d7a43ac5dfc_genres.py b/migrations/versions/9d7a43ac5dfc_genres.py
index 3056f1a..21d4227 100644
--- a/migrations/versions/9d7a43ac5dfc_genres.py
+++ b/migrations/versions/9d7a43ac5dfc_genres.py
@@ -5,41 +5,49 @@ Revises: d266fdc61e99
Create Date: 2025-06-25 11:24:30.229418
"""
+
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
import sqlmodel
-
+from alembic import op
# revision identifiers, used by Alembic.
-revision: str = '9d7a43ac5dfc'
-down_revision: Union[str, None] = 'd266fdc61e99'
+revision: str = "9d7a43ac5dfc"
+down_revision: Union[str, None] = "d266fdc61e99"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
- op.create_table('genre',
- sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.PrimaryKeyConstraint('id')
+ op.create_table(
+ "genre",
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
)
- op.create_index(op.f('ix_genre_id'), 'genre', ['id'], unique=False)
- op.create_table('genrebooklink',
- sa.Column('genre_id', sa.Integer(), nullable=False),
- sa.Column('book_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
- sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ),
- sa.PrimaryKeyConstraint('genre_id', 'book_id')
+ op.create_index(op.f("ix_genre_id"), "genre", ["id"], unique=False)
+ op.create_table(
+ "genrebooklink",
+ sa.Column("genre_id", sa.Integer(), nullable=False),
+ sa.Column("book_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["book_id"],
+ ["book.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["genre_id"],
+ ["genre.id"],
+ ),
+ sa.PrimaryKeyConstraint("genre_id", "book_id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('genrebooklink')
- op.drop_index(op.f('ix_genre_id'), table_name='genre')
- op.drop_table('genre')
+ op.drop_table("genrebooklink")
+ op.drop_index(op.f("ix_genre_id"), table_name="genre")
+ op.drop_table("genre")
# ### end Alembic commands ###
diff --git a/migrations/versions/b838606ad8d1_auth.py b/migrations/versions/b838606ad8d1_auth.py
new file mode 100644
index 0000000..5995ff3
--- /dev/null
+++ b/migrations/versions/b838606ad8d1_auth.py
@@ -0,0 +1,82 @@
+"""auth
+
+Revision ID: b838606ad8d1
+Revises: 9d7a43ac5dfc
+Create Date: 2025-12-07 20:18:05.839579
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+import sqlmodel
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "b838606ad8d1"
+down_revision: Union[str, None] = "9d7a43ac5dfc"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "roles",
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_roles_id"), "roles", ["id"], unique=False)
+ op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
+ op.create_table(
+ "users",
+ sa.Column(
+ "username", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False
+ ),
+ sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column(
+ "full_name", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True
+ ),
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column(
+ "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False
+ ),
+ sa.Column("is_active", sa.Boolean(), nullable=False),
+ sa.Column("is_verified", sa.Boolean(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
+ op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
+ op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
+ op.create_table(
+ "user_roles",
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("role_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["role_id"],
+ ["roles.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["users.id"],
+ ),
+ sa.PrimaryKeyConstraint("user_id", "role_id"),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("user_roles")
+ op.drop_index(op.f("ix_users_username"), table_name="users")
+ op.drop_index(op.f("ix_users_id"), table_name="users")
+ op.drop_index(op.f("ix_users_email"), table_name="users")
+ op.drop_table("users")
+ op.drop_index(op.f("ix_roles_name"), table_name="roles")
+ op.drop_index(op.f("ix_roles_id"), table_name="roles")
+ op.drop_table("roles")
+ # ### end Alembic commands ###
diff --git a/migrations/versions/d266fdc61e99_init.py b/migrations/versions/d266fdc61e99_init.py
index 42ca91d..012e677 100644
--- a/migrations/versions/d266fdc61e99_init.py
+++ b/migrations/versions/d266fdc61e99_init.py
@@ -1,19 +1,19 @@
"""init
Revision ID: d266fdc61e99
-Revises:
+Revises:
Create Date: 2025-05-27 18:04:22.279035
"""
+
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
import sqlmodel
-
+from alembic import op
# revision identifiers, used by Alembic.
-revision: str = 'd266fdc61e99'
+revision: str = "d266fdc61e99"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,34 +21,43 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
- op.create_table('author',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
- sa.PrimaryKeyConstraint('id')
+ op.create_table(
+ "author",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
)
- op.create_index(op.f('ix_author_id'), 'author', ['id'], unique=False)
- op.create_table('book',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
- sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
- sa.PrimaryKeyConstraint('id')
+ op.create_index(op.f("ix_author_id"), "author", ["id"], unique=False)
+ op.create_table(
+ "book",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
)
- op.create_index(op.f('ix_book_id'), 'book', ['id'], unique=False)
- op.create_table('authorbooklink',
- sa.Column('author_id', sa.Integer(), nullable=False),
- sa.Column('book_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
- sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
- sa.PrimaryKeyConstraint('author_id', 'book_id')
+ op.create_index(op.f("ix_book_id"), "book", ["id"], unique=False)
+ op.create_table(
+ "authorbooklink",
+ sa.Column("author_id", sa.Integer(), nullable=False),
+ sa.Column("book_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["author_id"],
+ ["author.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["book_id"],
+ ["book.id"],
+ ),
+ sa.PrimaryKeyConstraint("author_id", "book_id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('authorbooklink')
- op.drop_index(op.f('ix_book_id'), table_name='book')
- op.drop_table('book')
- op.drop_index(op.f('ix_author_id'), table_name='author')
- op.drop_table('author')
+ op.drop_table("authorbooklink")
+ op.drop_index(op.f("ix_book_id"), table_name="book")
+ op.drop_table("book")
+ op.drop_index(op.f("ix_author_id"), table_name="author")
+ op.drop_table("author")
# ### end Alembic commands ###
diff --git a/poetry.lock b/poetry.lock
index 38f5513..7b421ae 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,17 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
+[[package]]
+name = "aiofiles"
+version = "25.1.0"
+description = "File support for asyncio."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"},
+ {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"},
+]
+
[[package]]
name = "alembic"
version = "1.16.2"
@@ -53,6 +65,75 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)",
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
+[[package]]
+name = "argon2-cffi"
+version = "25.1.0"
+description = "Argon2 for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"},
+ {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"},
+]
+
+[package.dependencies]
+argon2-cffi-bindings = "*"
+
+[[package]]
+name = "argon2-cffi-bindings"
+version = "25.1.0"
+description = "Low-level CFFI bindings for Argon2"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"},
+ {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"},
+ {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"},
+ {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"},
+ {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"},
+ {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"},
+ {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"},
+ {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"},
+ {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"},
+]
+
+[package.dependencies]
+cffi = [
+ {version = ">=1.0.1", markers = "python_version < \"3.14\""},
+ {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""},
+]
+
+[[package]]
+name = "astroid"
+version = "4.0.2"
+description = "An abstract syntax tree for Python with inference support."
+optional = false
+python-versions = ">=3.10.0"
+groups = ["dev"]
+files = [
+ {file = "astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b"},
+ {file = "astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070"},
+]
+
[[package]]
name = "black"
version = "25.1.0"
@@ -110,6 +191,103 @@ files = [
{file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"},
]
+[[package]]
+name = "cffi"
+version = "2.0.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
+ {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
+ {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
+ {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
+ {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
+ {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
+ {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
+ {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
+ {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
+ {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
+ {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
+ {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
+ {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
+ {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
+ {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
+ {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
+ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
+ {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
+]
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
[[package]]
name = "click"
version = "8.2.1"
@@ -138,6 +316,99 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+groups = ["main"]
+files = [
+ {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
+ {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
+ {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
+ {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
+ {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
+ {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
+ {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
+ {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
+ {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
+ {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
+ {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
+ {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
+ {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
+ {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
+ {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
+ {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
+ {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
+ {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
+ {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
+ {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
+ {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
+ {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
+ {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
+ {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
+ {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
+]
+
+[package.dependencies]
+cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
+docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
+nox = ["nox[uv] (>=2024.4.15)"]
+pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
+sdist = ["build (>=1.0.0)"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "dill"
+version = "0.4.0"
+description = "serialize all of Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"},
+ {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"},
+]
+
+[package.extras]
+graph = ["objgraph (>=1.7.2)"]
+profile = ["gprof2dot (>=2022.7.29)"]
+
[[package]]
name = "dnspython"
version = "2.7.0"
@@ -159,6 +430,25 @@ idna = ["idna (>=3.7)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
+[[package]]
+name = "ecdsa"
+version = "0.19.1"
+description = "ECDSA cryptographic signature library (pure python)"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6"
+groups = ["main"]
+files = [
+ {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"},
+ {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"},
+]
+
+[package.dependencies]
+six = ">=1.9.0"
+
+[package.extras]
+gmpy = ["gmpy"]
+gmpy2 = ["gmpy2"]
+
[[package]]
name = "email-validator"
version = "2.2.0"
@@ -439,6 +729,22 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
+[[package]]
+name = "isort"
+version = "7.0.0"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.10.0"
+groups = ["dev"]
+files = [
+ {file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"},
+ {file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"},
+]
+
+[package.extras]
+colors = ["colorama"]
+plugins = ["setuptools"]
+
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -585,6 +891,18 @@ files = [
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
]
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -703,6 +1021,27 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
+[[package]]
+name = "passlib"
+version = "1.7.4"
+description = "comprehensive password hashing framework supporting over 30 schemes"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
+ {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
+]
+
+[package.dependencies]
+argon2-cffi = {version = ">=18.2.0", optional = true, markers = "extra == \"argon2\""}
+
+[package.extras]
+argon2 = ["argon2-cffi (>=18.2.0)"]
+bcrypt = ["bcrypt (>=3.1.0)"]
+build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
+totp = ["cryptography"]
+
[[package]]
name = "pathspec"
version = "0.12.1"
@@ -826,23 +1165,49 @@ files = [
{file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"},
]
+[[package]]
+name = "pyasn1"
+version = "0.6.1"
+description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
+ {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+]
+
+[[package]]
+name = "pycparser"
+version = "2.23"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "implementation_name != \"PyPy\""
+files = [
+ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
+ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
+]
+
[[package]]
name = "pydantic"
-version = "2.11.7"
+version = "2.12.5"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
- {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
+ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
+ {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
-pydantic-core = "2.33.2"
-typing-extensions = ">=4.12.2"
-typing-inspection = ">=0.4.0"
+email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
+pydantic-core = "2.41.5"
+typing-extensions = ">=4.14.1"
+typing-inspection = ">=0.4.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
@@ -850,115 +1215,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
[[package]]
name = "pydantic-core"
-version = "2.33.2"
+version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
- {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
+ {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
+ {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
+ {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
+ {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
+ {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
+ {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
+ {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
+ {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
+ {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
+ {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
+ {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
+ {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
+ {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
+ {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
+ {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
+ {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
+ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
+ {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
]
[package.dependencies]
-typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+typing-extensions = ">=4.14.1"
[[package]]
name = "pydantic-extra-types"
@@ -1023,6 +1410,31 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
+[[package]]
+name = "pylint"
+version = "4.0.4"
+description = "python code static checker"
+optional = false
+python-versions = ">=3.10.0"
+groups = ["dev"]
+files = [
+ {file = "pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0"},
+ {file = "pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2"},
+]
+
+[package.dependencies]
+astroid = ">=4.0.2,<=4.1.dev0"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""}
+isort = ">=5,<5.13 || >5.13,<8"
+mccabe = ">=0.6,<0.8"
+platformdirs = ">=2.2"
+tomlkit = ">=0.10.1"
+
+[package.extras]
+spelling = ["pyenchant (>=3.2,<4.0)"]
+testutils = ["gitpython (>3)"]
+
[[package]]
name = "pytest"
version = "8.4.1"
@@ -1045,6 +1457,25 @@ pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
+ {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
+]
+
+[package.dependencies]
+pytest = ">=8.2,<10"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
[[package]]
name = "python-dotenv"
version = "0.21.1"
@@ -1060,6 +1491,30 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
+[[package]]
+name = "python-jose"
+version = "3.5.0"
+description = "JOSE implementation in Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
+ {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
+]
+
+[package.dependencies]
+cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""}
+ecdsa = "!=0.15"
+pyasn1 = ">=0.5.0"
+rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0"
+
+[package.extras]
+cryptography = ["cryptography (>=3.4.0)"]
+pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"]
+pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"]
+test = ["pytest", "pytest-cov"]
+
[[package]]
name = "python-multipart"
version = "0.0.20"
@@ -1171,6 +1626,21 @@ click = ">=8.1.7"
rich = ">=13.7.1"
typing-extensions = ">=4.12.2"
+[[package]]
+name = "rsa"
+version = "4.9.1"
+description = "Pure-Python RSA implementation"
+optional = false
+python-versions = "<4,>=3.6"
+groups = ["main"]
+files = [
+ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
+ {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.1.3"
+
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -1183,6 +1653,18 @@ files = [
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -1337,6 +1819,18 @@ files = [
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
+[[package]]
+name = "tomlkit"
+version = "0.13.3"
+description = "Style preserving TOML library"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
+ {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
+]
+
[[package]]
name = "typer"
version = "0.16.0"
@@ -1357,26 +1851,26 @@ typing-extensions = ">=3.7.4.3"
[[package]]
name = "typing-extensions"
-version = "4.14.0"
+version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"},
- {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"},
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "typing-inspection"
-version = "0.4.1"
+version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
- {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
+ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
+ {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
@@ -1750,4 +2244,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.13"
-content-hash = "a3555dac28547317a5d8d75507b5923d03b58412a988a260b627c079782bc15c"
+content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e"
diff --git a/pyproject.toml b/pyproject.toml
index e3a7a41..822e221 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "LibraryAPI"
-version = "0.1.3"
+version = "0.2.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"]
readme = "README.md"
@@ -16,10 +16,17 @@ sqlmodel = "^0.0.24"
uvicorn = "^0.34.3"
jinja2 = "^3.1.6"
toml = "^0.10.2"
+python-jose = {extras = ["cryptography"], version = "^3.5.0"}
+passlib = {extras = ["argon2"], version = "^1.7.4"}
+aiofiles = "^25.1.0"
+pydantic = {extras = ["email"], version = "^2.12.5"}
[tool.poetry.group.dev.dependencies]
black = "^25.1.0"
pytest = "^8.4.1"
+isort = "^7.0.0"
+pytest-asyncio = "^1.3.0"
+pylint = "^4.0.4"
[tool.poetry.requires-plugins]
poetry-plugin-export = ">=1.8"
diff --git a/tests/mock_app.py b/tests/mock_app.py
index ca63a53..621a9f6 100644
--- a/tests/mock_app.py
+++ b/tests/mock_app.py
@@ -1,23 +1,24 @@
from fastapi import FastAPI
-from tests.mock_routers import books, authors, genres, relationships
+
from library_service.routers.misc import router as misc_router
+from tests.mock_routers import authors, books, genres, relationships
def create_mock_app() -> FastAPI:
- """Create FastAPI app with mock routers for testing"""
+ """Создание FastAPI app с моками роутеров для тестов"""
app = FastAPI(
title="Library API Test",
description="Library API for testing without database",
version="1.0.0",
)
- # Include mock routers
+ # Подключение мок-роутеров
app.include_router(books.router)
app.include_router(authors.router)
app.include_router(genres.router)
app.include_router(relationships.router)
- # Include real misc router (it doesn't use database)
+ # Подключение реального misc роутера
app.include_router(misc_router)
return app
diff --git a/tests/mock_routers/authors.py b/tests/mock_routers/authors.py
index e90da1a..1313eb3 100644
--- a/tests/mock_routers/authors.py
+++ b/tests/mock_routers/authors.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException
+
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/authors", tags=["authors"])
diff --git a/tests/mock_routers/books.py b/tests/mock_routers/books.py
index be58a4f..702a32c 100644
--- a/tests/mock_routers/books.py
+++ b/tests/mock_routers/books.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException
+
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/books", tags=["books"])
diff --git a/tests/mock_routers/genres.py b/tests/mock_routers/genres.py
index 46fb6a6..aeccecc 100644
--- a/tests/mock_routers/genres.py
+++ b/tests/mock_routers/genres.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException
+
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/genres", tags=["genres"])
diff --git a/tests/mock_routers/relationships.py b/tests/mock_routers/relationships.py
index b8385e4..fa6c1c8 100644
--- a/tests/mock_routers/relationships.py
+++ b/tests/mock_routers/relationships.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException
+
from tests.mocks.mock_storage import mock_storage
router = APIRouter(tags=["relations"])
@@ -36,5 +37,4 @@ def get_authors_for_book(book_id: int):
@router.post("/relationships/genre-book")
def add_genre_to_book(genre_id: int, book_id: int):
- # For tests that need genre functionality
return {"genre_id": genre_id, "book_id": book_id}
diff --git a/tests/mocks/mock_session.py b/tests/mocks/mock_session.py
index 01aa854..1d972cd 100644
--- a/tests/mocks/mock_session.py
+++ b/tests/mocks/mock_session.py
@@ -1,4 +1,5 @@
-from typing import Optional, List, Any
+from typing import Any, List
+
from tests.mocks.mock_storage import mock_storage
@@ -8,20 +9,13 @@ class MockSession:
def __init__(self):
self.storage = mock_storage
- def add(self, obj: Any):
- """Mock add - not needed for our implementation"""
- pass
+ def add(self, obj: Any): ...
- def commit(self):
- """Mock commit - not needed for our implementation"""
- pass
+ def commit(self): ...
- def refresh(self, obj: Any):
- """Mock refresh - not needed for our implementation"""
- pass
+ def refresh(self, obj: Any): ...
def get(self, model_class, pk: int):
- """Mock get method to retrieve object by primary key"""
if hasattr(model_class, "__name__"):
model_name = model_class.__name__.lower()
else:
@@ -35,12 +29,9 @@ class MockSession:
return self.storage.get_genre(pk)
return None
- def delete(self, obj: Any):
- """Mock delete - handled in storage methods"""
- pass
+ def delete(self, obj: Any): ...
def exec(self, statement):
- """Mock exec method for queries"""
return MockResult([])
diff --git a/tests/mocks/mock_storage.py b/tests/mocks/mock_storage.py
index 9a92fe7..81d8fba 100644
--- a/tests/mocks/mock_storage.py
+++ b/tests/mocks/mock_storage.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Optional
+from typing import Dict, List
class MockStorage:
@@ -15,7 +15,7 @@ class MockStorage:
self.genre_id_counter = 1
def clear_all(self):
- """Clear all data"""
+ """Очистка всех данных"""
self.books.clear()
self.authors.clear()
self.genres.clear()
@@ -33,7 +33,7 @@ class MockStorage:
self.book_id_counter += 1
return book
- def get_book(self, book_id: int) -> Optional[dict]:
+ def get_book(self, book_id: int) -> dict | None:
return self.books.get(book_id)
def get_all_books(self) -> List[dict]:
@@ -42,9 +42,9 @@ class MockStorage:
def update_book(
self,
book_id: int,
- title: Optional[str] = None,
- description: Optional[str] = None,
- ) -> Optional[dict]:
+ title: str | None = None,
+ description: str | None = None,
+ ) -> dict | None:
if book_id not in self.books:
return None
book = self.books[book_id]
@@ -54,7 +54,7 @@ class MockStorage:
book["description"] = description
return book
- def delete_book(self, book_id: int) -> Optional[dict]:
+ def delete_book(self, book_id: int) -> dict | None:
if book_id not in self.books:
return None
book = self.books.pop(book_id)
@@ -74,15 +74,15 @@ class MockStorage:
self.author_id_counter += 1
return author
- def get_author(self, author_id: int) -> Optional[dict]:
+ def get_author(self, author_id: int) -> dict | None:
return self.authors.get(author_id)
def get_all_authors(self) -> List[dict]:
return list(self.authors.values())
def update_author(
- self, author_id: int, name: Optional[str] = None
- ) -> Optional[dict]:
+ self, author_id: int, name: str | None = None
+ ) -> dict | None:
if author_id not in self.authors:
return None
author = self.authors[author_id]
@@ -90,7 +90,7 @@ class MockStorage:
author["name"] = name
return author
- def delete_author(self, author_id: int) -> Optional[dict]:
+ def delete_author(self, author_id: int) -> dict | None:
if author_id not in self.authors:
return None
author = self.authors.pop(author_id)
@@ -107,15 +107,13 @@ class MockStorage:
self.genre_id_counter += 1
return genre
- def get_genre(self, genre_id: int) -> Optional[dict]:
+ def get_genre(self, genre_id: int) -> dict | None:
return self.genres.get(genre)
def get_all_authors(self) -> List[dict]:
return list(self.authors.values())
- def update_genre(
- self, genre_id: int, name: Optional[str] = None
- ) -> Optional[dict]:
+ def update_genre(self, genre_id: int, name: str | None = None) -> dict | None:
if genre_id not in self.genres:
return None
genre = self.genres[genre_id]
@@ -123,7 +121,7 @@ class MockStorage:
genre["name"] = name
return genre
- def delete_genre(self, genre_id: int) -> Optional[dict]:
+ def delete_genre(self, genre_id: int) -> dict | None:
if genre_id not in self.genres:
return None
genre = self.genres.pop(genre_id)
diff --git a/tests/test_authors.py b/tests/test_authors.py
index a24ffd5..f6336f3 100644
--- a/tests/test_authors.py
+++ b/tests/test_authors.py
@@ -1,5 +1,6 @@
import pytest
from fastapi.testclient import TestClient
+
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True)
def setup_database():
- """Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
@@ -29,7 +29,6 @@ def test_create_author():
def test_list_authors():
- # First create an author
client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors")
@@ -42,7 +41,6 @@ def test_list_authors():
def test_get_existing_author():
- # First create an author
client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors/1")
@@ -63,7 +61,6 @@ def test_get_not_existing_author():
def test_update_author():
- # First create an author
client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors/1")
@@ -84,10 +81,7 @@ def test_update_not_existing_author():
def test_delete_author():
- # First create an author
client.post("/authors", json={"name": "Test Author"})
-
- # Update it first
client.put("/authors/1", json={"name": "Updated Author"})
response = client.get("/authors/1")
diff --git a/tests/test_books.py b/tests/test_books.py
index 56a3075..d6e5720 100644
--- a/tests/test_books.py
+++ b/tests/test_books.py
@@ -1,5 +1,6 @@
import pytest
from fastapi.testclient import TestClient
+
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True)
def setup_database():
- """Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
@@ -35,9 +35,7 @@ def test_create_book():
def test_list_books():
- # First create a book
- client.post(
- "/books", json={"title": "Test Book", "description": "Test Description"}
+ client.post("/books", json={"title": "Test Book", "description": "Test Description"}
)
response = client.get("/books")
@@ -50,9 +48,7 @@ def test_list_books():
def test_get_existing_book():
- # First create a book
- client.post(
- "/books", json={"title": "Test Book", "description": "Test Description"}
+ client.post("/books", json={"title": "Test Book", "description": "Test Description"}
)
response = client.get("/books/1")
@@ -74,9 +70,7 @@ def test_get_not_existing_book():
def test_update_book():
- # First create a book
- client.post(
- "/books", json={"title": "Test Book", "description": "Test Description"}
+ client.post("/books", json={"title": "Test Book", "description": "Test Description"}
)
response = client.get("/books/1")
@@ -102,14 +96,8 @@ def test_update_not_existing_book():
def test_delete_book():
- # First create a book
- client.post(
- "/books", json={"title": "Test Book", "description": "Test Description"}
- )
-
- # Update it first
- client.put(
- "/books/1", json={"title": "Updated Book", "description": "Updated Description"}
+ client.post("/books", json={"title": "Test Book", "description": "Test Description"})
+ client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"}
)
response = client.get("/books/1")
diff --git a/tests/test_misc.py b/tests/test_misc.py
index f1f7d44..f24c78e 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1,6 +1,8 @@
-import pytest
from datetime import datetime
+
+import pytest
from fastapi.testclient import TestClient
+
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
@@ -9,20 +11,15 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True)
def setup_database():
- """Setup and cleanup mock database for each test"""
- # Clear data before each test
mock_storage.clear_all()
yield
- # Clear data after each test (optional, but good practice)
mock_storage.clear_all()
-# Test the main page of the application
def test_main_page():
- response = client.get("/") # Send GET request to the main page
+ response = client.get("/api")
try:
- content = response.content.decode("utf-8") # Decode response content
- # Find indices of key elements in the content
+ content = response.content.decode("utf-8")
title_idx = content.index("Welcome to ")
description_idx = content.index("Description: ")
version_idx = content.index("Version: ")
@@ -38,25 +35,16 @@ def test_main_page():
assert content[time_idx + 1] != "<", "Time not provided"
assert content[status_idx + 1] != "<", "Status not provided"
except Exception as e:
- print(f"Error: {e}") # Print error if an exception occurs
- assert False, "Unexpected error" # Force test failure on unexpected error
+ print(f"Error: {e}")
+ assert False, "Unexpected error"
-# Test application info endpoint
def test_app_info_test():
- response = client.get("/api/info") # Send GET request to the info endpoint
+ response = client.get("/api/info")
assert response.status_code == 200, "Invalid response status"
assert response.json()["status"] == "ok", "Status not ok"
assert response.json()["app_info"]["title"] != "", "Title not provided"
assert response.json()["app_info"]["description"] != "", "Description not provided"
assert response.json()["app_info"]["version"] != "", "Version not provided"
- # Check time difference
- assert (
- 0
- < (
- datetime.now() - datetime.fromisoformat(response.json()["server_time"])
- ).total_seconds()
- ), "Negative time difference"
- assert (
- datetime.now() - datetime.fromisoformat(response.json()["server_time"])
- ).total_seconds() < 1, "Time difference too large"
+ assert (0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds()), "Negative time difference"
+ assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
diff --git a/tests/test_relationships.py b/tests/test_relationships.py
index 0ff1004..01cd44a 100644
--- a/tests/test_relationships.py
+++ b/tests/test_relationships.py
@@ -1,5 +1,6 @@
import pytest
from fastapi.testclient import TestClient
+
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
@pytest.fixture(autouse=True)
def setup_database():
- """Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
@@ -30,28 +30,18 @@ def make_genrebook_relationship(genre_id, book_id):
def test_prepare_data():
- # Create books
- assert client.post(
- "/books", json={"title": "Test Book 1", "description": "Test Description 1"}
- ).status_code == 200
- assert client.post(
- "/books", json={"title": "Test Book 2", "description": "Test Description 2"}
- ).status_code == 200
- assert client.post(
- "/books", json={"title": "Test Book 3", "description": "Test Description 3"}
- ).status_code == 200
+ assert (client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"}).status_code == 200)
+ assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200)
+ assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200)
- # Create authors
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
- # Create genres
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
- # Create relationships
make_authorbook_relationship(1, 1)
make_authorbook_relationship(2, 1)
make_authorbook_relationship(1, 2)
@@ -63,8 +53,8 @@ def test_prepare_data():
make_genrebook_relationship(2, 3)
make_genrebook_relationship(3, 3)
+
def test_get_book_authors():
- # Setup test data
test_prepare_data()
response1 = client.get("/books/1/authors")
@@ -91,7 +81,6 @@ def test_get_book_authors():
def test_get_author_books():
- # Setup test data
test_prepare_data()
response1 = client.get("/authors/1/books")