изображение книги

This commit is contained in:
2026-02-01 16:55:43 +03:00
parent 4368ee0d3c
commit 80acdceba6
11 changed files with 196 additions and 45 deletions
Vendored
+1
View File
@@ -1,4 +1,5 @@
.env .env
library_service/static/books/
*.log *.log
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
+3 -1
View File
@@ -1,6 +1,7 @@
"""Модуль DB-моделей книг""" """Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String from sqlalchemy import Column, String
@@ -26,7 +27,8 @@ class Book(BookBase, table=True):
sa_column=Column(String, nullable=False, default="active"), sa_column=Column(String, nullable=False, default="active"),
description="Статус", description="Статус",
) )
embedding: list[float] | None = Field(sa_column=Column(Vector(1024))) embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
authors: List["Author"] = Relationship( authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink back_populates="books", link_model=AuthorBookLink
) )
+1
View File
@@ -46,6 +46,7 @@ class BookRead(BookBase):
id: int = Field(description="Идентификатор") id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус") status: BookStatus = Field(description="Статус")
preview_url: str | None = Field(None, description="URL изображения")
class BookList(SQLModel): class BookList(SQLModel):
+3
View File
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус") status: BookStatus | None = Field(None, description="Статус")
preview_url: str | None = Field(default=None, description="URL изображения")
authors: List[AuthorRead] = Field( authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов" default_factory=list, description="Список авторов"
) )
@@ -52,6 +53,7 @@ class BookWithGenres(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус") status: BookStatus | None = Field(None, description="Статус")
preview_url: str | None = Field(default=None, description="URL изображения")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров") genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
@@ -63,6 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус") status: BookStatus | None = Field(None, description="Статус")
preview_url: str | None = Field(default=None, description="URL изображения")
authors: List[AuthorRead] = Field( authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов" default_factory=list, description="Список авторов"
) )
+105 -22
View File
@@ -1,4 +1,6 @@
"""Модуль работы с книгами""" """Модуль работы с книгами"""
import shutil
from uuid import uuid4
from pydantic import Field from pydantic import Field
from typing_extensions import Annotated from typing_extensions import Annotated
@@ -10,12 +12,12 @@ from sqlalchemy import text, case, distinct
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client from ollama import Client
from sqlmodel import Session, select, col, func from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
from library_service.settings import get_session, OLLAMA_URL from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
from library_service.models.enums import BookStatus from library_service.models.enums import BookStatus
from library_service.models.db import ( from library_service.models.db import (
Author, Author,
@@ -47,9 +49,9 @@ def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса""" """Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec( active_loan = session.exec(
select(BookUserLink) select(BookUserLink)
.where(BookUserLink.book_id == book_id) .where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # noqa: E711 .where(BookUserLink.returned_at == None) # ty: ignore
).first() ).first() # ty: ignore
if active_loan: if active_loan:
active_loan.returned_at = datetime.now(timezone.utc) active_loan.returned_at = datetime.now(timezone.utc)
@@ -72,19 +74,19 @@ def filter_books(
size: int = Query(20, gt=0, le=100), size: int = Query(20, gt=0, le=100),
): ):
statement = select(Book).options( statement = select(Book).options(
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
) )
if min_page_count: if min_page_count:
statement = statement.where(Book.page_count >= min_page_count) statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
if max_page_count: if max_page_count:
statement = statement.where(Book.page_count <= max_page_count) statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
if author_ids: if author_ids:
statement = statement.where( statement = statement.where(
exists().where( exists().where(
AuthorBookLink.book_id == Book.id, AuthorBookLink.book_id == Book.id, # ty: ignore
AuthorBookLink.author_id.in_(author_ids), AuthorBookLink.author_id.in_(author_ids), # ty: ignore
) )
) )
@@ -92,7 +94,7 @@ def filter_books(
for genre_id in genre_ids: for genre_id in genre_ids:
statement = statement.where( statement = statement.where(
exists().where( exists().where(
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
) )
) )
@@ -101,13 +103,13 @@ def filter_books(
if q: if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"] emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb) distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
statement = statement.order_by(keyword_match, distance_col) statement = statement.order_by(keyword_match, distance_col)
else: else:
statement = statement.order_by(Book.id) statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size offset = (page - 1) * size
statement = statement.offset(offset).limit(size) statement = statement.offset(offset).limit(size)
@@ -131,10 +133,16 @@ def create_book(
full_text = book.title + " " + book.description full_text = book.title + " " + book.description
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book = Book(**book.model_dump(), embedding=emb["embedding"]) db_book = Book(**book.model_dump(), embedding=emb["embedding"])
session.add(db_book) session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"}))
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
if db_book.preview_id:
book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png"
return BookRead(**book_data)
@router.get( @router.get(
@@ -145,9 +153,17 @@ def create_book(
) )
def read_books(session: Session = Depends(get_session)): def read_books(session: Session = Depends(get_session)):
"""Возвращает список всех книг""" """Возвращает список всех книг"""
books = session.exec(select(Book)).all() books = session.exec(select(Book)).all() # ty: ignore
books_data = []
for book in books:
book_data = book.model_dump(exclude={"embedding", "preview_id"})
if book.preview_id:
book_data["preview_url"] = f"/static/books/{book.preview_id}.png"
books_data.append(book_data)
return BookList( return BookList(
books=[BookRead(**book.model_dump(exclude={"embedding"})) for book in books], books=[BookRead(**book_data) for book_data in books_data],
total=len(books), total=len(books),
) )
@@ -170,18 +186,20 @@ def get_book(
) )
authors = session.scalars( authors = session.scalars(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
).all() ).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors] author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.scalars( genres = session.scalars(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
).all() ).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump(exclude={"embedding"}) book_data = book.model_dump(exclude={"embedding", "preview_id"})
if book.preview_id:
book_data["preview_url"] = f"/static/books/{book.preview_id}.png"
book_data["authors"] = author_reads book_data["authors"] = author_reads
book_data["genres"] = genre_reads book_data["genres"] = genre_reads
@@ -233,11 +251,18 @@ def update_book(
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book.embedding = emb["embedding"] db_book.embedding = emb["embedding"]
if book_update.page_count is not None:
db_book.page_count = book_update.page_count
session.add(db_book) session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"})) book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
if db_book.preview_id:
book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png"
return BookRead(**book_data)
@router.delete( @router.delete(
@@ -267,3 +292,61 @@ def delete_book(
session.delete(book) session.delete(book)
session.commit() session.commit()
return book_read return book_read
@router.post("/{book_id}/preview")
async def upload_book_preview(
current_user: RequireStaff,
file: UploadFile = File(...),
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
if not file.content_type == "image/png":
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "PNG required")
if (file.size or 0) > 10 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
file_uuid= uuid4()
filename = f"{file_uuid}.png"
file_path = BOOKS_PREVIEW_DIR / filename
with open(file_path, "wb") as f:
shutil.copyfileobj(file.file, f)
book = session.get(Book, book_id)
if not book:
file_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png"
if old_path.exists():
old_path.unlink()
book.preview_id = file_uuid
session.add(book)
session.commit()
return {"preview_url": f"/static/books/{filename}"}
@router.delete("/{book_id}/preview")
async def remove_book_preview(
current_user: RequireStaff,
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png"
if old_path.exists():
old_path.unlink()
book.preview_id = None
session.add(book)
session.commit()
return {"preview_url": None}
+1
View File
@@ -181,6 +181,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает схему базы данных с описаниями полей", description="Возвращает схему базы данных с описаниями полей",
) )
async def api_schema(): async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate() return generator.generate()
+3
View File
@@ -10,6 +10,9 @@ from toml import load
load_dotenv() load_dotenv()
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
with open("pyproject.toml", "r", encoding="utf-8") as f: with open("pyproject.toml", "r", encoding="utf-8") as f:
_pyproject = load(f) _pyproject = load(f)
+25
View File
@@ -234,6 +234,7 @@ $(document).ready(() => {
function renderBook(book) { function renderBook(book) {
$("#book-title").text(book.title); $("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`); $("#book-id").text(`ID: ${book.id}`);
const $coverContainer = $("#book-cover-container");
if (book.page_count && book.page_count > 0) { if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count); $("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden"); $("#book-page-count-text").removeClass("hidden");
@@ -253,6 +254,30 @@ $(document).ready(() => {
$("#book-actions-container").empty(); $("#book-actions-container").empty();
} }
if (book.preview_url) {
$coverContainer.html(`
<img
src="${Utils.escapeHtml(book.preview_url)}"
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
class="w-full h-full object-cover"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
/>
<div class="hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
`);
} else {
$coverContainer.html(`
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
`);
}
if (book.genres && book.genres.length > 0) { if (book.genres && book.genres.length > 0) {
$("#genres-section").removeClass("hidden"); $("#genres-section").removeClass("hidden");
const $genres = $("#genres-container"); const $genres = $("#genres-container");
+20 -17
View File
@@ -65,23 +65,26 @@
<div <div
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto" class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
> >
<div <div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4" id="book-cover-container"
> class="w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100"
<svg >
class="w-20 h-20 text-white opacity-80" <div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
fill="none" <svg
stroke="currentColor" class="w-20 h-20 text-white opacity-80"
viewBox="0 0 24 24" fill="none"
> stroke="currentColor"
<path viewBox="0 0 24 24"
stroke-linecap="round" >
stroke-linejoin="round" <path
stroke-width="1.5" stroke-linecap="round"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" stroke-linejoin="round"
></path> stroke-width="1.5"
</svg> d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
</div> ></path>
</svg>
</div>
</div>
<div <div
id="book-status-container" id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4" class="relative w-full flex justify-center z-10 mb-4"
@@ -0,0 +1,33 @@
"""Book preview
Revision ID: abbc38275032
Revises: 6c616cc9d1f0
Create Date: 2026-02-01 14:41:14.611420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = 'abbc38275032'
down_revision: Union[str, None] = '6c616cc9d1f0'
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.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
op.drop_column('book', 'preview_id')
# ### end Alembic commands ###
Generated
+1 -5
View File
@@ -479,7 +479,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -487,7 +486,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -495,7 +493,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -503,7 +500,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -631,7 +627,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]] [[package]]
name = "lib" name = "lib"
version = "0.8.0" version = "0.8.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },