From 9bad7b6e6319f43e533044328001226eb406ef71 Mon Sep 17 00:00:00 2001
From: meijiali <你的邮箱@xxx.com>
Date: Fri, 3 Jul 2026 11:12:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E7=83=AD=E6=A6=9C?=
=?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7=20MVP=20?=
=?UTF-8?q?=E5=9F=BA=E7=BA=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 16 +
Dockerfile | 23 +
app/__init__.py | 1 +
app/config.py | 41 +
app/db.py | 45 +
app/main.py | 161 +++
app/models.py | 176 ++++
app/platforms/.gitkeep | 1 +
app/platforms/base.py | 117 +++
app/platforms/douyin.py | 99 ++
app/platforms/xiaohongshu.py | 84 ++
app/prompts/comment_analysis.txt | 4 +
app/prompts/report_summary.txt | 3 +
app/schemas.py | 40 +
app/services/.gitkeep | 1 +
app/services/__init__.py | 1 +
app/services/ai_service.py | 140 +++
app/services/export_service.py | 104 ++
app/services/report_service.py | 162 +++
app/services/task_service.py | 243 +++++
app/static/.gitkeep | 1 +
app/static/app.css | 7 +
app/static/app.js | 93 ++
app/templates/.gitkeep | 1 +
app/templates/base.html | 35 +
app/templates/hotspots/report.html | 26 +
app/templates/index.html | 66 ++
app/templates/items/detail.html | 34 +
app/templates/macros/label_tags.html | 7 +
app/templates/macros/sentiment_badge.html | 10 +
app/templates/macros/status_badge.html | 4 +
app/templates/partials/task_rows.html | 21 +
app/templates/tasks/detail.html | 47 +
app/templating.py | 33 +
docker-compose.yml | 9 +
pyproject.toml | 38 +
tests/__init__.py | 1 +
tests/fixtures/.gitkeep | 1 +
tests/helpers.py | 31 +
tests/integration/__init__.py | 1 +
tests/integration/test_failure_tolerance.py | 51 +
tests/integration/test_health.py | 12 +
tests/integration/test_routes.py | 146 +++
tests/integration/test_task_creation.py | 118 +++
tests/integration/test_task_flow_douyin.py | 39 +
.../integration/test_task_flow_xiaohongshu.py | 130 +++
tests/integration/test_task_recovery.py | 40 +
tests/unit/__init__.py | 1 +
tests/unit/test_ai_schema.py | 119 +++
tests/unit/test_comment_pagination.py | 51 +
tests/unit/test_config.py | 76 ++
tests/unit/test_deployment_config.py | 17 +
tests/unit/test_douyin_mapping.py | 120 +++
tests/unit/test_export.py | 19 +
tests/unit/test_models.py | 88 ++
tests/unit/test_report_stats.py | 76 ++
tests/unit/test_task_executor.py | 12 +
tests/unit/test_template_filters.py | 13 +
tests/unit/test_xiaohongshu_mapping.py | 65 ++
uv.lock | 937 ++++++++++++++++++
60 files changed, 4058 insertions(+)
create mode 100644 .env.example
create mode 100644 Dockerfile
create mode 100644 app/__init__.py
create mode 100644 app/config.py
create mode 100644 app/db.py
create mode 100644 app/main.py
create mode 100644 app/models.py
create mode 100644 app/platforms/.gitkeep
create mode 100644 app/platforms/base.py
create mode 100644 app/platforms/douyin.py
create mode 100644 app/platforms/xiaohongshu.py
create mode 100644 app/prompts/comment_analysis.txt
create mode 100644 app/prompts/report_summary.txt
create mode 100644 app/schemas.py
create mode 100644 app/services/.gitkeep
create mode 100644 app/services/__init__.py
create mode 100644 app/services/ai_service.py
create mode 100644 app/services/export_service.py
create mode 100644 app/services/report_service.py
create mode 100644 app/services/task_service.py
create mode 100644 app/static/.gitkeep
create mode 100644 app/static/app.css
create mode 100644 app/static/app.js
create mode 100644 app/templates/.gitkeep
create mode 100644 app/templates/base.html
create mode 100644 app/templates/hotspots/report.html
create mode 100644 app/templates/index.html
create mode 100644 app/templates/items/detail.html
create mode 100644 app/templates/macros/label_tags.html
create mode 100644 app/templates/macros/sentiment_badge.html
create mode 100644 app/templates/macros/status_badge.html
create mode 100644 app/templates/partials/task_rows.html
create mode 100644 app/templates/tasks/detail.html
create mode 100644 app/templating.py
create mode 100644 docker-compose.yml
create mode 100644 pyproject.toml
create mode 100644 tests/__init__.py
create mode 100644 tests/fixtures/.gitkeep
create mode 100644 tests/helpers.py
create mode 100644 tests/integration/__init__.py
create mode 100644 tests/integration/test_failure_tolerance.py
create mode 100644 tests/integration/test_health.py
create mode 100644 tests/integration/test_routes.py
create mode 100644 tests/integration/test_task_creation.py
create mode 100644 tests/integration/test_task_flow_douyin.py
create mode 100644 tests/integration/test_task_flow_xiaohongshu.py
create mode 100644 tests/integration/test_task_recovery.py
create mode 100644 tests/unit/__init__.py
create mode 100644 tests/unit/test_ai_schema.py
create mode 100644 tests/unit/test_comment_pagination.py
create mode 100644 tests/unit/test_config.py
create mode 100644 tests/unit/test_deployment_config.py
create mode 100644 tests/unit/test_douyin_mapping.py
create mode 100644 tests/unit/test_export.py
create mode 100644 tests/unit/test_models.py
create mode 100644 tests/unit/test_report_stats.py
create mode 100644 tests/unit/test_task_executor.py
create mode 100644 tests/unit/test_template_filters.py
create mode 100644 tests/unit/test_xiaohongshu_mapping.py
create mode 100644 uv.lock
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a3cea82
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,16 @@
+APP_ENV=development
+DATABASE_URL=sqlite:///./data/app.db
+TIKHUB_API_KEY=
+TIKHUB_BASE_URL=https://api.tikhub.io
+AI_PROVIDER=openai-compatible
+AI_BASE_URL=
+AI_API_KEY=
+AI_MODEL=
+AI_BATCH_SIZE=20
+AI_CONCURRENCY=2
+AI_MAX_RETRIES=3
+AI_TIMEOUT_SECONDS=30
+HTTP_TIMEOUT_SECONDS=20
+HTTP_MAX_RETRIES=3
+# 评论分页请求间隔秒数,支持浮点数,默认 1.5 秒。
+CRAWL_PAGE_INTERVAL_SECONDS=1.5
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..89aa3c1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+RUN adduser --disabled-password --gecos "" appuser
+
+COPY pyproject.toml uv.lock ./
+RUN pip install --no-cache-dir uv \
+ && uv pip install --system --no-cache .
+
+COPY app ./app
+COPY .env.example ./.env.example
+
+RUN mkdir -p /app/data && chown -R appuser:appuser /app
+
+USER appuser
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..3aaf944
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+"""Application package for the hot comments analysis tool."""
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..5b52d3d
--- /dev/null
+++ b/app/config.py
@@ -0,0 +1,41 @@
+from functools import lru_cache
+
+from pydantic import field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ app_env: str = "development"
+ database_url: str = "sqlite:///data/app.db"
+ tikhub_api_key: str = ""
+ tikhub_base_url: str = "https://api.tikhub.io"
+ ai_provider: str = "openai-compatible"
+ ai_base_url: str = ""
+ ai_api_key: str = ""
+ ai_model: str = ""
+ ai_batch_size: int = 20
+ ai_concurrency: int = 2
+ ai_max_retries: int = 3
+ ai_timeout_seconds: int = 30
+ http_timeout_seconds: int = 20
+ http_max_retries: int = 3
+ crawl_page_interval_seconds: float = 1.5
+
+ hot_limit_min: int = 1
+ hot_limit_max: int = 10
+ item_limit_per_hot_min: int = 1
+ item_limit_per_hot_max: int = 10
+ comment_limit_per_item_min: int = 10
+ comment_limit_per_item_max: int = 100
+
+ @field_validator("ai_concurrency", mode="after")
+ @classmethod
+ def cap_ai_concurrency(cls, value: int) -> int:
+ return min(value, 3)
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
diff --git a/app/db.py b/app/db.py
new file mode 100644
index 0000000..8ca241b
--- /dev/null
+++ b/app/db.py
@@ -0,0 +1,45 @@
+from collections.abc import Generator
+from pathlib import Path
+
+from sqlalchemy import create_engine, event
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+from app.config import get_settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def create_sqlite_engine(database_url: str):
+ if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
+ db_path = Path(database_url.removeprefix("sqlite:///"))
+ if db_path.parent != Path("."):
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+
+ connect_args = {"check_same_thread": False, "timeout": 10}
+ engine = create_engine(database_url, connect_args=connect_args)
+ engine.dialect.connect_args = connect_args
+
+ @event.listens_for(engine, "connect")
+ def set_sqlite_pragma(dbapi_connection, _connection_record):
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA journal_mode=WAL")
+ cursor.close()
+
+ return engine
+
+
+engine = create_sqlite_engine(get_settings().database_url)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+
+def init_db() -> None:
+ import app.models # noqa: F401
+
+ Base.metadata.create_all(engine)
+
+
+def get_db_session() -> Generator[Session]:
+ with SessionLocal() as session:
+ yield session
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..995678d
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,161 @@
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from urllib.parse import quote
+
+from fastapi import Depends, FastAPI, HTTPException, Request, status
+from fastapi.responses import HTMLResponse
+from fastapi.responses import Response
+from fastapi.staticfiles import StaticFiles
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db import SessionLocal, get_db_session, init_db
+from app.models import Comment, ContentItem, Hotspot, Report
+from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse
+from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown
+from app.services.task_service import (
+ RUNNING_TASK_MESSAGE,
+ create_task,
+ get_task,
+ has_running_task,
+ list_tasks,
+ recover_running_tasks,
+)
+from app.templating import templates
+
+
+@asynccontextmanager
+async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
+ init_db()
+ with SessionLocal() as session:
+ recover_running_tasks(session)
+ yield
+
+
+app = FastAPI(title="热榜评论分析工具", lifespan=lifespan)
+app.mount("/static", StaticFiles(directory="app/static"), name="static")
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+
+@app.get("/", response_class=HTMLResponse)
+def index_page(request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
+ return templates.TemplateResponse(
+ request,
+ "index.html",
+ {"tasks": list_tasks(session)},
+ )
+
+
+@app.post("/api/tasks", response_model=CreateTaskResponse, status_code=status.HTTP_201_CREATED)
+def create_task_api(
+ request: CreateTaskRequest,
+ session: Session = Depends(get_db_session),
+) -> CreateTaskResponse:
+ if has_running_task(session):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=RUNNING_TASK_MESSAGE)
+
+ task = create_task(session, request)
+ return CreateTaskResponse(task_id=task.id, status=task.status)
+
+
+@app.get("/api/tasks", response_model=list[TaskResponse])
+def list_tasks_api(session: Session = Depends(get_db_session)) -> list[TaskResponse]:
+ return [TaskResponse.model_validate(task) for task in list_tasks(session)]
+
+
+@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
+def get_task_api(task_id: str, session: Session = Depends(get_db_session)) -> TaskResponse:
+ task = get_task(session, task_id)
+ if task is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
+ return TaskResponse.model_validate(task)
+
+
+@app.get("/tasks/{task_id}", response_class=HTMLResponse)
+def task_detail_page(task_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
+ task = get_task(session, task_id)
+ if task is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
+ hotspots = list(session.scalars(select(Hotspot).where(Hotspot.task_id == task.id).order_by(Hotspot.rank)))
+ return templates.TemplateResponse(request, "tasks/detail.html", {"task": task, "hotspots": hotspots})
+
+
+@app.get("/hotspots/{hotspot_id}/report", response_class=HTMLResponse)
+def hotspot_report_page(hotspot_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
+ hotspot = session.get(Hotspot, hotspot_id)
+ if hotspot is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="热点不存在")
+ task = get_task(session, hotspot.task_id)
+ report = session.scalar(select(Report).where(Report.hotspot_id == hotspot.id, Report.report_type == "hotspot"))
+ return templates.TemplateResponse(request, "hotspots/report.html", {"task": task, "hotspot": hotspot, "report": report})
+
+
+@app.get("/items/{item_id}", response_class=HTMLResponse)
+def item_detail_page(item_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
+ item = session.get(ContentItem, item_id)
+ if item is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="内容条目不存在")
+ hotspot = session.get(Hotspot, item.hotspot_id)
+ task = get_task(session, item.task_id)
+ report = session.scalar(select(Report).where(Report.content_item_id == item.id, Report.report_type == "item"))
+ comments = list(
+ session.scalars(
+ select(Comment)
+ .where(Comment.content_item_id == item.id)
+ .order_by(Comment.like_count.desc().nullslast(), Comment.comment_time.desc().nullslast())
+ .limit(100)
+ )
+ )
+ return templates.TemplateResponse(
+ request,
+ "items/detail.html",
+ {"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments},
+ )
+
+
+@app.get("/api/export/items/{item_id}/comments.csv")
+def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response:
+ try:
+ content, filename = export_item_comments_csv(session, item_id)
+ except ValueError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
+ return Response(
+ content=content,
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@app.get("/api/export/hotspots/{hotspot_id}/comments.csv")
+def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
+ try:
+ content, filename = export_hotspot_comments_csv(session, hotspot_id)
+ except ValueError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
+ return Response(
+ content=content,
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@app.get("/api/export/hotspots/{hotspot_id}.md")
+def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
+ try:
+ content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id)
+ except ValueError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
+ return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+
+
+@app.get("/api/export/items/{item_id}.md")
+def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response:
+ try:
+ content, filename = export_report_markdown(session, report_type="item", item_id=item_id)
+ except ValueError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
+ return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..9202cd5
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,176 @@
+import uuid
+from datetime import UTC, datetime
+
+from sqlalchemy import CheckConstraint, Float, ForeignKey, Index, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db import Base
+
+
+def new_uuid() -> str:
+ return str(uuid.uuid4())
+
+
+def utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+class Task(Base):
+ __tablename__ = "tasks"
+ __table_args__ = (
+ CheckConstraint("status IN ('running', 'success', 'failed')", name="ck_tasks_status"),
+ CheckConstraint(
+ "analysis_status IN ('normal', 'insufficient')",
+ name="ck_tasks_analysis_status",
+ ),
+ )
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
+ platform: Mapped[str] = mapped_column(String(32), nullable=False)
+ status: Mapped[str] = mapped_column(String(16), nullable=False, default="running")
+ analysis_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal")
+ analysis_success_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
+ hotspot_limit: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
+ item_limit_per_hotspot: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
+ comment_limit_per_item: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
+ total_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ processed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ successful_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ failed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ error_stage: Mapped[str | None] = mapped_column(String(64))
+ error_type: Mapped[str | None] = mapped_column(String(64))
+ error_message: Mapped[str | None] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
+ started_at: Mapped[datetime | None] = mapped_column()
+ finished_at: Mapped[datetime | None] = mapped_column()
+
+ hotspots: Mapped[list["Hotspot"]] = relationship(back_populates="task")
+ content_items: Mapped[list["ContentItem"]] = relationship(back_populates="task")
+ comments: Mapped[list["Comment"]] = relationship(back_populates="task")
+ reports: Mapped[list["Report"]] = relationship(back_populates="task")
+
+
+class Hotspot(Base):
+ __tablename__ = "hotspots"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
+ task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
+ platform: Mapped[str] = mapped_column(String(32), nullable=False)
+ source_hot_id: Mapped[str | None] = mapped_column(String(128))
+ rank: Mapped[int | None] = mapped_column(Integer)
+ title: Mapped[str] = mapped_column(Text, nullable=False)
+ heat_value: Mapped[str | None] = mapped_column(String(128))
+ raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
+
+ task: Mapped[Task] = relationship(back_populates="hotspots")
+ content_items: Mapped[list["ContentItem"]] = relationship(back_populates="hotspot")
+ comments: Mapped[list["Comment"]] = relationship(back_populates="hotspot")
+ reports: Mapped[list["Report"]] = relationship(back_populates="hotspot")
+
+
+class ContentItem(Base):
+ __tablename__ = "content_items"
+ __table_args__ = (
+ Index("ix_content_items_task_hotspot", "task_id", "hotspot_id"),
+ )
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
+ task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
+ hotspot_id: Mapped[str] = mapped_column(ForeignKey("hotspots.id"), nullable=False)
+ platform: Mapped[str] = mapped_column(String(32), nullable=False)
+ source_item_id: Mapped[str] = mapped_column(String(128), nullable=False)
+ item_type: Mapped[str] = mapped_column(String(32), nullable=False)
+ title: Mapped[str | None] = mapped_column(Text)
+ summary: Mapped[str | None] = mapped_column(Text)
+ url: Mapped[str | None] = mapped_column(Text)
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
+ error_stage: Mapped[str | None] = mapped_column(String(64))
+ error_type: Mapped[str | None] = mapped_column(String(64))
+ error_message: Mapped[str | None] = mapped_column(Text)
+ raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
+
+ task: Mapped[Task] = relationship(back_populates="content_items")
+ hotspot: Mapped[Hotspot] = relationship(back_populates="content_items")
+ comments: Mapped[list["Comment"]] = relationship(back_populates="content_item")
+ reports: Mapped[list["Report"]] = relationship(back_populates="content_item")
+
+
+class Comment(Base):
+ __tablename__ = "comments"
+ __table_args__ = (
+ Index("ix_comments_task_content_item", "task_id", "content_item_id"),
+ )
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
+ task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
+ hotspot_id: Mapped[str] = mapped_column(ForeignKey("hotspots.id"), nullable=False)
+ content_item_id: Mapped[str] = mapped_column(ForeignKey("content_items.id"), nullable=False)
+ platform: Mapped[str] = mapped_column(String(32), nullable=False)
+ source_comment_id: Mapped[str | None] = mapped_column(String(128))
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ author: Mapped[str | None] = mapped_column(Text)
+ like_count: Mapped[int | None] = mapped_column(Integer)
+ comment_time: Mapped[datetime | None] = mapped_column()
+ sentiment: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
+ labels: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+ reason: Mapped[str | None] = mapped_column(Text)
+ ai_analysis_status: Mapped[str] = mapped_column(String(32), nullable=False, default="skipped")
+ raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ ai_raw_response: Mapped[str | None] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
+
+ task: Mapped[Task] = relationship(back_populates="comments")
+ hotspot: Mapped[Hotspot] = relationship(back_populates="comments")
+ content_item: Mapped[ContentItem] = relationship(back_populates="comments")
+
+
+class Report(Base):
+ __tablename__ = "reports"
+ __table_args__ = (
+ Index("ix_reports_task_report_type", "task_id", "report_type"),
+ )
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
+ task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
+ hotspot_id: Mapped[str | None] = mapped_column(ForeignKey("hotspots.id"))
+ content_item_id: Mapped[str | None] = mapped_column(ForeignKey("content_items.id"))
+ report_type: Mapped[str] = mapped_column(String(32), nullable=False)
+ title: Mapped[str] = mapped_column(Text, nullable=False)
+ data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ markdown: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ metrics_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ typical_comments_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+ summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ markdown_content: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
+
+ task: Mapped[Task] = relationship(back_populates="reports")
+ hotspot: Mapped[Hotspot | None] = relationship(back_populates="reports")
+ content_item: Mapped[ContentItem | None] = relationship(back_populates="reports")
+
+
+def create_report_record(
+ *,
+ task_id: str,
+ report_type: str,
+ title: str,
+ hotspot_id: str | None = None,
+ content_item_id: str | None = None,
+ data: str = "{}",
+ markdown: str = "",
+) -> Report:
+ if report_type == "hotspot" and not hotspot_id:
+ raise ValueError("hotspot report requires hotspot_id")
+ if report_type == "content_item" and not content_item_id:
+ raise ValueError("content item report requires content_item_id")
+ return Report(
+ task_id=task_id,
+ report_type=report_type,
+ title=title,
+ hotspot_id=hotspot_id,
+ content_item_id=content_item_id,
+ data=data,
+ markdown=markdown,
+ )
diff --git a/app/platforms/.gitkeep b/app/platforms/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/platforms/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/app/platforms/base.py b/app/platforms/base.py
new file mode 100644
index 0000000..e53d702
--- /dev/null
+++ b/app/platforms/base.py
@@ -0,0 +1,117 @@
+import time
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any
+
+import httpx
+
+
+@dataclass
+class HotspotData:
+ source_hot_id: str | None
+ title: str
+ rank: int | None = None
+ heat_value: str | None = None
+ raw_data: dict[str, Any] | None = None
+
+
+@dataclass
+class ContentItemData:
+ source_item_id: str
+ item_type: str
+ title: str | None = None
+ summary: str | None = None
+ url: str | None = None
+ raw_data: dict[str, Any] | None = None
+
+
+@dataclass
+class CommentData:
+ source_comment_id: str | None
+ content: str
+ author: str | None = None
+ like_count: int | None = None
+ comment_time: datetime | None = None
+ raw_data: dict[str, Any] | None = None
+
+
+class PlatformAPIError(Exception):
+ def __init__(self, message: str, *, error_type: str, status_code: int | None = None):
+ super().__init__(message)
+ self.error_type = error_type
+ self.status_code = status_code
+
+
+class TikHubClient:
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ api_key: str,
+ timeout_seconds: int = 20,
+ max_retries: int = 3,
+ http_client: httpx.Client | None = None,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.api_key = api_key
+ self.timeout_seconds = timeout_seconds
+ self.max_retries = max_retries
+ self._http_client = http_client or httpx.Client(timeout=timeout_seconds)
+
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
+ return self._request("GET", path, params=params)
+
+ def post(self, path: str, *, json: dict[str, Any] | None = None) -> dict[str, Any]:
+ return self._request("POST", path, json=json)
+
+ def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]:
+ url = f"{self.base_url}{path}"
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
+ last_status: int | None = None
+ for attempt in range(self.max_retries + 1):
+ try:
+ response = self._http_client.request(method, url, headers=headers, **kwargs)
+ except httpx.RequestError as exc:
+ if attempt >= self.max_retries:
+ raise PlatformAPIError("External API request failed", error_type="network_error") from exc
+ time.sleep(2**attempt)
+ continue
+
+ last_status = response.status_code
+ if response.status_code == 429:
+ if attempt >= self.max_retries:
+ raise PlatformAPIError(
+ "External API rate limited",
+ error_type="rate_limited",
+ status_code=response.status_code,
+ )
+ time.sleep(2**attempt)
+ continue
+ if response.is_error:
+ raise PlatformAPIError(
+ f"External API returned HTTP {response.status_code}",
+ error_type="api_error",
+ status_code=response.status_code,
+ )
+ return response.json()
+
+ raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
+
+
+def parse_timestamp(value: Any) -> datetime | None:
+ if value in (None, ""):
+ return None
+ try:
+ number = int(value)
+ except (TypeError, ValueError):
+ return None
+ if number > 10_000_000_000:
+ number = number // 1000
+ return datetime.fromtimestamp(number, tz=UTC)
+
+
+def first_present(data: dict[str, Any], *keys: str) -> Any:
+ for key in keys:
+ if key in data and data[key] not in (None, ""):
+ return data[key]
+ return None
diff --git a/app/platforms/douyin.py b/app/platforms/douyin.py
new file mode 100644
index 0000000..4e8f16f
--- /dev/null
+++ b/app/platforms/douyin.py
@@ -0,0 +1,99 @@
+from typing import Any
+
+from app.platforms.base import CommentData, ContentItemData, HotspotData, TikHubClient, first_present, parse_timestamp
+
+
+class DouyinPlatform:
+ def __init__(self, client: TikHubClient | None) -> None:
+ self.client = client
+
+ def map_hotspots(self, payload: dict[str, Any], *, limit: int) -> list[HotspotData]:
+ data = payload.get("data", {})
+ items = data.get("word_list") or data.get("list") or data.get("item_list") or []
+ result = []
+ for index, item in enumerate(items[:limit], start=1):
+ result.append(
+ HotspotData(
+ source_hot_id=str(item.get("query_id")) if item.get("query_id") is not None else None,
+ title=str(first_present(item, "title", "sentence", "word") or ""),
+ rank=item.get("rank") or index,
+ heat_value=str(item.get("hot_score")) if item.get("hot_score") is not None else None,
+ raw_data=item,
+ )
+ )
+ return result
+
+ def map_items(self, payload: dict[str, Any], *, limit: int) -> list[ContentItemData]:
+ data = payload.get("data", [])
+ raw_items = data.get("business_data") or data.get("data") or data.get("items") or [] if isinstance(data, dict) else data
+ result = []
+ for item in raw_items[:limit]:
+ nested = item.get("data", item) if isinstance(item, dict) else {}
+ aweme = nested.get("aweme_info", nested) if isinstance(nested, dict) else {}
+ aweme_id = aweme.get("aweme_id")
+ if not aweme_id:
+ continue
+ result.append(
+ ContentItemData(
+ source_item_id=str(aweme_id),
+ item_type="video",
+ title=aweme.get("desc"),
+ summary=aweme.get("desc"),
+ url=aweme.get("share_url"),
+ raw_data=aweme,
+ )
+ )
+ return result
+
+ def map_comments(self, payload: dict[str, Any], *, limit: int) -> list[CommentData]:
+ comments = payload.get("comments") or payload.get("data", {}).get("comments") or []
+ result = []
+ for comment in comments[:limit]:
+ content = comment.get("text")
+ if not content:
+ continue
+ result.append(
+ CommentData(
+ source_comment_id=first_present(comment, "comment_id", "cid"),
+ content=str(content),
+ author=self._author_name(comment),
+ like_count=comment.get("digg_count"),
+ comment_time=parse_timestamp(first_present(comment, "create_time", "create_time_str")),
+ raw_data=comment,
+ )
+ )
+ return result
+
+ def fetch_hotspots(self, *, limit: int) -> list[HotspotData]:
+ payload = self.client.get(
+ "/api/v1/douyin/creator/fetch_creator_hot_spot_billboard",
+ params={"billboard_tag": 0, "hot_search_type": 1},
+ )
+ return self.map_hotspots(payload, limit=limit)
+
+ def search_items_by_hotspot(self, keyword: str, *, limit: int) -> list[ContentItemData]:
+ payload = self.client.post(
+ "/api/v1/douyin/search/fetch_video_search_v2",
+ json={
+ "keyword": keyword,
+ "cursor": 0,
+ "sort_type": "0",
+ "publish_time": "0",
+ "filter_duration": "0",
+ "content_type": "1",
+ "search_id": "",
+ "backtrace": "",
+ },
+ )
+ return self.map_items(payload, limit=limit)
+
+ def fetch_comments(self, source_item_id: str, *, limit: int) -> list[CommentData]:
+ payload = self.client.get(
+ "/api/v1/douyin/app/v3/fetch_video_comments",
+ params={"aweme_id": source_item_id, "cursor": 0, "count": 20},
+ )
+ return self.map_comments(payload, limit=limit)
+
+ def _author_name(self, comment: dict[str, Any]) -> str | None:
+ user = comment.get("user") or {}
+ return user.get("nickname") or user.get("name") if isinstance(user, dict) else None
diff --git a/app/platforms/xiaohongshu.py b/app/platforms/xiaohongshu.py
new file mode 100644
index 0000000..314bd27
--- /dev/null
+++ b/app/platforms/xiaohongshu.py
@@ -0,0 +1,84 @@
+from typing import Any
+
+from app.platforms.base import CommentData, ContentItemData, HotspotData, TikHubClient, first_present, parse_timestamp
+
+
+class XiaohongshuPlatform:
+ def __init__(self, client: TikHubClient | None) -> None:
+ self.client = client
+
+ def map_hotspots(self, payload: dict[str, Any], *, limit: int) -> list[HotspotData]:
+ items = payload.get("data", {}).get("data", {}).get("items", [])
+ hotspots = []
+ for index, item in enumerate(items[:limit], start=1):
+ hotspots.append(
+ HotspotData(
+ source_hot_id=str(item.get("id")) if item.get("id") is not None else None,
+ title=str(item.get("title") or ""),
+ rank=index,
+ heat_value=str(item.get("score")) if item.get("score") is not None else None,
+ raw_data=item,
+ )
+ )
+ return hotspots
+
+ def map_items(self, payload: dict[str, Any], *, limit: int) -> list[ContentItemData]:
+ raw_items = payload.get("data", {}).get("data", {}).get("items", [])
+ notes = [item.get("note", item) for item in raw_items]
+ notes.sort(key=lambda note: 0 if int(note.get("comments_count") or 0) > 0 else 1)
+ result = []
+ for note in notes[:limit]:
+ note_id = note.get("id")
+ if not note_id:
+ continue
+ result.append(
+ ContentItemData(
+ source_item_id=str(note_id),
+ item_type="note",
+ title=note.get("title"),
+ summary=note.get("desc"),
+ url=note.get("url"),
+ raw_data=note,
+ )
+ )
+ return result
+
+ def map_comments(self, payload: dict[str, Any], *, limit: int) -> list[CommentData]:
+ comments = payload.get("data", {}).get("data", {}).get("comments", [])
+ result = []
+ for comment in comments[:limit]:
+ content = first_present(comment, "content", "text")
+ if not content:
+ continue
+ result.append(
+ CommentData(
+ source_comment_id=first_present(comment, "comment_id", "id"),
+ content=str(content),
+ author=self._author_name(comment),
+ like_count=comment.get("like_count"),
+ comment_time=parse_timestamp(first_present(comment, "create_time", "create_time_str")),
+ raw_data=comment,
+ )
+ )
+ return result
+
+ def fetch_hotspots(self, *, limit: int) -> list[HotspotData]:
+ return self.map_hotspots(self.client.get("/api/v1/xiaohongshu/web_v2/fetch_hot_list"), limit=limit)
+
+ def search_items_by_hotspot(self, keyword: str, *, limit: int) -> list[ContentItemData]:
+ payload = self.client.get(
+ "/api/v1/xiaohongshu/app_v2/search_notes",
+ params={"keyword": keyword, "page": 1, "sort": "general", "note_type": 0},
+ )
+ return self.map_items(payload, limit=limit)
+
+ def fetch_comments(self, source_item_id: str, *, limit: int) -> list[CommentData]:
+ payload = self.client.get(
+ "/api/v1/xiaohongshu/app_v2/get_note_comments",
+ params={"note_id": source_item_id, "cursor": "", "index": 0, "pageArea": "UNFOLDED", "sort_strategy": "latest_v2"},
+ )
+ return self.map_comments(payload, limit=limit)
+
+ def _author_name(self, comment: dict[str, Any]) -> str | None:
+ user = comment.get("user_info") or comment.get("user") or {}
+ return user.get("nickname") or user.get("name") if isinstance(user, dict) else None
diff --git a/app/prompts/comment_analysis.txt b/app/prompts/comment_analysis.txt
new file mode 100644
index 0000000..e6a3a63
--- /dev/null
+++ b/app/prompts/comment_analysis.txt
@@ -0,0 +1,4 @@
+只返回 JSON Array,不输出 Markdown 或解释性自然语言。
+请原样回填输入中的 comment_id,不得修改或生成新 ID。
+sentiment 只能是 positive、negative、neutral、unknown。
+labels 使用 1 到 3 个简短中文短语;无法判断时 labels 可为空数组。
diff --git a/app/prompts/report_summary.txt b/app/prompts/report_summary.txt
new file mode 100644
index 0000000..78464e7
--- /dev/null
+++ b/app/prompts/report_summary.txt
@@ -0,0 +1,3 @@
+根据输入的情绪分布、Top 标签和典型评论,生成事实性中文总结。
+只输出纯文本,不使用 Markdown,不超过指定字数。
+总结失败时由后端使用默认文案兜底。
diff --git a/app/schemas.py b/app/schemas.py
new file mode 100644
index 0000000..e560bb7
--- /dev/null
+++ b/app/schemas.py
@@ -0,0 +1,40 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field
+
+
+class CreateTaskRequest(BaseModel):
+ platform: Literal["xiaohongshu", "douyin"]
+ hotspot_limit: int = Field(default=5, ge=1, le=10)
+ item_limit_per_hotspot: int = Field(
+ default=5,
+ ge=1,
+ le=10,
+ validation_alias=AliasChoices("item_limit_per_hotspot", "item_limit"),
+ )
+ comment_limit_per_item: int = Field(default=50, ge=10, le=100)
+
+
+class TaskResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ task_id: str = Field(validation_alias="id", serialization_alias="task_id")
+ platform: str
+ status: str
+ analysis_status: str
+ analysis_success_rate: float
+ hotspot_limit: int
+ item_limit_per_hotspot: int
+ comment_limit_per_item: int
+ total_items_count: int
+ processed_items_count: int
+ successful_items_count: int
+ failed_items_count: int
+ error_message: str | None
+ created_at: datetime
+
+
+class CreateTaskResponse(BaseModel):
+ task_id: str
+ status: str
diff --git a/app/services/.gitkeep b/app/services/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/services/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000..5917348
--- /dev/null
+++ b/app/services/__init__.py
@@ -0,0 +1 @@
+"""Service modules for task execution, crawling, AI analysis, reports, and export."""
diff --git a/app/services/ai_service.py b/app/services/ai_service.py
new file mode 100644
index 0000000..06e0d19
--- /dev/null
+++ b/app/services/ai_service.py
@@ -0,0 +1,140 @@
+import json
+import time
+from dataclasses import dataclass
+from typing import Literal
+
+import httpx
+from pydantic import BaseModel, Field, ValidationError
+
+
+Sentiment = Literal["positive", "negative", "neutral", "unknown"]
+
+
+@dataclass(frozen=True)
+class AIAnalysisResult:
+ comment_id: str
+ sentiment: str
+ labels: list[str]
+ reason: str = ""
+ ai_analysis_status: str = "success"
+
+
+class AIAnalysisItem(BaseModel):
+ comment_id: str
+ sentiment: Sentiment
+ labels: list[str] = Field(default_factory=list, max_length=3)
+ reason: str = ""
+
+
+def build_comment_prompt(comments: list[dict[str, str]]) -> str:
+ payload = [
+ {
+ "comment_id": str(comment["comment_id"]),
+ "content": str(comment.get("content", ""))[:150],
+ }
+ for comment in comments
+ ]
+ return (
+ "只返回 JSON Array,不输出 Markdown 或解释性自然语言。"
+ "请原样回填输入中的 comment_id,不得修改或生成新 ID。"
+ "sentiment 只能是 positive、negative、neutral、unknown;labels 最多 3 个简短中文短语。\n"
+ f"{json.dumps(payload, ensure_ascii=False)}"
+ )
+
+
+def parse_ai_comment_response(response_text: str, *, expected_comment_ids: set[str]) -> list[AIAnalysisResult]:
+ try:
+ raw = json.loads(response_text)
+ except json.JSONDecodeError as exc:
+ raise ValueError("AI response is not valid JSON") from exc
+ if not isinstance(raw, list):
+ raise ValueError("AI response must be a JSON Array")
+
+ results = []
+ for item in raw:
+ try:
+ parsed = AIAnalysisItem.model_validate(item)
+ except ValidationError as exc:
+ raise ValueError("AI response item does not match schema") from exc
+ if parsed.comment_id not in expected_comment_ids:
+ raise ValueError("AI response comment_id does not match input")
+ results.append(
+ AIAnalysisResult(
+ comment_id=parsed.comment_id,
+ sentiment=parsed.sentiment,
+ labels=parsed.labels,
+ reason=parsed.reason,
+ )
+ )
+ return results
+
+
+def calculate_analysis_status(*, success_count: int, total_count: int) -> tuple[float, str]:
+ if total_count <= 0:
+ return 0.0, "insufficient"
+ rate = success_count / total_count
+ return rate, "normal" if rate >= 0.8 else "insufficient"
+
+
+def analyze_comments_with_retry(
+ comments: list[dict[str, str]],
+ *,
+ requester,
+ max_retries: int = 3,
+) -> list[AIAnalysisResult]:
+ expected_ids = {str(comment["comment_id"]) for comment in comments}
+ prompt = build_comment_prompt(comments)
+ for attempt in range(max_retries):
+ try:
+ return parse_ai_comment_response(requester(prompt), expected_comment_ids=expected_ids)
+ except Exception:
+ if attempt >= max_retries - 1:
+ break
+ time.sleep(2**attempt)
+ return [
+ AIAnalysisResult(
+ comment_id=comment_id,
+ sentiment="unknown",
+ labels=[],
+ reason="ai_parse_failed",
+ ai_analysis_status="failed",
+ )
+ for comment_id in expected_ids
+ ]
+
+
+class OpenAICompatibleAIClient:
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ api_key: str,
+ model: str,
+ timeout_seconds: int = 30,
+ http_client: httpx.Client | None = None,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.api_key = api_key
+ self.model = model
+ self._http_client = http_client or httpx.Client(timeout=timeout_seconds)
+
+ def request(self, prompt: str) -> str:
+ endpoint = f"{self.base_url}/chat/completions" if self.base_url.endswith("/v1") else f"{self.base_url}/v1/chat/completions"
+ response = self._http_client.post(
+ endpoint,
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ json={
+ "model": self.model,
+ "messages": [
+ {
+ "role": "system",
+ "content": "只返回 JSON Array,不输出 Markdown 或解释性自然语言。",
+ },
+ {"role": "user", "content": prompt},
+ ],
+ "temperature": 0,
+ },
+ )
+ response.raise_for_status()
+ payload = response.json()
+ return str(payload["choices"][0]["message"]["content"])
diff --git a/app/services/export_service.py b/app/services/export_service.py
new file mode 100644
index 0000000..59db585
--- /dev/null
+++ b/app/services/export_service.py
@@ -0,0 +1,104 @@
+import csv
+import io
+import json
+import re
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models import Comment, ContentItem, Hotspot, Report
+
+
+def labels_to_text(labels_json: str | None) -> str:
+ try:
+ labels = json.loads(labels_json or "[]")
+ except json.JSONDecodeError:
+ return ""
+ return ",".join(str(label) for label in labels) if isinstance(labels, list) else ""
+
+
+def safe_csv_field(value) -> str:
+ text = "" if value is None else str(value)
+ text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
+ if text.startswith(("=", "+", "-", "@")):
+ return f"'{text}"
+ return text
+
+
+def safe_filename(platform: str, task_id: str, hotspot_keyword: str, *, extension: str = "csv") -> str:
+ keyword = hotspot_keyword[:20] + ("..." if len(hotspot_keyword) > 20 else "")
+ name = f"{platform}_{task_id}_{keyword}"
+ name = re.sub(r'[/\\:*?"<>|]+', "_", name)
+ name = re.sub(r"_+", "_", name).strip("_")
+ return f"{name}.{extension}"
+
+
+def export_item_comments_csv(session: Session, item_id: str) -> tuple[bytes, str]:
+ item = session.get(ContentItem, item_id)
+ if item is None:
+ raise ValueError("content item not found")
+ hotspot = session.get(Hotspot, item.hotspot_id)
+ comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
+ for comment in comments:
+ writer.writerow(
+ [
+ item.platform,
+ item.task_id,
+ hotspot.id if hotspot else "",
+ hotspot.title if hotspot else "",
+ item.id,
+ item.title or "",
+ comment.source_comment_id or "",
+ safe_csv_field(comment.content),
+ comment.sentiment,
+ labels_to_text(comment.labels),
+ comment.like_count or 0,
+ comment.comment_time or "",
+ ]
+ )
+ filename = safe_filename(item.platform, item.task_id, hotspot.title if hotspot else item.title or "comments")
+ return output.getvalue().encode("utf-8-sig"), filename
+
+
+def export_hotspot_comments_csv(session: Session, hotspot_id: str) -> tuple[bytes, str]:
+ hotspot = session.get(Hotspot, hotspot_id)
+ if hotspot is None:
+ raise ValueError("hotspot not found")
+ comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
+ for comment in comments:
+ item = session.get(ContentItem, comment.content_item_id)
+ writer.writerow(
+ [
+ comment.platform,
+ comment.task_id,
+ hotspot.id,
+ hotspot.title,
+ item.id if item else "",
+ item.title if item else "",
+ comment.source_comment_id or "",
+ safe_csv_field(comment.content),
+ comment.sentiment,
+ labels_to_text(comment.labels),
+ comment.like_count or 0,
+ comment.comment_time or "",
+ ]
+ )
+ return output.getvalue().encode("utf-8-sig"), safe_filename(hotspot.platform, hotspot.task_id, hotspot.title)
+
+
+def export_report_markdown(session: Session, *, report_type: str, hotspot_id: str | None = None, item_id: str | None = None) -> tuple[str, str]:
+ query = select(Report).where(Report.report_type == report_type)
+ if hotspot_id:
+ query = query.where(Report.hotspot_id == hotspot_id)
+ if item_id:
+ query = query.where(Report.content_item_id == item_id)
+ report = session.scalar(query)
+ if report is None:
+ raise ValueError("report not found")
+ return report.markdown_content or report.markdown, safe_filename("report", report.task_id, report.title, extension="md")
diff --git a/app/services/report_service.py b/app/services/report_service.py
new file mode 100644
index 0000000..bf56848
--- /dev/null
+++ b/app/services/report_service.py
@@ -0,0 +1,162 @@
+import json
+from collections import Counter, defaultdict
+from collections.abc import Callable
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.models import Comment, ContentItem, Hotspot, Report
+
+
+DEFAULT_SUMMARY = "总结生成失败,请查看上方统计数据。"
+
+
+def _labels(value: str | None) -> list[str]:
+ try:
+ parsed = json.loads(value or "[]")
+ except json.JSONDecodeError:
+ return []
+ return [str(label) for label in parsed] if isinstance(parsed, list) else []
+
+
+def build_comment_metrics(comments: list[Comment]) -> dict:
+ sentiment_counts = Counter(comment.sentiment or "unknown" for comment in comments)
+ label_counts = Counter(label for comment in comments for label in _labels(comment.labels))
+ total = len(comments)
+
+ def sentiment_entry(name: str) -> dict:
+ count = sentiment_counts.get(name, 0)
+ return {"count": count, "pct": round((count / total * 100) if total else 0, 2)}
+
+ return {
+ "sample_count": total,
+ "sentiment": {
+ "positive": sentiment_entry("positive"),
+ "negative": sentiment_entry("negative"),
+ "neutral": sentiment_entry("neutral"),
+ "unknown": sentiment_entry("unknown"),
+ },
+ "top_labels": [{"name": name, "count": count} for name, count in label_counts.most_common(5)],
+ }
+
+
+def select_typical_comments(comments: list[Comment], *, per_sentiment: int = 2) -> dict[str, list[dict]]:
+ buckets: dict[str, list[Comment]] = defaultdict(list)
+ for comment in comments:
+ buckets[comment.sentiment or "unknown"].append(comment)
+
+ result = {}
+ for sentiment, values in buckets.items():
+ sorted_values = sorted(values, key=lambda c: (c.like_count or 0, c.comment_time or c.created_at), reverse=True)
+ result[sentiment] = [
+ {"content": comment.content, "like_count": comment.like_count or 0, "comment_id": comment.source_comment_id}
+ for comment in sorted_values[:per_sentiment]
+ ]
+ return result
+
+
+def generate_item_report(
+ session: Session,
+ content_item_id: str,
+ *,
+ summary_provider: Callable[[dict, dict], str] | None = None,
+) -> Report:
+ item = session.get(ContentItem, content_item_id)
+ if item is None:
+ raise ValueError("content item not found")
+ hotspot = session.get(Hotspot, item.hotspot_id)
+ comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
+ metrics = build_comment_metrics(comments)
+ typical = select_typical_comments(comments)
+ summary = _safe_summary(summary_provider, metrics, typical, limit=200)
+ markdown = _build_markdown(title=item.title or item.source_item_id, metrics=metrics, typical=typical, summary=summary, hotspot_title=hotspot.title if hotspot else "")
+ report = Report(
+ task_id=item.task_id,
+ hotspot_id=item.hotspot_id,
+ content_item_id=item.id,
+ report_type="item",
+ title=item.title or item.source_item_id,
+ data=json.dumps(metrics, ensure_ascii=False),
+ markdown=markdown,
+ metrics_json=json.dumps(metrics, ensure_ascii=False),
+ typical_comments_json=json.dumps(typical, ensure_ascii=False),
+ summary=summary,
+ markdown_content=markdown,
+ )
+ session.add(report)
+ session.commit()
+ session.refresh(report)
+ return report
+
+
+def generate_hotspot_report(
+ session: Session,
+ hotspot_id: str,
+ *,
+ summary_provider: Callable[[dict, dict], str] | None = None,
+) -> Report:
+ hotspot = session.get(Hotspot, hotspot_id)
+ if hotspot is None:
+ raise ValueError("hotspot not found")
+ comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
+ item_count = session.scalar(select(func.count(ContentItem.id)).where(ContentItem.hotspot_id == hotspot.id)) or 0
+ metrics = build_comment_metrics(comments)
+ metrics["item_count"] = item_count
+ typical = select_typical_comments(comments)
+ summary = _safe_summary(summary_provider, metrics, typical, limit=300)
+ markdown = _build_markdown(title=hotspot.title, metrics=metrics, typical=typical, summary=summary)
+ report = Report(
+ task_id=hotspot.task_id,
+ hotspot_id=hotspot.id,
+ content_item_id=None,
+ report_type="hotspot",
+ title=hotspot.title,
+ data=json.dumps(metrics, ensure_ascii=False),
+ markdown=markdown,
+ metrics_json=json.dumps(metrics, ensure_ascii=False),
+ typical_comments_json=json.dumps(typical, ensure_ascii=False),
+ summary=summary,
+ markdown_content=markdown,
+ )
+ session.add(report)
+ session.commit()
+ session.refresh(report)
+ return report
+
+
+def _safe_summary(summary_provider, metrics: dict, typical: dict, *, limit: int) -> str:
+ if summary_provider is None:
+ return DEFAULT_SUMMARY
+ try:
+ summary = summary_provider(metrics, typical)
+ except Exception:
+ return DEFAULT_SUMMARY
+ return str(summary)[:limit]
+
+
+def _build_markdown(*, title: str, metrics: dict, typical: dict, summary: str, hotspot_title: str = "") -> str:
+ lines = [
+ f"# {title}",
+ "",
+ ]
+ if hotspot_title:
+ lines.extend([f"- 所属热点:{hotspot_title}", ""])
+ lines.extend(
+ [
+ f"- 样本评论数量:{metrics['sample_count']}",
+ "",
+ "## 情绪分布",
+ ]
+ )
+ for name in ("positive", "neutral", "negative", "unknown"):
+ item = metrics["sentiment"][name]
+ lines.append(f"- {name}: {item['count']} ({item['pct']}%)")
+ lines.extend(["", "## Top 标签"])
+ for label in metrics["top_labels"]:
+ lines.append(f"- {label['name']}: {label['count']}")
+ lines.extend(["", "## 典型评论"])
+ for sentiment, comments in typical.items():
+ for comment in comments:
+ lines.append(f"- [{sentiment}] {comment['content']}")
+ lines.extend(["", "## 总结", summary])
+ return "\n".join(lines)
diff --git a/app/services/task_service.py b/app/services/task_service.py
new file mode 100644
index 0000000..3d0c807
--- /dev/null
+++ b/app/services/task_service.py
@@ -0,0 +1,243 @@
+import inspect
+import json
+from concurrent.futures import ThreadPoolExecutor
+from collections.abc import Callable
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.config import get_settings
+from app.db import SessionLocal
+from app.models import Comment, ContentItem, Hotspot, Task
+from app.platforms.base import PlatformAPIError, TikHubClient
+from app.platforms.douyin import DouyinPlatform
+from app.platforms.xiaohongshu import XiaohongshuPlatform
+from app.schemas import CreateTaskRequest
+from app.services.ai_service import OpenAICompatibleAIClient, analyze_comments_with_retry, calculate_analysis_status
+from app.services.report_service import generate_hotspot_report, generate_item_report
+
+
+RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
+RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
+task_executor = ThreadPoolExecutor(max_workers=1)
+
+
+def has_running_task(session: Session) -> bool:
+ return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None
+
+
+def recover_running_tasks(session: Session) -> int:
+ tasks = list(session.scalars(select(Task).where(Task.status == "running")))
+ for task in tasks:
+ task.status = "failed"
+ task.error_stage = "system"
+ task.error_type = "unexpected_restart"
+ task.error_message = RESTART_ERROR_MESSAGE
+ session.commit()
+ return len(tasks)
+
+
+def build_platform(platform: str):
+ settings = get_settings()
+ client = TikHubClient(
+ base_url=settings.tikhub_base_url,
+ api_key=settings.tikhub_api_key,
+ timeout_seconds=settings.http_timeout_seconds,
+ max_retries=settings.http_max_retries,
+ )
+ if platform == "xiaohongshu":
+ return XiaohongshuPlatform(client)
+ if platform == "douyin":
+ return DouyinPlatform(client)
+ raise ValueError(f"Unsupported platform: {platform}")
+
+
+def build_ai_requester():
+ settings = get_settings()
+ if settings.ai_base_url and settings.ai_api_key and settings.ai_model:
+ client = OpenAICompatibleAIClient(
+ base_url=settings.ai_base_url,
+ api_key=settings.ai_api_key,
+ model=settings.ai_model,
+ timeout_seconds=settings.ai_timeout_seconds,
+ )
+ return client.request
+
+ def fallback_requester(_prompt: str) -> str:
+ return "[]"
+
+ return fallback_requester
+
+
+def create_task(session: Session, request: CreateTaskRequest, *, submit_background: bool = True) -> Task:
+ task = Task(
+ platform=request.platform,
+ status="running",
+ hotspot_limit=request.hotspot_limit,
+ item_limit_per_hotspot=request.item_limit_per_hotspot,
+ comment_limit_per_item=request.comment_limit_per_item,
+ total_items_count=0,
+ processed_items_count=0,
+ successful_items_count=0,
+ failed_items_count=0,
+ analysis_success_rate=0.0,
+ analysis_status="normal",
+ )
+ session.add(task)
+ session.commit()
+ session.refresh(task)
+ if submit_background:
+ task_executor.submit(run_task, task.id)
+ return task
+
+
+def list_tasks(session: Session) -> list[Task]:
+ return list(session.scalars(select(Task).order_by(Task.created_at.desc())))
+
+
+def get_task(session: Session, task_id: str) -> Task | None:
+ return session.get(Task, task_id)
+
+
+def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionmaker = SessionLocal) -> None:
+ session = session_factory()
+ close_session = hasattr(session, "close")
+ try:
+ task = session.get(Task, task_id)
+ if task is None:
+ return
+ try:
+ platform = build_platform(task.platform)
+ try:
+ hotspots = platform.fetch_hotspots(limit=task.hotspot_limit)
+ except PlatformAPIError as exc:
+ _mark_task_failed(task, "crawl_hotspots", exc.error_type, str(exc))
+ session.commit()
+ return
+ except Exception as exc:
+ _mark_task_failed(task, "crawl_hotspots", "api_error", str(exc))
+ session.commit()
+ return
+
+ for hotspot_data in hotspots:
+ hotspot = Hotspot(
+ task_id=task.id,
+ platform=task.platform,
+ source_hot_id=hotspot_data.source_hot_id,
+ rank=hotspot_data.rank,
+ title=hotspot_data.title,
+ heat_value=hotspot_data.heat_value,
+ raw_data=json.dumps(hotspot_data.raw_data or {}, ensure_ascii=False),
+ )
+ session.add(hotspot)
+ session.flush()
+
+ try:
+ items = platform.search_items_by_hotspot(hotspot.title, limit=task.item_limit_per_hotspot)
+ except Exception as exc:
+ task.failed_items_count += task.item_limit_per_hotspot
+ task.error_stage = "crawl_items"
+ task.error_type = getattr(exc, "error_type", "api_error")
+ task.error_message = str(exc)
+ session.commit()
+ continue
+
+ task.total_items_count += len(items)
+ seen_item_ids: set[str] = set()
+ for item_data in items:
+ if item_data.source_item_id in seen_item_ids:
+ continue
+ seen_item_ids.add(item_data.source_item_id)
+ item = ContentItem(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ platform=task.platform,
+ source_item_id=item_data.source_item_id,
+ item_type=item_data.item_type,
+ title=item_data.title,
+ summary=item_data.summary,
+ url=item_data.url,
+ status="pending",
+ raw_data=json.dumps(item_data.raw_data or {}, ensure_ascii=False),
+ )
+ session.add(item)
+ session.flush()
+ try:
+ comments = platform.fetch_comments(item.source_item_id, limit=task.comment_limit_per_item)
+ for comment_data in comments:
+ session.add(
+ Comment(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ content_item_id=item.id,
+ platform=task.platform,
+ source_comment_id=comment_data.source_comment_id,
+ content=comment_data.content,
+ author=comment_data.author,
+ like_count=comment_data.like_count,
+ comment_time=comment_data.comment_time,
+ raw_data=json.dumps(comment_data.raw_data or {}, ensure_ascii=False),
+ )
+ )
+ session.flush()
+ item_comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
+ ai_results = analyze_comments_with_retry(
+ [{"comment_id": comment.id, "content": comment.content} for comment in item_comments],
+ requester=build_ai_requester(),
+ max_retries=get_settings().ai_max_retries,
+ )
+ results_by_comment_id = {result.comment_id: result for result in ai_results}
+ for comment in item_comments:
+ result = results_by_comment_id.get(comment.id)
+ if result is None:
+ comment.sentiment = "unknown"
+ comment.labels = "[]"
+ comment.reason = "ai_missing_result"
+ comment.ai_analysis_status = "failed"
+ continue
+ comment.sentiment = result.sentiment
+ comment.labels = json.dumps(result.labels, ensure_ascii=False)
+ comment.reason = result.reason
+ comment.ai_analysis_status = result.ai_analysis_status
+ item.status = "success"
+ task.successful_items_count += 1
+ generate_item_report(session, item.id)
+ except Exception as exc:
+ item.status = "failed"
+ item.error_stage = "crawl_comments"
+ item.error_type = getattr(exc, "error_type", "api_error")
+ item.error_message = str(exc)
+ task.failed_items_count += 1
+ task.error_stage = item.error_stage
+ task.error_type = item.error_type
+ task.error_message = item.error_message
+ finally:
+ task.processed_items_count += 1
+ session.commit()
+
+ if task.successful_items_count > 0:
+ total_comments = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
+ success_comments = sum(1 for comment in task.comments if comment.ai_analysis_status == "success")
+ task.analysis_success_rate, task.analysis_status = calculate_analysis_status(
+ success_count=success_comments,
+ total_count=total_comments,
+ )
+ for hotspot in task.hotspots:
+ generate_hotspot_report(session, hotspot.id)
+ task.status = "success"
+ else:
+ _mark_task_failed(task, task.error_stage or "crawl_items", task.error_type or "no_successful_items", task.error_message or "没有任何内容条目成功")
+ session.commit()
+ except Exception as exc:
+ _mark_task_failed(task, "system", "unexpected_error", str(exc))
+ session.commit()
+ finally:
+ if close_session:
+ session.close()
+
+
+def _mark_task_failed(task: Task, stage: str, error_type: str, message: str) -> None:
+ task.status = "failed"
+ task.error_stage = stage
+ task.error_type = error_type
+ task.error_message = message
diff --git a/app/static/.gitkeep b/app/static/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/static/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/app/static/app.css b/app/static/app.css
new file mode 100644
index 0000000..dc4fbc8
--- /dev/null
+++ b/app/static/app.css
@@ -0,0 +1,7 @@
+body {
+ background: #f8f9fa;
+}
+
+.card {
+ border-radius: 8px;
+}
diff --git a/app/static/app.js b/app/static/app.js
new file mode 100644
index 0000000..e515c97
--- /dev/null
+++ b/app/static/app.js
@@ -0,0 +1,93 @@
+function readInt(id) {
+ return parseInt(document.getElementById(id).value, 10) || 0;
+}
+
+function updateScaleHint() {
+ const total = readInt("hotspot_limit") * readInt("item_limit_per_hotspot") * readInt("comment_limit_per_item");
+ const target = document.getElementById("scale-calc");
+ if (target) target.textContent = total.toLocaleString();
+}
+
+function validateRange(input) {
+ const value = readInt(input.id);
+ const min = parseInt(input.min, 10);
+ const max = parseInt(input.max, 10);
+ const valid = value >= min && value <= max;
+ input.classList.toggle("is-invalid", !valid);
+ return valid;
+}
+
+async function submitTask(event) {
+ event.preventDefault();
+ const btn = document.getElementById("submit-btn");
+ const errorBox = document.getElementById("form-error");
+ const inputs = Array.from(document.querySelectorAll(".scale-input"));
+ if (!inputs.every(validateRange)) {
+ errorBox.textContent = "参数超出范围,请检查配置。";
+ errorBox.classList.remove("d-none");
+ return;
+ }
+
+ btn.disabled = true;
+ btn.textContent = "提交中...";
+ errorBox.classList.add("d-none");
+ const payload = {
+ platform: document.getElementById("platform").value,
+ hotspot_limit: readInt("hotspot_limit"),
+ item_limit_per_hotspot: readInt("item_limit_per_hotspot"),
+ comment_limit_per_item: readInt("comment_limit_per_item"),
+ };
+
+ try {
+ const resp = await fetch("/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (resp.ok) {
+ const data = await resp.json();
+ window.location.href = `/tasks/${data.task_id}`;
+ return;
+ }
+ const data = await resp.json();
+ errorBox.textContent = resp.status === 422 ? "参数错误,请检查配置。" : (data.detail || "服务器错误,请稍后重试。");
+ errorBox.classList.remove("d-none");
+ } catch (_error) {
+ errorBox.textContent = "网络异常,请检查连接后重试。";
+ errorBox.classList.remove("d-none");
+ } finally {
+ btn.disabled = false;
+ btn.textContent = "开始抓取";
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ document.querySelectorAll(".scale-input").forEach((input) => {
+ input.addEventListener("input", () => {
+ validateRange(input);
+ updateScaleHint();
+ });
+ });
+ updateScaleHint();
+});
+
+async function downloadExport(url, defaultFilename, event) {
+ if (event) event.preventDefault();
+ const resp = await fetch(url);
+ if (!resp.ok) {
+ alert("导出失败,请稍后重试。");
+ return;
+ }
+ const blob = await resp.blob();
+ const disposition = resp.headers.get("Content-Disposition") || "";
+ const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
+ const filename = match ? decodeURIComponent(match[1]) : defaultFilename;
+ const blobUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = blobUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(blobUrl);
+}
diff --git a/app/templates/.gitkeep b/app/templates/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/templates/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..27e481a
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {% block title %}热榜评论分析工具{% endblock %}
+
+
+
+
+
+
+
+
+ {% block breadcrumbs %}
+
+ {% endblock %}
+ {% block content %}{% endblock %}
+
+
+
+
diff --git a/app/templates/hotspots/report.html b/app/templates/hotspots/report.html
new file mode 100644
index 0000000..7e806f7
--- /dev/null
+++ b/app/templates/hotspots/report.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+{% block title %}{{ hotspot.title }} 汇总报告 - 热榜评论分析工具{% endblock %}
+{% block breadcrumbs %}
+
+{% endblock %}
+{% block content %}
+
+
{{ hotspot.title }} 汇总报告
+
+
+{% if report %}
+
+
{{ report.summary }}
+
{{ report.markdown_content }}
+
+{% else %}
+ 报告生成中,请稍候...
+{% endif %}
+{% endblock %}
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..ac5485e
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,66 @@
+{% extends "base.html" %}
+{% block title %}任务列表 - 热榜评论分析工具{% endblock %}
+{% block content %}
+
+
任务列表
+
+
+
+
+
+
+
+
+
+
+
+ | 任务 ID |
+ 平台 |
+ 创建时间 |
+ 配置规模 |
+ 进度 |
+ 状态 |
+ 操作 |
+
+
+
+ {% include "partials/task_rows.html" %}
+
+
+
+
+{% endblock %}
diff --git a/app/templates/items/detail.html b/app/templates/items/detail.html
new file mode 100644
index 0000000..d42fbb1
--- /dev/null
+++ b/app/templates/items/detail.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{% block title %}{{ item.title or item.source_item_id }} 详情 - 热榜评论分析工具{% endblock %}
+{% block breadcrumbs %}
+
+{% endblock %}
+{% block content %}
+
+
{{ item.title or item.source_item_id }}
+
+
+{% if report %}
+
+
{{ report.summary }}
+
{{ report.markdown_content }}
+
+{% endif %}
+
+
| 评论内容 | 情绪 | 标签 | 点赞 |
+ {% for comment in comments %}
+ | {{ comment.content }} | {{ comment.sentiment }} | {% for label in comment.labels | from_json %}{{ label }}{% else %}-{% endfor %} | {{ comment.like_count or 0 }} |
+ {% else %}
+ | 暂无评论数据(该内容无评论或评论抓取为空) |
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/app/templates/macros/label_tags.html b/app/templates/macros/label_tags.html
new file mode 100644
index 0000000..53dc244
--- /dev/null
+++ b/app/templates/macros/label_tags.html
@@ -0,0 +1,7 @@
+{% macro label_tags(labels_json) %}
+ {% for label in labels_json | from_json %}
+ {{ label }}
+ {% else %}
+ -
+ {% endfor %}
+{% endmacro %}
diff --git a/app/templates/macros/sentiment_badge.html b/app/templates/macros/sentiment_badge.html
new file mode 100644
index 0000000..ddba93c
--- /dev/null
+++ b/app/templates/macros/sentiment_badge.html
@@ -0,0 +1,10 @@
+{% macro sentiment_badge(sentiment) %}
+ {% set config = {
+ "positive": ("正向", "bg-success"),
+ "neutral": ("中性", "bg-warning text-dark"),
+ "negative": ("负向", "bg-danger"),
+ "unknown": ("未知", "bg-secondary"),
+ } %}
+ {% set label, style = config.get(sentiment, ("未知", "bg-secondary")) %}
+ {{ label }}
+{% endmacro %}
diff --git a/app/templates/macros/status_badge.html b/app/templates/macros/status_badge.html
new file mode 100644
index 0000000..7511be8
--- /dev/null
+++ b/app/templates/macros/status_badge.html
@@ -0,0 +1,4 @@
+{% macro status_badge(status) %}
+ {% set label, style = status_badge_config(status) %}
+ {{ label }}
+{% endmacro %}
diff --git a/app/templates/partials/task_rows.html b/app/templates/partials/task_rows.html
new file mode 100644
index 0000000..513bdd9
--- /dev/null
+++ b/app/templates/partials/task_rows.html
@@ -0,0 +1,21 @@
+{% for task in tasks %}
+ {% set status_label, status_class = status_badge_config(task.status) %}
+
+ {{ task.id }} |
+ {{ task.platform | platform_label }} |
+ {{ task.created_at }} |
+ 热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }} |
+ {{ task.successful_items_count }} / {{ task.total_items_count }} |
+
+ {{ status_label }}
+ {% if task.error_stage or task.error_type %}
+ {{ task.error_stage }} / {{ task.error_type }}
+ {% endif %}
+ |
+ 查看 |
+
+{% else %}
+
+ | 还没有任何任务,请在上方创建第一个任务 |
+
+{% endfor %}
diff --git a/app/templates/tasks/detail.html b/app/templates/tasks/detail.html
new file mode 100644
index 0000000..32f2e9a
--- /dev/null
+++ b/app/templates/tasks/detail.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+{% block title %}任务 #{{ task.id }} - 热榜评论分析工具{% endblock %}
+{% block breadcrumbs %}
+
+{% endblock %}
+{% block content %}
+
+
任务 #{{ task.id }}
+
+
+
+ {% set label, cls = status_badge_config(task.status) %}
+
平台:{{ task.platform | platform_label }} {{ label }}
+
成功 {{ task.successful_items_count }} / 共 {{ task.total_items_count }} 条内容
+ {% if task.error_message %}
{{ task.error_stage }} / {{ task.error_type }}:{{ task.error_message }}
{% endif %}
+
+{% if task.status == "running" and not hotspots %}
+
+{% endif %}
+
+{% for hotspot in hotspots %}
+
+
+
+
+
查看热点级汇总报告
+
+ {% for item in hotspot.content_items %}
+ -
+ {{ item.title or item.summary or item.source_item_id }} {{ item.status }}
+ 查看详情
+
+ {% endfor %}
+
+
+
+
+{% endfor %}
+
+{% endblock %}
diff --git a/app/templating.py b/app/templating.py
new file mode 100644
index 0000000..64f711a
--- /dev/null
+++ b/app/templating.py
@@ -0,0 +1,33 @@
+import json
+
+from fastapi.templating import Jinja2Templates
+
+
+templates = Jinja2Templates(directory="app/templates")
+
+
+def status_badge_config(status: str) -> tuple[str, str]:
+ config = {
+ "pending": ("等待中", "bg-secondary"),
+ "running": ("运行中", "bg-warning text-dark"),
+ "success": ("已完成", "bg-success"),
+ "failed": ("失败", "bg-danger"),
+ }
+ return config.get(status, ("未知", "bg-secondary"))
+
+
+def platform_label(platform: str) -> str:
+ return {"xiaohongshu": "小红书", "douyin": "抖音"}.get(platform, platform)
+
+
+def from_json_filter(value: str | None) -> list:
+ try:
+ parsed = json.loads(value) if value else []
+ except (TypeError, json.JSONDecodeError):
+ return []
+ return parsed if isinstance(parsed, list) else []
+
+
+templates.env.globals["status_badge_config"] = status_badge_config
+templates.env.filters["platform_label"] = platform_label
+templates.env.filters["from_json"] = from_json_filter
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..accce96
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,9 @@
+services:
+ app:
+ build: .
+ ports:
+ - "8000:8000"
+ env_file:
+ - .env
+ volumes:
+ - ./data:/app/data
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..606c455
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[project]
+name = "hot-comments-analysis-tool"
+version = "0.1.0"
+description = "FastAPI MVP for Xiaohongshu and Douyin hot topic comment analysis."
+requires-python = ">=3.12"
+dependencies = [
+ "beautifulsoup4>=4.12.3",
+ "fastapi>=0.115.0",
+ "httpx>=0.27.0",
+ "jinja2>=3.1.4",
+ "pydantic>=2.8.0",
+ "pydantic-settings>=2.4.0",
+ "sqlalchemy>=2.0.32",
+ "uvicorn[standard]>=0.30.6",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.3.2",
+ "pytest-cov>=5.0.0",
+ "pytest-mock>=3.14.0",
+ "respx>=0.21.1",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+pythonpath = ["."]
+
+[tool.coverage.run]
+branch = true
+source = ["app"]
+
+[tool.coverage.report]
+omit = [
+ "app/templates/*",
+ "app/static/*",
+ "tests/*",
+]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..38bb211
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Test package."""
diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/fixtures/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..727da53
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,31 @@
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+from sqlalchemy.pool import StaticPool
+
+from app.db import Base, get_db_session
+from app.main import app
+
+
+@contextmanager
+def make_test_client() -> Iterator[tuple[TestClient, object]]:
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(engine)
+
+ def override_db_session():
+ with Session(engine) as session:
+ yield session
+
+ app.dependency_overrides[get_db_session] = override_db_session
+ try:
+ yield TestClient(app), engine
+ finally:
+ app.dependency_overrides.clear()
+ engine.dispose()
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..c210fac
--- /dev/null
+++ b/tests/integration/__init__.py
@@ -0,0 +1 @@
+"""Integration tests."""
diff --git a/tests/integration/test_failure_tolerance.py b/tests/integration/test_failure_tolerance.py
new file mode 100644
index 0000000..146eaf5
--- /dev/null
+++ b/tests/integration/test_failure_tolerance.py
@@ -0,0 +1,51 @@
+from sqlalchemy.orm import Session
+
+from app.platforms.base import PlatformAPIError
+from app.schemas import CreateTaskRequest
+from app.services.task_service import create_task, run_task
+from tests.helpers import make_test_client
+
+
+class FailingHotspotPlatform:
+ def fetch_hotspots(self, *, limit):
+ raise PlatformAPIError("hotspot failed", error_type="api_error", status_code=500)
+
+
+def test_hotspot_failure_marks_task_failed(monkeypatch):
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FailingHotspotPlatform())
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+ run_task(task_id, session_factory=lambda: session)
+ task = session.get(type(task), task_id)
+
+ assert task.status == "failed"
+ assert task.error_stage == "crawl_hotspots"
+ assert task.error_type == "api_error"
+
+
+def test_unexpected_task_exception_marks_task_failed(monkeypatch):
+ def raise_unexpected_error(_platform):
+ raise RuntimeError("boom")
+
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", raise_unexpected_error)
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+ run_task(task_id, session_factory=lambda: session)
+ task = session.get(type(task), task_id)
+
+ assert task.status == "failed"
+ assert task.error_stage == "system"
+ assert task.error_type == "unexpected_error"
+ assert task.error_message == "boom"
diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py
new file mode 100644
index 0000000..e87ea05
--- /dev/null
+++ b/tests/integration/test_health.py
@@ -0,0 +1,12 @@
+from fastapi.testclient import TestClient
+
+from app.main import app
+
+
+def test_health_returns_ok():
+ client = TestClient(app)
+
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
diff --git a/tests/integration/test_routes.py b/tests/integration/test_routes.py
new file mode 100644
index 0000000..6883455
--- /dev/null
+++ b/tests/integration/test_routes.py
@@ -0,0 +1,146 @@
+from app.models import Comment, ContentItem, Hotspot, Report, Task
+from tests.helpers import make_test_client
+
+
+def test_index_page_renders_task_form_empty_state_and_default_scale():
+ with make_test_client() as (client, _engine):
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "热榜评论分析工具" in response.text
+ assert 'name="platform"' in response.text
+ assert 'name="hotspot_limit"' in response.text
+ assert 'name="item_limit_per_hotspot"' in response.text
+ assert 'name="comment_limit_per_item"' in response.text
+ assert "1250" in response.text
+ assert 'onclick="window.location.href=\'/\'"' in response.text
+ assert "还没有任何任务" in response.text
+
+
+def test_index_page_lists_existing_tasks():
+ with make_test_client() as (client, engine):
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ session.add(
+ Task(
+ id="task-visible",
+ platform="douyin",
+ status="running",
+ hotspot_limit=3,
+ item_limit_per_hotspot=4,
+ comment_limit_per_item=50,
+ )
+ )
+ session.commit()
+
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "task-visible" in response.text
+ assert "抖音" in response.text
+ assert "运行中" in response.text
+
+
+def seed_result_data(engine):
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ task = Task(id="task-result", platform="douyin", status="success", total_items_count=1, successful_items_count=1)
+ session.add(task)
+ hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}")
+ session.add(hotspot)
+ item = ContentItem(
+ id="item-result",
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ platform="douyin",
+ source_item_id="v1",
+ item_type="video",
+ title="视频标题",
+ status="success",
+ raw_data="{}",
+ )
+ session.add(item)
+ session.add(
+ Comment(
+ id="comment-result",
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ content_item_id=item.id,
+ platform="douyin",
+ source_comment_id="c1",
+ content="评论内容",
+ sentiment="positive",
+ labels='["认可"]',
+ like_count=3,
+ raw_data="{}",
+ )
+ )
+ session.add(
+ Report(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ report_type="hotspot",
+ title="热点标题",
+ metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}',
+ typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}',
+ summary="热点总结",
+ markdown_content="# 热点标题\n\n热点总结",
+ data="{}",
+ markdown="# 热点标题\n\n热点总结",
+ )
+ )
+ session.add(
+ Report(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ content_item_id=item.id,
+ report_type="item",
+ title="视频标题",
+ metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}',
+ typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}',
+ summary="内容总结",
+ markdown_content="# 视频标题\n\n内容总结",
+ data="{}",
+ markdown="# 视频标题\n\n内容总结",
+ )
+ )
+ session.commit()
+
+
+def test_result_pages_render_seeded_data():
+ with make_test_client() as (client, engine):
+ seed_result_data(engine)
+
+ task_response = client.get("/tasks/task-result")
+ hotspot_response = client.get("/hotspots/hot-result/report")
+ item_response = client.get("/items/item-result")
+
+ assert task_response.status_code == 200
+ assert "热点标题" in task_response.text
+ assert 'onclick="window.location.href=\'/\'"' in task_response.text
+ assert hotspot_response.status_code == 200
+ assert "热点总结" in hotspot_response.text
+ assert item_response.status_code == 200
+ assert "评论内容" in item_response.text
+
+
+def test_export_routes_return_csv_and_markdown():
+ with make_test_client() as (client, engine):
+ seed_result_data(engine)
+
+ csv_response = client.get("/api/export/items/item-result/comments.csv")
+ hotspot_csv_response = client.get("/api/export/hotspots/hot-result/comments.csv")
+ hotspot_md = client.get("/api/export/hotspots/hot-result.md")
+ item_md = client.get("/api/export/items/item-result.md")
+
+ assert csv_response.status_code == 200
+ assert csv_response.content.startswith(b"\xef\xbb\xbf")
+ assert "评论内容".encode("utf-8") in csv_response.content
+ assert hotspot_csv_response.status_code == 200
+ assert "评论内容".encode("utf-8") in hotspot_csv_response.content
+ assert hotspot_md.status_code == 200
+ assert "热点总结" in hotspot_md.text
+ assert item_md.status_code == 200
+ assert "内容总结" in item_md.text
diff --git a/tests/integration/test_task_creation.py b/tests/integration/test_task_creation.py
new file mode 100644
index 0000000..5fa71df
--- /dev/null
+++ b/tests/integration/test_task_creation.py
@@ -0,0 +1,118 @@
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.pool import StaticPool
+
+from app.db import Base, get_db_session
+from app.main import app
+from app.models import Task
+
+
+def make_test_client():
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(engine)
+
+ def override_db_session():
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ yield session
+
+ app.dependency_overrides[get_db_session] = override_db_session
+ return TestClient(app), engine
+
+
+def test_create_task_returns_task_id_and_running_status():
+ client, engine = make_test_client()
+ try:
+ response = client.post(
+ "/api/tasks",
+ json={
+ "platform": "xiaohongshu",
+ "hotspot_limit": 5,
+ "item_limit_per_hotspot": 5,
+ "comment_limit_per_item": 50,
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["task_id"]
+ assert data["status"] == "running"
+ finally:
+ engine.dispose()
+ app.dependency_overrides.clear()
+
+
+def test_create_task_rejects_invalid_platform_and_limits():
+ client, engine = make_test_client()
+ try:
+ response = client.post(
+ "/api/tasks",
+ json={
+ "platform": "weibo",
+ "hotspot_limit": 11,
+ "item_limit_per_hotspot": 0,
+ "comment_limit_per_item": 101,
+ },
+ )
+
+ assert response.status_code == 422
+ finally:
+ engine.dispose()
+ app.dependency_overrides.clear()
+
+
+def test_create_task_returns_400_when_running_task_exists():
+ client, engine = make_test_client()
+ try:
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ session.add(Task(platform="douyin", status="running"))
+ session.commit()
+
+ response = client.post(
+ "/api/tasks",
+ json={
+ "platform": "douyin",
+ "hotspot_limit": 5,
+ "item_limit_per_hotspot": 5,
+ "comment_limit_per_item": 50,
+ },
+ )
+
+ assert response.status_code == 400
+ assert response.json() == {"detail": "当前有正在运行的任务,请稍后再试"}
+ finally:
+ engine.dispose()
+ app.dependency_overrides.clear()
+
+
+def test_task_list_and_detail_return_created_tasks():
+ client, engine = make_test_client()
+ try:
+ created = client.post(
+ "/api/tasks",
+ json={
+ "platform": "douyin",
+ "hotspot_limit": 3,
+ "item_limit_per_hotspot": 4,
+ "comment_limit_per_item": 30,
+ },
+ ).json()
+
+ list_response = client.get("/api/tasks")
+ detail_response = client.get(f"/api/tasks/{created['task_id']}")
+
+ assert list_response.status_code == 200
+ assert list_response.json()[0]["task_id"] == created["task_id"]
+ assert detail_response.status_code == 200
+ assert detail_response.json()["platform"] == "douyin"
+ assert detail_response.json()["hotspot_limit"] == 3
+ finally:
+ engine.dispose()
+ app.dependency_overrides.clear()
diff --git a/tests/integration/test_task_flow_douyin.py b/tests/integration/test_task_flow_douyin.py
new file mode 100644
index 0000000..cfe5634
--- /dev/null
+++ b/tests/integration/test_task_flow_douyin.py
@@ -0,0 +1,39 @@
+from sqlalchemy.orm import Session
+
+from app.models import Comment, Task
+from app.platforms.base import CommentData, ContentItemData, HotspotData
+from app.schemas import CreateTaskRequest
+from app.services.task_service import create_task, run_task
+from tests.helpers import make_test_client
+
+
+class FakeDouyinPlatform:
+ def fetch_hotspots(self, *, limit):
+ return [HotspotData(source_hot_id="d1", title="抖音热点", rank=1, heat_value="200", raw_data={})]
+
+ def search_items_by_hotspot(self, keyword, *, limit):
+ return [ContentItemData(source_item_id="v1", item_type="video", title="视频", raw_data={})]
+
+ def fetch_comments(self, source_item_id, *, limit):
+ return [CommentData(source_comment_id="dc1", content="抖音评论", raw_data={})]
+
+
+def test_douyin_task_flow_persists_comments(monkeypatch):
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeDouyinPlatform())
+
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+
+ with Session(engine) as session:
+ run_task(task_id, session_factory=lambda: session)
+ task = session.get(Task, task_id)
+ comment = session.query(Comment).filter_by(task_id=task_id).one()
+
+ assert task.status == "success"
+ assert comment.content == "抖音评论"
diff --git a/tests/integration/test_task_flow_xiaohongshu.py b/tests/integration/test_task_flow_xiaohongshu.py
new file mode 100644
index 0000000..9686f74
--- /dev/null
+++ b/tests/integration/test_task_flow_xiaohongshu.py
@@ -0,0 +1,130 @@
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models import Comment, ContentItem, Hotspot, Report, Task
+from app.schemas import CreateTaskRequest
+from app.services.task_service import create_task, run_task
+from tests.helpers import make_test_client
+
+
+class FakeXhsPlatform:
+ def fetch_hotspots(self, *, limit):
+ from app.platforms.base import HotspotData
+
+ return [HotspotData(source_hot_id="h1", title="热点一", rank=1, heat_value="100", raw_data={"id": "h1"})]
+
+ def search_items_by_hotspot(self, keyword, *, limit):
+ from app.platforms.base import ContentItemData
+
+ return [
+ ContentItemData(source_item_id="n1", item_type="note", title=f"{keyword} 笔记", raw_data={"id": "n1"})
+ ]
+
+ def fetch_comments(self, source_item_id, *, limit):
+ from app.platforms.base import CommentData
+
+ return [CommentData(source_comment_id="c1", content="评论一", like_count=5, raw_data={"id": "c1"})]
+
+
+def test_xiaohongshu_task_flow_persists_hotspots_items_and_comments(monkeypatch):
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
+ monkeypatch.setattr(
+ "app.services.task_service.build_ai_requester",
+ lambda: (
+ lambda prompt: (
+ '[{"comment_id":"'
+ + ("c1" if "c1" in prompt else prompt.split('"comment_id": "')[1].split('"')[0])
+ + '","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'
+ )
+ ),
+ )
+
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+
+ with Session(engine) as session:
+ run_task(task_id, session_factory=lambda: session)
+
+ persisted = session.get(Task, task_id)
+ assert persisted.status == "success"
+ assert persisted.processed_items_count == 1
+ assert persisted.successful_items_count == 1
+ assert persisted.analysis_status == "normal"
+ assert session.scalar(select(Hotspot).where(Hotspot.task_id == task_id)).title == "热点一"
+ assert session.scalar(select(ContentItem).where(ContentItem.task_id == task_id)).source_item_id == "n1"
+ comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
+ assert comment.content == "评论一"
+ assert comment.ai_analysis_status == "success"
+ assert comment.sentiment == "positive"
+ assert comment.labels == '["认可"]'
+ assert comment.reason == "喜欢"
+ assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "item")) is not None
+ assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "hotspot")) is not None
+
+
+def test_xiaohongshu_task_flow_marks_comments_failed_when_ai_parse_fails(monkeypatch):
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
+ monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: (lambda _prompt: "not-json"))
+ monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
+
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+
+ with Session(engine) as session:
+ run_task(task_id, session_factory=lambda: session)
+
+ persisted = session.get(Task, task_id)
+ comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
+
+ assert persisted.status == "success"
+ assert persisted.analysis_status == "insufficient"
+ assert persisted.analysis_success_rate == 0.0
+ assert comment.ai_analysis_status == "failed"
+ assert comment.sentiment == "unknown"
+ assert comment.labels == "[]"
+ assert comment.reason == "ai_parse_failed"
+
+
+def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeypatch):
+ with make_test_client() as (_client, engine):
+ monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
+ monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
+
+ def failing_requester(_prompt):
+ raise RuntimeError("ai unauthorized")
+
+ monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: failing_requester)
+
+ with Session(engine) as session:
+ task = create_task(
+ session,
+ CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
+ submit_background=False,
+ )
+ task_id = task.id
+
+ with Session(engine) as session:
+ run_task(task_id, session_factory=lambda: session)
+
+ persisted = session.get(Task, task_id)
+ comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
+
+ assert persisted.status == "success"
+ assert persisted.successful_items_count == 1
+ assert persisted.failed_items_count == 0
+ assert persisted.analysis_status == "insufficient"
+ assert persisted.analysis_success_rate == 0.0
+ assert comment.ai_analysis_status == "failed"
+ assert comment.reason == "ai_parse_failed"
diff --git a/tests/integration/test_task_recovery.py b/tests/integration/test_task_recovery.py
new file mode 100644
index 0000000..1560ffe
--- /dev/null
+++ b/tests/integration/test_task_recovery.py
@@ -0,0 +1,40 @@
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.db import Base
+from app.main import app
+from app.models import Task
+
+
+def test_lifespan_marks_running_tasks_failed_after_restart(monkeypatch):
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+ Base.metadata.create_all(engine)
+
+ with TestingSessionLocal() as session:
+ session.add(Task(platform="xiaohongshu", status="running"))
+ session.commit()
+
+ monkeypatch.setattr("app.main.SessionLocal", TestingSessionLocal, raising=False)
+ monkeypatch.setattr("app.main.init_db", lambda: None)
+
+ try:
+ with TestClient(app):
+ pass
+
+ with Session(engine) as session:
+ task = session.scalar(select(Task))
+
+ assert task is not None
+ assert task.status == "failed"
+ assert task.error_stage == "system"
+ assert task.error_type == "unexpected_restart"
+ assert task.error_message == "系统重启,任务被中断"
+ finally:
+ engine.dispose()
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e0310a0
--- /dev/null
+++ b/tests/unit/__init__.py
@@ -0,0 +1 @@
+"""Unit tests."""
diff --git a/tests/unit/test_ai_schema.py b/tests/unit/test_ai_schema.py
new file mode 100644
index 0000000..dd9c621
--- /dev/null
+++ b/tests/unit/test_ai_schema.py
@@ -0,0 +1,119 @@
+import pytest
+import httpx
+
+from app.services.ai_service import (
+ AIAnalysisResult,
+ OpenAICompatibleAIClient,
+ analyze_comments_with_retry,
+ build_comment_prompt,
+ calculate_analysis_status,
+ parse_ai_comment_response,
+)
+
+
+def test_build_comment_prompt_contains_ids_and_truncates_content():
+ prompt = build_comment_prompt([{"comment_id": "c1", "content": "你" * 200}])
+
+ assert "c1" in prompt
+ assert "请原样回填输入中的 comment_id" in prompt
+ assert "你" * 150 in prompt
+ assert "你" * 151 not in prompt
+
+
+def test_parse_ai_comment_response_validates_array_sentiment_labels_and_ids():
+ result = parse_ai_comment_response(
+ '[{"comment_id":"c1","sentiment":"positive","labels":["质量好"],"reason":"认可"}]',
+ expected_comment_ids={"c1"},
+ )
+
+ assert result == [AIAnalysisResult(comment_id="c1", sentiment="positive", labels=["质量好"], reason="认可")]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "not-json",
+ '{"comment_id":"c1"}',
+ '[{"comment_id":"missing","sentiment":"positive","labels":[],"reason":""}]',
+ '[{"comment_id":"c1","sentiment":"happy","labels":[],"reason":""}]',
+ '[{"comment_id":"c1","sentiment":"positive","labels":["a","b","c","d"],"reason":""}]',
+ ],
+)
+def test_parse_ai_comment_response_rejects_invalid_payloads(payload):
+ with pytest.raises(ValueError):
+ parse_ai_comment_response(payload, expected_comment_ids={"c1"})
+
+
+def test_calculate_analysis_status_uses_eighty_percent_threshold():
+ assert calculate_analysis_status(success_count=8, total_count=10) == (0.8, "normal")
+ assert calculate_analysis_status(success_count=7, total_count=10) == (0.7, "insufficient")
+ assert calculate_analysis_status(success_count=0, total_count=0) == (0.0, "insufficient")
+
+
+def test_analyze_comments_with_retry_marks_batch_failed_after_three_parse_failures(monkeypatch):
+ sleeps = []
+ monkeypatch.setattr("app.services.ai_service.time.sleep", sleeps.append)
+
+ results = analyze_comments_with_retry(
+ [{"comment_id": "c1", "content": "内容"}],
+ requester=lambda _prompt: "not-json",
+ max_retries=3,
+ )
+
+ assert results[0].comment_id == "c1"
+ assert results[0].sentiment == "unknown"
+ assert results[0].ai_analysis_status == "failed"
+ assert results[0].reason == "ai_parse_failed"
+ assert sleeps == [1, 2]
+
+
+def test_openai_compatible_client_posts_chat_completion_and_returns_message_content():
+ captured = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ captured["url"] = str(request.url)
+ captured["authorization"] = request.headers.get("authorization")
+ captured["body"] = request.read().decode("utf-8")
+ return httpx.Response(
+ 200,
+ json={"choices": [{"message": {"content": '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'}}]},
+ )
+
+ http_client = httpx.Client(transport=httpx.MockTransport(handler))
+ client = OpenAICompatibleAIClient(
+ base_url="https://ai.example.com",
+ api_key="test-ai-key",
+ model="test-model",
+ http_client=http_client,
+ )
+
+ result = client.request("prompt text")
+
+ assert result == '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'
+ assert captured["url"] == "https://ai.example.com/v1/chat/completions"
+ assert captured["authorization"] == "Bearer test-ai-key"
+ assert '"model":"test-model"' in captured["body"]
+ assert "prompt text" in captured["body"]
+
+
+def test_openai_compatible_client_accepts_base_url_that_already_includes_v1():
+ captured = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ captured["url"] = str(request.url)
+ return httpx.Response(
+ 200,
+ json={"choices": [{"message": {"content": "[]"}}]},
+ )
+
+ http_client = httpx.Client(transport=httpx.MockTransport(handler))
+ client = OpenAICompatibleAIClient(
+ base_url="https://ai.example.com/v1",
+ api_key="test-ai-key",
+ model="test-model",
+ http_client=http_client,
+ )
+
+ client.request("prompt text")
+
+ assert captured["url"] == "https://ai.example.com/v1/chat/completions"
diff --git a/tests/unit/test_comment_pagination.py b/tests/unit/test_comment_pagination.py
new file mode 100644
index 0000000..d5132fd
--- /dev/null
+++ b/tests/unit/test_comment_pagination.py
@@ -0,0 +1,51 @@
+import httpx
+import pytest
+
+from app.platforms.base import PlatformAPIError, TikHubClient
+
+
+class SequenceTransport:
+ def __init__(self, responses):
+ self.responses = list(responses)
+ self.requests = []
+
+ def __call__(self, request: httpx.Request) -> httpx.Response:
+ self.requests.append(request)
+ response = self.responses.pop(0)
+ response.request = request
+ return response
+
+
+def test_tikhub_client_retries_429_with_exponential_backoff(monkeypatch):
+ sleeps = []
+ monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
+ transport = SequenceTransport(
+ [
+ httpx.Response(429, json={"message": "Too Many Requests"}),
+ httpx.Response(429, json={"message": "Too Many Requests"}),
+ httpx.Response(200, json={"ok": True}),
+ ]
+ )
+ http_client = httpx.Client(transport=httpx.MockTransport(transport))
+ client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
+
+ result = client.get("/demo")
+
+ assert result == {"ok": True}
+ assert sleeps == [1, 2]
+ assert len(transport.requests) == 3
+ assert transport.requests[0].headers["Authorization"] == "Bearer secret-token"
+
+
+def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
+ monkeypatch.setattr("app.platforms.base.time.sleep", lambda _seconds: None)
+ transport = SequenceTransport([httpx.Response(429, json={"message": "Too Many Requests"}) for _ in range(4)])
+ http_client = httpx.Client(transport=httpx.MockTransport(transport))
+ client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
+
+ with pytest.raises(PlatformAPIError) as exc_info:
+ client.get("/demo")
+
+ assert exc_info.value.error_type == "rate_limited"
+ assert exc_info.value.status_code == 429
+ assert "secret-token" not in str(exc_info.value)
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
new file mode 100644
index 0000000..2c7ec21
--- /dev/null
+++ b/tests/unit/test_config.py
@@ -0,0 +1,76 @@
+from app.config import Settings
+
+
+def test_settings_use_t01_defaults(monkeypatch):
+ monkeypatch.delenv("APP_ENV", raising=False)
+ monkeypatch.delenv("DATABASE_URL", raising=False)
+ monkeypatch.delenv("TIKHUB_BASE_URL", raising=False)
+ monkeypatch.delenv("AI_PROVIDER", raising=False)
+
+ settings = Settings(_env_file=None)
+
+ assert settings.app_env == "development"
+ assert settings.database_url == "sqlite:///data/app.db"
+ assert settings.tikhub_base_url == "https://api.tikhub.io"
+ assert settings.ai_provider == "openai-compatible"
+
+
+def test_settings_read_environment_overrides(monkeypatch):
+ monkeypatch.setenv("APP_ENV", "test")
+ monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
+ monkeypatch.setenv("TIKHUB_API_KEY", "test-token")
+ monkeypatch.setenv("AI_API_KEY", "test-ai-key")
+
+ settings = Settings(_env_file=None)
+
+ assert settings.app_env == "test"
+ assert settings.database_url == "sqlite:///:memory:"
+ assert settings.tikhub_api_key == "test-token"
+ assert settings.ai_api_key == "test-ai-key"
+
+
+def test_settings_include_t02_defaults(monkeypatch):
+ monkeypatch.delenv("AI_BATCH_SIZE", raising=False)
+ monkeypatch.delenv("AI_CONCURRENCY", raising=False)
+ monkeypatch.delenv("AI_MAX_RETRIES", raising=False)
+ monkeypatch.delenv("AI_TIMEOUT_SECONDS", raising=False)
+ monkeypatch.delenv("HTTP_TIMEOUT_SECONDS", raising=False)
+ monkeypatch.delenv("HTTP_MAX_RETRIES", raising=False)
+ monkeypatch.delenv("CRAWL_PAGE_INTERVAL_SECONDS", raising=False)
+
+ settings = Settings(_env_file=None)
+
+ assert settings.ai_batch_size == 20
+ assert settings.ai_concurrency == 2
+ assert settings.ai_max_retries == 3
+ assert settings.ai_timeout_seconds == 30
+ assert settings.http_timeout_seconds == 20
+ assert settings.http_max_retries == 3
+ assert settings.crawl_page_interval_seconds == 1.5
+
+
+def test_settings_read_crawl_page_interval_from_environment(monkeypatch):
+ monkeypatch.setenv("CRAWL_PAGE_INTERVAL_SECONDS", "0.25")
+
+ settings = Settings(_env_file=None)
+
+ assert settings.crawl_page_interval_seconds == 0.25
+
+
+def test_ai_concurrency_has_hard_upper_bound(monkeypatch):
+ monkeypatch.setenv("AI_CONCURRENCY", "4")
+
+ settings = Settings(_env_file=None)
+
+ assert settings.ai_concurrency == 3
+
+
+def test_task_limit_ranges_are_available_on_settings():
+ settings = Settings(_env_file=None)
+
+ assert settings.hot_limit_min == 1
+ assert settings.hot_limit_max == 10
+ assert settings.item_limit_per_hot_min == 1
+ assert settings.item_limit_per_hot_max == 10
+ assert settings.comment_limit_per_item_min == 10
+ assert settings.comment_limit_per_item_max == 100
diff --git a/tests/unit/test_deployment_config.py b/tests/unit/test_deployment_config.py
new file mode 100644
index 0000000..eff71ce
--- /dev/null
+++ b/tests/unit/test_deployment_config.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_docker_compose_reads_real_env_file_not_example():
+ compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+
+ assert "- .env\n" in compose_content
+ assert "- .env.example" not in compose_content
+
+
+def test_real_env_file_is_gitignored():
+ gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
+
+ assert ".env" in gitignore_lines
diff --git a/tests/unit/test_douyin_mapping.py b/tests/unit/test_douyin_mapping.py
new file mode 100644
index 0000000..893a776
--- /dev/null
+++ b/tests/unit/test_douyin_mapping.py
@@ -0,0 +1,120 @@
+from app.platforms.douyin import DouyinPlatform
+
+
+def test_douyin_maps_hotspots():
+ platform = DouyinPlatform(client=None)
+ payload = {
+ "data": {
+ "word_list": [
+ {"query_id": "q1", "title": "热点", "rank": 2, "hot_score": "888"}
+ ]
+ }
+ }
+
+ hotspots = platform.map_hotspots(payload, limit=5)
+
+ assert hotspots[0].source_hot_id == "q1"
+ assert hotspots[0].title == "热点"
+ assert hotspots[0].rank == 2
+ assert hotspots[0].heat_value == "888"
+
+
+def test_douyin_maps_hotspots_from_real_item_list_shape():
+ platform = DouyinPlatform(client=None)
+ payload = {
+ "data": {
+ "item_list": [
+ {"query_id": "q-real", "sentence": "真实热点", "rank": 1, "hot_score": 999}
+ ]
+ }
+ }
+
+ hotspots = platform.map_hotspots(payload, limit=5)
+
+ assert hotspots[0].source_hot_id == "q-real"
+ assert hotspots[0].title == "真实热点"
+ assert hotspots[0].rank == 1
+ assert hotspots[0].heat_value == "999"
+
+
+def test_douyin_maps_videos():
+ platform = DouyinPlatform(client=None)
+ payload = {
+ "data": [
+ {
+ "aweme_info": {
+ "aweme_id": "aweme-1",
+ "desc": "视频描述",
+ "author": {"nickname": "作者"},
+ "statistics": {"comment_count": 10},
+ }
+ }
+ ]
+ }
+
+ items = platform.map_items(payload, limit=5)
+
+ assert items[0].source_item_id == "aweme-1"
+ assert items[0].title == "视频描述"
+ assert items[0].item_type == "video"
+
+
+def test_douyin_maps_videos_from_real_business_data_shape():
+ platform = DouyinPlatform(client=None)
+ payload = {
+ "data": {
+ "business_data": [
+ {
+ "data": {
+ "aweme_info": {
+ "aweme_id": "aweme-real",
+ "desc": "真实视频描述",
+ "share_url": "https://example.com/video",
+ }
+ }
+ }
+ ]
+ }
+ }
+
+ items = platform.map_items(payload, limit=5)
+
+ assert items[0].source_item_id == "aweme-real"
+ assert items[0].title == "真实视频描述"
+ assert items[0].url == "https://example.com/video"
+
+
+def test_douyin_maps_comment_id_and_missing_optional_fields():
+ platform = DouyinPlatform(client=None)
+ payload = {
+ "comments": [
+ {"comment_id": "preferred", "cid": "fallback", "text": "评论"},
+ {"cid": "fallback-only", "text": "评论2", "digg_count": 9},
+ ]
+ }
+
+ comments = platform.map_comments(payload, limit=10)
+
+ assert comments[0].source_comment_id == "preferred"
+ assert comments[0].content == "评论"
+ assert comments[0].like_count is None
+ assert comments[0].comment_time is None
+ assert comments[1].source_comment_id == "fallback-only"
+ assert comments[1].like_count == 9
+
+
+def test_douyin_maps_comments_when_user_is_none():
+ platform = DouyinPlatform(client=None)
+ payload = {"data": {"comments": [{"cid": "c-none-user", "text": "评论", "user": None}]}}
+
+ comments = platform.map_comments(payload, limit=10)
+
+ assert comments[0].source_comment_id == "c-none-user"
+ assert comments[0].author is None
+
+
+def test_douyin_maps_null_comments_as_empty_list():
+ platform = DouyinPlatform(client=None)
+ payload = {"data": {"comments": None}}
+
+ assert platform.map_comments(payload, limit=10) == []
diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py
new file mode 100644
index 0000000..cc64c70
--- /dev/null
+++ b/tests/unit/test_export.py
@@ -0,0 +1,19 @@
+from app.services.export_service import labels_to_text, safe_csv_field, safe_filename
+
+
+def test_labels_to_text_joins_json_array_with_chinese_comma():
+ assert labels_to_text('["认可", "质量好"]') == "认可,质量好"
+ assert labels_to_text("bad-json") == ""
+
+
+def test_safe_csv_field_prevents_formula_injection_and_newlines():
+ assert safe_csv_field("=1+1") == "'=1+1"
+ assert safe_csv_field("hello\nworld") == "hello world"
+
+
+def test_safe_filename_replaces_illegal_chars_and_truncates_keyword():
+ filename = safe_filename("douyin", "task", "a/b:c*d?eg|很长很长很长很长很长")
+
+ assert "/" not in filename
+ assert "__" not in filename
+ assert filename.endswith(".csv")
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
new file mode 100644
index 0000000..b20b005
--- /dev/null
+++ b/tests/unit/test_models.py
@@ -0,0 +1,88 @@
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from app.db import Base, create_sqlite_engine
+from app.models import Comment, ContentItem, Hotspot, Report, Task, create_report_record
+
+
+def test_all_t03_tables_can_be_created_in_memory_sqlite():
+ engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
+ try:
+ Base.metadata.create_all(engine)
+
+ assert set(Base.metadata.tables) >= {
+ "tasks",
+ "hotspots",
+ "content_items",
+ "comments",
+ "reports",
+ }
+ finally:
+ engine.dispose()
+
+
+def test_sqlite_engine_uses_required_connection_options():
+ engine = create_sqlite_engine("sqlite:///data/app.db")
+ try:
+ assert engine.url.database == "data/app.db"
+ assert engine.dialect.connect_args["check_same_thread"] is False
+ assert engine.dialect.connect_args["timeout"] == 10
+
+ finally:
+ engine.dispose()
+
+
+def test_task_status_rejects_partial_status_values():
+ engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
+ try:
+ Base.metadata.create_all(engine)
+
+ with Session(engine) as session:
+ session.add(Task(platform="douyin", status="partial_success"))
+
+ with pytest.raises(IntegrityError):
+ session.commit()
+ finally:
+ engine.dispose()
+
+
+def test_analysis_status_insufficient_does_not_change_task_status():
+ task = Task(
+ platform="xiaohongshu",
+ status="success",
+ analysis_status="insufficient",
+ analysis_success_rate=0.5,
+ )
+
+ assert task.status == "success"
+ assert task.analysis_status == "insufficient"
+
+
+def test_models_define_relationships_and_indexes():
+ assert any(index.name == "ix_comments_task_content_item" for index in Comment.__table__.indexes)
+ assert any(index.name == "ix_content_items_task_hotspot" for index in ContentItem.__table__.indexes)
+ assert any(index.name == "ix_reports_task_report_type" for index in Report.__table__.indexes)
+
+ assert Hotspot.task.property.mapper.class_ is Task
+ assert ContentItem.hotspot.property.mapper.class_ is Hotspot
+ assert Comment.content_item.property.mapper.class_ is ContentItem
+ assert Report.task.property.mapper.class_ is Task
+
+
+def test_report_record_application_constraint_requires_matching_owner_id():
+ with pytest.raises(ValueError, match="hotspot_id"):
+ create_report_record(task_id="task-1", report_type="hotspot", title="热点报告")
+
+ with pytest.raises(ValueError, match="content_item_id"):
+ create_report_record(task_id="task-1", report_type="content_item", title="内容报告")
+
+ report = create_report_record(
+ task_id="task-1",
+ report_type="hotspot",
+ title="热点报告",
+ hotspot_id="hot-1",
+ )
+
+ assert report.hotspot_id == "hot-1"
diff --git a/tests/unit/test_report_stats.py b/tests/unit/test_report_stats.py
new file mode 100644
index 0000000..4bdb878
--- /dev/null
+++ b/tests/unit/test_report_stats.py
@@ -0,0 +1,76 @@
+from datetime import UTC, datetime
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+from app.db import Base
+from app.models import Comment, ContentItem, Hotspot, Task
+from app.services.report_service import build_comment_metrics, generate_item_report
+
+
+def test_build_comment_metrics_counts_sentiments_and_top_labels():
+ comments = [
+ Comment(content="好", sentiment="positive", labels='["质量好", "价格好"]', like_count=5),
+ Comment(content="差", sentiment="negative", labels='["质量好"]', like_count=2),
+ Comment(content="一般", sentiment="neutral", labels="[]", like_count=1),
+ Comment(content="未知", sentiment="unknown", labels="[]", like_count=0),
+ ]
+
+ metrics = build_comment_metrics(comments)
+
+ assert metrics["sample_count"] == 4
+ assert metrics["sentiment"]["positive"]["count"] == 1
+ assert metrics["sentiment"]["negative"]["count"] == 1
+ assert metrics["sentiment"]["neutral"]["count"] == 1
+ assert metrics["sentiment"]["unknown"]["count"] == 1
+ assert metrics["top_labels"][0] == {"name": "质量好", "count": 2}
+
+
+def test_generate_item_report_persists_markdown_and_default_summary():
+ engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
+ Base.metadata.create_all(engine)
+ try:
+ with Session(engine) as session:
+ task = Task(platform="douyin", status="success")
+ session.add(task)
+ session.flush()
+ hotspot = Hotspot(task_id=task.id, platform="douyin", title="热点", raw_data="{}")
+ session.add(hotspot)
+ session.flush()
+ item = ContentItem(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ platform="douyin",
+ source_item_id="v1",
+ item_type="video",
+ title="视频",
+ status="success",
+ raw_data="{}",
+ )
+ session.add(item)
+ session.flush()
+ session.add(
+ Comment(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ content_item_id=item.id,
+ platform="douyin",
+ source_comment_id="c1",
+ content="好评",
+ sentiment="positive",
+ labels='["认可"]',
+ like_count=10,
+ comment_time=datetime(2026, 1, 1, tzinfo=UTC),
+ raw_data="{}",
+ )
+ )
+ session.commit()
+
+ report = generate_item_report(session, item.id, summary_provider=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("ai failed")))
+
+ assert report.report_type == "item"
+ assert report.content_item_id == item.id
+ assert "总结生成失败,请查看上方统计数据。" in report.markdown_content
+ assert "样本评论数量" in report.markdown_content
+ finally:
+ engine.dispose()
diff --git a/tests/unit/test_task_executor.py b/tests/unit/test_task_executor.py
new file mode 100644
index 0000000..28f0b94
--- /dev/null
+++ b/tests/unit/test_task_executor.py
@@ -0,0 +1,12 @@
+from concurrent.futures import ThreadPoolExecutor
+
+from app.services import task_service
+
+
+def test_task_executor_is_single_worker_thread_pool():
+ assert isinstance(task_service.task_executor, ThreadPoolExecutor)
+ assert task_service.task_executor._max_workers == 1
+
+
+def test_run_task_entrypoint_is_synchronous_function():
+ assert task_service.inspect.iscoroutinefunction(task_service.run_task) is False
diff --git a/tests/unit/test_template_filters.py b/tests/unit/test_template_filters.py
new file mode 100644
index 0000000..6d52973
--- /dev/null
+++ b/tests/unit/test_template_filters.py
@@ -0,0 +1,13 @@
+from app.templating import from_json_filter, status_badge_config
+
+
+def test_status_badge_config_centralizes_task_status_copy():
+ assert status_badge_config("running") == ("运行中", "bg-warning text-dark")
+ assert status_badge_config("success") == ("已完成", "bg-success")
+ assert status_badge_config("failed") == ("失败", "bg-danger")
+ assert status_badge_config("unknown") == ("未知", "bg-secondary")
+
+
+def test_from_json_filter_returns_empty_list_for_invalid_json():
+ assert from_json_filter('["认可"]') == ["认可"]
+ assert from_json_filter("bad-json") == []
diff --git a/tests/unit/test_xiaohongshu_mapping.py b/tests/unit/test_xiaohongshu_mapping.py
new file mode 100644
index 0000000..8bc270b
--- /dev/null
+++ b/tests/unit/test_xiaohongshu_mapping.py
@@ -0,0 +1,65 @@
+from app.platforms.xiaohongshu import XiaohongshuPlatform
+
+
+def test_xiaohongshu_maps_hotspots_from_items_not_outer_title():
+ platform = XiaohongshuPlatform(client=None)
+ payload = {
+ "data": {
+ "data": {
+ "title": "搜索发现",
+ "items": [
+ {"id": "hot-1", "title": "真实热点", "score": "999", "rank_change": 1}
+ ],
+ }
+ }
+ }
+
+ hotspots = platform.map_hotspots(payload, limit=5)
+
+ assert hotspots[0].source_hot_id == "hot-1"
+ assert hotspots[0].title == "真实热点"
+ assert hotspots[0].heat_value == "999"
+
+
+def test_xiaohongshu_prefers_notes_with_comments_and_falls_back_to_zero_comment_notes():
+ platform = XiaohongshuPlatform(client=None)
+ payload = {
+ "data": {
+ "data": {
+ "items": [
+ {"note": {"id": "n0", "title": "无评论", "desc": "", "comments_count": 0}},
+ {"note": {"id": "n1", "title": "有评论1", "desc": "d1", "comments_count": 3}},
+ {"note": {"id": "n2", "title": "有评论2", "desc": "d2", "comments_count": 1}},
+ ]
+ }
+ }
+ }
+
+ items = platform.map_items(payload, limit=3)
+
+ assert [item.source_item_id for item in items] == ["n1", "n2", "n0"]
+ assert all(item.item_type == "note" for item in items)
+
+
+def test_xiaohongshu_maps_comment_id_content_and_missing_optional_fields():
+ platform = XiaohongshuPlatform(client=None)
+ payload = {
+ "data": {
+ "data": {
+ "comments": [
+ {"comment_id": "preferred", "id": "fallback", "content": "正文"},
+ {"id": "fallback-only", "text": "文本字段", "like_count": 7},
+ ]
+ }
+ }
+ }
+
+ comments = platform.map_comments(payload, limit=10)
+
+ assert comments[0].source_comment_id == "preferred"
+ assert comments[0].content == "正文"
+ assert comments[0].like_count is None
+ assert comments[0].comment_time is None
+ assert comments[1].source_comment_id == "fallback-only"
+ assert comments[1].content == "文本字段"
+ assert comments[1].like_count == 7
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..ab48af1
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,937 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" },
+ { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" },
+ { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" },
+ { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" },
+ { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" },
+ { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" },
+ { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" },
+ { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" },
+ { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" },
+ { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" },
+ { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" },
+ { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" },
+ { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" },
+ { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" },
+ { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" },
+ { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" },
+ { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" },
+ { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" },
+ { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" },
+ { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" },
+ { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.139.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
+ { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
+ { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
+ { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
+ { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
+ { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
+ { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
+ { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
+ { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
+ { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
+ { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hot-comments-analysis-tool"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "fastapi" },
+ { name = "httpx" },
+ { name = "jinja2" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "sqlalchemy" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "pytest-mock" },
+ { name = "respx" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "beautifulsoup4", specifier = ">=4.12.3" },
+ { name = "fastapi", specifier = ">=0.115.0" },
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "jinja2", specifier = ">=3.1.4" },
+ { name = "pydantic", specifier = ">=2.8.0" },
+ { name = "pydantic-settings", specifier = ">=2.4.0" },
+ { name = "sqlalchemy", specifier = ">=2.0.32" },
+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.6" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.3.2" },
+ { name = "pytest-cov", specifier = ">=5.0.0" },
+ { name = "pytest-mock", specifier = ">=3.14.0" },
+ { name = "respx", specifier = ">=0.21.1" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httptools"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
+ { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
+ { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
+ { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
+ { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
+ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "respx"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.51"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
+ { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
+ { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
+ { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
+ { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
+ { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
+ { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
+ { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
+ { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
+ { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.49.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
+ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
+ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
+ { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
+ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
+ { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
+ { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
+ { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
+ { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
+ { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
+ { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
+ { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
+ { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
+ { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
+ { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
+ { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
+ { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
+ { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
+ { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
+ { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
+ { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
+ { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
+ { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
+ { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
+ { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
+ { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
+ { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
+]