diff --git a/.gitignore b/.gitignore index a1969af..53b97e6 100755 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ htmlcov/ logs/ tmp/ +# PDF 导出等本地落盘产物(不进库;线上应对接对象存储,Phase B 再处理) +var/ + # 算法 SPIKE 的模型缓存与输出(模型文件绝不入库) models/ scripts/spikes/out/ diff --git a/apps/api/deps.py b/apps/api/deps.py index faa243f..1648c5a 100644 --- a/apps/api/deps.py +++ b/apps/api/deps.py @@ -191,6 +191,21 @@ def storage_service_dep(settings: SettingsDep) -> StorageService: return StorageService(settings) +def budget_guard_dep(settings: SettingsDep) -> DailyBudgetGuard: + """全局日预算的场景闸(架构 §13.3):hd_export 等可降级场景在请求入口先过它。 + + 每次请求新建实例是刻意的:Guard 本身无状态(水位读 Redis), + 与 build_llm_gateway 里给网关用的那只互不影响。 + """ + return DailyBudgetGuard( + RedisCostCounter(get_redis()), + daily_budget_cents=Decimal(settings.llm_daily_budget_cents), + enabled=settings.llm_daily_budget_enabled, + tz_offset_hours=settings.llm_budget_timezone_offset_hours, + tiers=list(settings.llm_budget_tiers), + ) + + def agent_service_dep( session: SessionDep, gateway: GatewayDep, @@ -204,4 +219,5 @@ BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)] TaskServiceDep = Annotated[TaskService, Depends(task_service_dep)] AnalyticsServiceDep = Annotated[AnalyticsService, Depends(analytics_service_dep)] StorageServiceDep = Annotated[StorageService, Depends(storage_service_dep)] +BudgetGuardDep = Annotated[DailyBudgetGuard, Depends(budget_guard_dep)] AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)] diff --git a/apps/api/routers/reports.py b/apps/api/routers/reports.py index 5df2fb3..ecf55e9 100644 --- a/apps/api/routers/reports.py +++ b/apps/api/routers/reports.py @@ -13,10 +13,14 @@ from typing import Annotated from celery import Celery from fastapi import APIRouter, Depends, Query +from fastapi.responses import FileResponse -from apps.api.deps import CurrentUserId, SessionDep, SettingsDep +from apps.api.deps import BudgetGuardDep, CurrentUserId, SessionDep, SettingsDep +from packages.report.export_service import ReportExportService from packages.report.schemas import ( ReportCreateRequest, + ReportExportRequest, + ReportExportResponse, ReportItem, ReportRegenerateRequest, ReportSubmitResponse, @@ -29,6 +33,7 @@ from packages.shared.response import ok router = APIRouter(prefix="/reports", tags=["reports"]) REPORT_TASK_NAME = "meida.report.generate" +REPORT_EXPORT_TASK_NAME = "meida.report.export" @lru_cache(maxsize=1) @@ -43,6 +48,11 @@ def _enqueue_report(task_id: int) -> None: _producer().send_task(REPORT_TASK_NAME, args=[task_id], queue="cpu") +def _enqueue_export(task_id: int) -> None: + """PDF 导出同属 meida.report.* → cpu 队列;投递纪律与生成一致。""" + _producer().send_task(REPORT_EXPORT_TASK_NAME, args=[task_id], queue="cpu") + + def _serialize(report: RpReport) -> dict[str, object]: result = report.result or {} if report.status == "failed": @@ -67,6 +77,17 @@ def report_service_dep(session: SessionDep, settings: SettingsDep) -> ReportServ ReportServiceDep = Annotated[ReportService, Depends(report_service_dep)] +def report_export_service_dep( + session: SessionDep, settings: SettingsDep, guard: BudgetGuardDep +) -> ReportExportService: + return ReportExportService( + session, settings=settings, scene_guard=guard, enqueue=_enqueue_export + ) + + +ReportExportServiceDep = Annotated[ReportExportService, Depends(report_export_service_dep)] + + @router.post("", summary="生成第一份美学报告(终身一次,走 report_basic 配额)") async def create_report( payload: ReportCreateRequest, @@ -119,6 +140,37 @@ async def regenerate_report( return ok(_submit_payload(submission)) +@router.post( + "/{report_id}/export", summary="导出形象报告 PDF(走 hd_export 日配额,tier2 熔断可拦)" +) +async def export_report( + report_id: int, + payload: ReportExportRequest, + user_id: CurrentUserId, + service: ReportExportServiceDep, +) -> dict[str, object]: + request_key = payload.request_key or f"report-export:{user_id}:{report_id}:{uuid.uuid4().hex}" + submission = await service.create_export( + user_id=user_id, report_id=report_id, request_key=request_key + ) + return ok( + ReportExportResponse(task_id=str(submission.task_id), status=submission.status).model_dump() + ) + + +@router.get("/{report_id}/export/download", summary="下载已生成的报告 PDF(归属校验)") +async def download_report_pdf( + report_id: int, + user_id: CurrentUserId, + service: ReportExportServiceDep, +) -> FileResponse: + export_file = await service.get_export_file(user_id=user_id, report_id=report_id) + # 二进制文件不走 ok() 信封:Content-Disposition 携带展示文件名(UTF-8 百分号编码)。 + return FileResponse( + export_file.path, media_type="application/pdf", filename=export_file.filename + ) + + def _submit_payload(submission: ReportSubmission) -> dict[str, object]: return ReportSubmitResponse( task_id=str(submission.task_id), diff --git a/apps/worker/celery_app.py b/apps/worker/celery_app.py index 5e69baf..4b47d1a 100755 --- a/apps/worker/celery_app.py +++ b/apps/worker/celery_app.py @@ -27,7 +27,7 @@ celery_app = Celery( "meida", broker=settings.celery_broker_url, backend=settings.celery_result_backend, - include=["apps.worker.tasks", "apps.worker.report_tasks"], + include=["apps.worker.tasks", "apps.worker.report_tasks", "apps.worker.export_tasks"], ) celery_app.conf.update( diff --git a/apps/worker/export_tasks.py b/apps/worker/export_tasks.py new file mode 100644 index 0000000..87c568f --- /dev/null +++ b/apps/worker/export_tasks.py @@ -0,0 +1,183 @@ +"""报告 PDF 导出的 celery 任务(name=``meida.report.export``,cpu 队列)。 + +只做「取产物 → 渲染 → 落盘 → 回写 rendered_asset_key」,不重算内容。 +配额三段式与报告生成一致:成功 commit、失败 refund(FR-BILL-05), +预扣凭 ``tk_task.reserve_tx_id`` 找回(复用 pipeline.load_reservation)。 + +渲染是同步 CPU 工作(WeasyPrint 没有 async API),在 --pool=solo 的 cpu +worker 里直接执行——单份报告渲染耗时为秒级,可接受;未来上量再挪线程池。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +from apps.worker import loop as worker_loop +from apps.worker.celery_app import celery_app +from packages.billing.service import BillingService +from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus +from packages.report import repo as report_repo +from packages.report.pdf.renderer import ReportPdfMeta, render_report_pdf +from packages.report.pipeline import load_reservation +from packages.shared.config import get_settings +from packages.shared.context import bind_context +from packages.shared.db import get_session_factory +from packages.shared.errors import AppError, ErrorCode +from packages.shared.logging import get_logger +from packages.task import repo as task_repo +from packages.task.service import TaskService + +logger = get_logger(__name__) + + +class ExportRejectedError(Exception): + """业务性拒绝(如报告不再是 succeeded):已在任务内结案并退款,重跑也不会好。 + + 与流水线的 StageFailureError 同义:celery 包装层据类型区分「业务失败 + 不重试」与「基础设施异常值得重试」。 + """ + + +def _write_pdf(export_dir: str, filename: str, pdf_bytes: bytes) -> Path: + """落盘导出文件(同步:pathlib 阻塞方法不进 async 函数,ASYNC240)。 + + 返回含导出目录前缀的相对路径——rendered_asset_key 按此入库,下载侧 + 按同一 cwd 解析,且整体迁移 var/ 目录时不用改库。 + """ + directory = Path(export_dir) + directory.mkdir(parents=True, exist_ok=True) + path = directory / filename + path.write_bytes(pdf_bytes) + return path + + +async def _export_report(task_id: int) -> dict[str, Any]: + """执行一次报告导出(celery 包装层的内芯)。""" + settings = get_settings() + factory = get_session_factory() + async with factory() as session: + task = await task_repo.get_for_update(session, task_id) + if task is None: + raise AppError(ErrorCode.NOT_FOUND, message=f"任务不存在:{task_id}") + current = TaskStatus(task.status) + if current in TERMINAL_STATUSES: + # 重复投递/幂等重放的守卫:已结案的任务不再执行,防重复 commit 配额。 + logger.info("report_export_replay_skipped", task_id=task_id, status=task.status) + return {"task_id": str(task_id), "status": task.status, "skipped": True} + + report_id_raw = task.payload.get("report_id") + if not isinstance(report_id_raw, str | int): + raise AppError(ErrorCode.STATE_CONFLICT, message="任务缺少 report_id") + report_id = int(report_id_raw) + report = await report_repo.get_report(session, report_id) + if report is None: + raise AppError(ErrorCode.STATE_CONFLICT, message="报告数据不完整") + + service = TaskService(session) + # 与报告生成同一纪律:重试入站先归一 QUEUED 再进 RUNNING。 + if current is not TaskStatus.QUEUED: + await service.transition(task_id=task_id, target=TaskStatus.QUEUED, stage_text="排队中") + await service.transition(task_id=task_id, target=TaskStatus.RUNNING, stage_text="渲染中") + await session.commit() # RUNNING 先落库:前端进度读它 + + if report.status != report_repo.STATUS_SUCCEEDED: + # 发起导出后报告被重生成顶掉/置失败等:业务性拒绝,结案退款不重试。 + await _reject_export(task_id, "报告当前状态不可导出") + raise ExportRejectedError(f"report {report_id} status={report.status}") + + pdf_bytes = render_report_pdf( + report.result or {}, + meta=ReportPdfMeta( + report_id=report.report_id, + version=report.version, + generated_at=report.completed_at or report.created_at, + ), + ) + filename = f"report_{report.report_id}_v{report.version}.pdf" + asset_key = _write_pdf(settings.report_export_dir, filename, pdf_bytes).as_posix() + + report.rendered_asset_key = asset_key + reservation = await load_reservation(session, task_id) + if reservation is not None: + await BillingService(session).commit(reservation) + await service.transition( + task_id=task_id, + target=TaskStatus.SUCCEEDED, + stage_text="已完成", + result={ + "report_id": str(report.report_id), + "version": report.version, + "asset_key": asset_key, + }, + ) + await session.commit() + logger.info( + "report_export_succeeded", + report_id=report_id, + task_id=task_id, + asset_key=asset_key, + pdf_bytes=len(pdf_bytes), + ) + return {"task_id": str(task_id), "report_id": str(report_id), "status": "succeeded"} + + +async def _reject_export(task_id: int, message: str) -> None: + """业务性拒绝的结案:task failed + 退款,提交后才返回(纪律同流水线失败结案)。""" + factory = get_session_factory() + async with factory() as session: + await TaskService(session).transition( + task_id=task_id, + target=TaskStatus.FAILED, + stage_text="无法导出", + error_message=message, + ) + reservation = await load_reservation(session, task_id) + if reservation is not None: + await BillingService(session).refund(reservation, reason="report_export_rejected") + await session.commit() + + +async def _fail_export(task_id: int, message: str) -> None: + """基础设施异常重试耗尽后的兜底结案:failed + 退款(尽力而为)。""" + factory = get_session_factory() + try: + async with factory() as session: + task = await task_repo.get_for_update(session, task_id) + if task is None or TaskStatus(task.status) in TERMINAL_STATUSES: + return + await TaskService(session).transition( + task_id=task_id, + target=TaskStatus.FAILED, + stage_text="执行失败", + error_message=message, + ) + reservation = await load_reservation(session, task_id) + if reservation is not None: + await BillingService(session).refund(reservation, reason="report_export_failed") + await session.commit() + except Exception: + logger.exception("report_export_fail_settle_failed", task_id=task_id) + + +# Celery 的 task 装饰器没有类型标注,会把被装饰函数擦成 untyped +@celery_app.task(name="meida.report.export", bind=True, max_retries=1) # type: ignore[untyped-decorator] +def export_report_pdf(self: Any, task_id: int) -> dict[str, Any]: + """cpu 队列的报告 PDF 导出入口。 + + 重试语义与报告生成对齐:基础设施类异常重试一次;业务性拒绝 + (ExportRejectedError)已在任务内结案,重跑也不会好,不再重试。 + """ + bind_context(task_id=task_id) + try: + return cast("dict[str, Any]", worker_loop.run(_export_report(task_id))) + except ExportRejectedError as exc: + logger.warning("report_export_rejected", task_id=task_id, error=str(exc)) + raise + except Exception as exc: + if self.request.retries < self.max_retries: + logger.warning("report_export_retry", task_id=task_id, error=str(exc)) + raise self.retry(exc=exc, countdown=10) from exc + logger.exception("report_export_failed_after_retry", task_id=task_id) + worker_loop.run(_fail_export(task_id, f"{type(exc).__name__}: {exc}")) + raise diff --git a/packages/report/diagnosis_stage.py b/packages/report/diagnosis_stage.py index fd95c18..38049ec 100644 --- a/packages/report/diagnosis_stage.py +++ b/packages/report/diagnosis_stage.py @@ -192,8 +192,7 @@ def build_rule_context( features=tuple(_str_list(derived_features.get("body_features"))), goals=tuple(_str_list(questionnaire.get("core_goals"))), occasion=occasion, - style=_str(questionnaire.get("psyche_style")) - or _str(questionnaire.get("psych_style")), + style=_str(questionnaire.get("psyche_style")) or _str(questionnaire.get("psych_style")), user_taboos=tuple(_str_list(questionnaire.get("taboos"))), ) diff --git a/packages/report/export_service.py b/packages/report/export_service.py new file mode 100644 index 0000000..79b8f51 --- /dev/null +++ b/packages/report/export_service.py @@ -0,0 +1,208 @@ +"""报告 PDF 导出的域服务(Phase A):资格、幂等、配额预扣与任务投递。 + +与报告生成的差别:导出是「读产物 → 渲染 → 落盘」的纯加工,不重算内容, +因此不建新报告行,只建 tk_task(type=render)并在成功后回写 +``rp_report.rendered_asset_key``。 + +事务纪律与 ReportService 一致:reserve 与建任务同事务,commit 之后才投递; +幂等同样是双保险——tk_task 幂等键挡重复任务行,reserve 幂等键挡重复预扣, +服务层重放快捷路径让同键重试拿到 200。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.billing.service import BillingService +from packages.domain.task.state import TaskStatus, TaskType +from packages.report import repo +from packages.shared.config import Settings +from packages.shared.errors import AppError, ErrorCode, ForbiddenError +from packages.shared.logging import get_logger +from packages.shared.models import RpReport, Task +from packages.task import repo as task_repo +from packages.task.service import TaskService + +logger = get_logger(__name__) + +#: 导出走 hd_export 日配额(迁移 0003 种子:day / base 1 / hard_cap 3); +#: 同一字符串也是熔断档位拦截的场景名(tier2 起被 block,见架构 §13.3)。 +QUOTA_KEY_EXPORT = "hd_export" +EXPORT_REF_TYPE = "report_export" +EXPORT_ETA_SECONDS = 30 + +_TERMINAL_TASK_STATUSES = frozenset( + {TaskStatus.SUCCEEDED.value, TaskStatus.FAILED.value, TaskStatus.CANCELED.value} +) + + +class SceneGuard(Protocol): + """熔断场景闸的最小接口(DailyBudgetGuard 结构匹配)。 + + 声明 Protocol 而非直接依赖 packages.llm.budget:单测可以塞假实现, + 服务单测不必拉起 Redis。 + """ + + async def ensure_scene_allowed(self, *, scene: str, critical: bool = False) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ExportSubmission: + """一次导出提交的结果;幂等重放返回同值。""" + + task_id: int + report_id: int + status: str + + +@dataclass(frozen=True, slots=True) +class ExportFile: + """下载就绪的导出文件:磁盘路径 + 给客户端的展示文件名。""" + + path: Path + filename: str + + +def _submission_of(task: Task) -> ExportSubmission: + """从 tk_task 行重建导出提交结果(幂等重放路径)。""" + raw_report_id = task.payload.get("report_id") + if not isinstance(raw_report_id, str | int): + raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT) + return ExportSubmission(task_id=task.task_id, report_id=int(raw_report_id), status=task.status) + + +def _resolve_export_path(export_dir: str, asset_key: str) -> Path: + """同步解析导出文件路径(pathlib 的阻塞方法不进 async 函数,ASYNC240)。 + + key 由 worker 写入,越界即数据被篡改——按服务器错误处理而非 404; + 行已回写但文件丢了(磁盘清理等),对用户来说等于还没生成。 + """ + export_root = Path(export_dir).resolve() + path = Path(asset_key).resolve() + if not path.is_relative_to(export_root): + raise AppError(ErrorCode.INTERNAL_ERROR, message="导出文件路径异常") + if not path.is_file(): + raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY) + return path + + +class ReportExportService: + def __init__( + self, + session: AsyncSession, + *, + settings: Settings, + scene_guard: SceneGuard | None = None, + enqueue: Callable[[int], None] | None = None, + ) -> None: + self._session = session + self._settings = settings + # scene_guard 为 None 时跳过熔断(单测直调);API 装配层总是注入真实现。 + self._scene_guard = scene_guard + self._enqueue = enqueue + self._billing = BillingService( + session, tz_offset_hours=settings.llm_budget_timezone_offset_hours + ) + self._tasks = TaskService(session) + + # -- 发起导出 ------------------------------------------------------------ + + async def create_export( + self, *, user_id: int, report_id: int, request_key: str + ) -> ExportSubmission: + """预扣 hd_export + 建 render 任务 + commit 后投递;同 request_key 重放返回同一任务。""" + replay = await self._find_replay( + request_key=request_key, user_id=user_id, report_id=report_id + ) + if replay is not None: + return replay + + report = await self.get_report(user_id=user_id, report_id=report_id) + if report.status != repo.STATUS_SUCCEEDED: + raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的报告可以导出 PDF") + + # 熔断先于预扣:档位拦截时用户额度原样保留(架构 §13.3)。 + if self._scene_guard is not None: + await self._scene_guard.ensure_scene_allowed(scene=QUOTA_KEY_EXPORT) + + reservation = await self._billing.reserve( + user_id=user_id, + quota_key=QUOTA_KEY_EXPORT, + request_key=request_key, + amount=1, + reason=f"export report v{report.version} pdf", + ref_type=EXPORT_REF_TYPE, + ref_id=str(report.report_id), + ) + task = await self._tasks.submit( + user_id=user_id, + task_type=TaskType.RENDER, + idempotency_key=request_key, + payload={"report_id": str(report.report_id), "version": report.version}, + quota_reserved=reservation.amount, + reserve_tx_id=reservation.tx_id, + eta_seconds=EXPORT_ETA_SECONDS, + ) + if task.payload.get("report_id") != str(report.report_id): + # 并发同键:tk_task 唯一键已被先到者占住,回滚自己的预扣,按先到者返回。 + await self._session.rollback() + winner = await task_repo.find_by_idempotency_key(self._session, request_key) + if winner is None: # pragma: no cover - 唯一键刚被对手占住,不可达兜底 + raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT) + return _submission_of(winner) + + # 先提交再投递:worker 收到消息时任务行必须已经可见。 + await self._session.commit() + if self._enqueue is not None and task.status not in _TERMINAL_TASK_STATUSES: + self._enqueue(task.task_id) + logger.info( + "report_export_created", + user_id=user_id, + report_id=report_id, + task_id=task.task_id, + ) + return ExportSubmission(task.task_id, report.report_id, task.status) + + # -- 下载 ----------------------------------------------------------------- + + async def get_export_file(self, *, user_id: int, report_id: int) -> ExportFile: + """已生成校验 + 路径收敛:rendered_asset_key 只允许落在导出目录内。""" + report = await self.get_report(user_id=user_id, report_id=report_id) + if not report.rendered_asset_key: + raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY) + path = _resolve_export_path(self._settings.report_export_dir, report.rendered_asset_key) + return ExportFile(path=path, filename=f"美搭形象报告-v{report.version}.pdf") + + # -- 查询 ----------------------------------------------------------------- + + async def get_report(self, *, user_id: int, report_id: int) -> RpReport: + report = await repo.get_report(self._session, report_id) + if report is None: + raise AppError(ErrorCode.REPORT_NOT_FOUND) + if report.user_id != user_id: + # 归属校验(架构 §11.1):不能只靠 ID 不可猜,必须显式比对。 + raise ForbiddenError() + return report + + # -- 内部 ----------------------------------------------------------------- + + async def _find_replay( + self, *, request_key: str, user_id: int, report_id: int + ) -> ExportSubmission | None: + """同 request_key 的 RENDER 任务已存在时重建提交结果。""" + existing = await task_repo.find_by_idempotency_key(self._session, request_key) + if existing is None: + return None + if existing.user_id != user_id or existing.task_type != TaskType.RENDER.value: + # 幂等键全局唯一,撞上了其它用户/其它功能的关键字:按幂等冲突拒绝。 + raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT) + submission = _submission_of(existing) + if submission.report_id != report_id: + # 同键不同报告:不是同一次请求的重放。 + raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT) + return submission diff --git a/packages/report/pdf/__init__.py b/packages/report/pdf/__init__.py new file mode 100644 index 0000000..4cdfd7a --- /dev/null +++ b/packages/report/pdf/__init__.py @@ -0,0 +1,7 @@ +"""报告 PDF 渲染(Phase A):Jinja2 HTML 模板 + WeasyPrint。 + +为什么偏离 TD-12 的 Playwright 方案:本版报告的勾选行 / 色卡 / 置信度条 +用 CSS Paged Media 足以表达,而本环境拉取 Chromium(Microsoft CDN)困难; +WeasyPrint 纯 Python + Pango,无浏览器依赖、渲染确定性好。代价是复杂 +长图式交互布局表达力弱——Phase B 若要像素级复刻参考长图再评估切回。 +""" diff --git a/packages/report/pdf/renderer.py b/packages/report/pdf/renderer.py new file mode 100644 index 0000000..89eb9b4 --- /dev/null +++ b/packages/report/pdf/renderer.py @@ -0,0 +1,196 @@ +"""形象报告 PDF 渲染器:result JSONB → PDF bytes(无 I/O,纯内存渲染)。 + +输入是 ``rp_report.result``(diagnosis/copy/validation 三段产物),输出 +PDF 字节串;落盘、回写 ``rendered_asset_key`` 归 worker 任务,下载归路由—— +渲染器本身只关心「内容 → 版式」,便于单测与将来换排版。 + +安全约定(WeasyPrint 能加载外部资源,等于一个 SSRF 面): +模板不引用任何外部 URL,且自定义 url_fetcher 对**一切** URL(含 file://) +直接拒绝——未来模板误加 ```` 也只是渲染失败,绝不会把本地文件 +或内网资源打进 PDF。 + +维度中文名不在 ``diagnosis.dimensions`` 行内(DimensionResult 只有 key), +渲染时按 key 回填 ``DIMENSION_BY_KEY`` 的字典定义——PDF 展示口径与流水线 +产物口径各管各的,产物不冗余存展示字段。 +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, NoReturn + +import jinja2 +from weasyprint import HTML + +from packages.report.dimensions import DIMENSION_BY_KEY +from packages.shared.errors import AppError, ErrorCode + +_TEMPLATE_DIR = Path(__file__).parent / "templates" + +#: 档案摘要页「核心结论」卡片的维度键:体/脸/季 + 风格三件套是报告的画像锚点 +#: (与 copy.py HARD_DIMENSION_KEYS 的取舍同源——这些是用户最先看的结论)。 +_CORE_DIM_KEYS: tuple[str, ...] = ( + "body_shape", + "face_shape", + "skin_tone", + "season_code", + "gene_style", + "psyche_style", + "fusion_style_name", + "core_goals", +) + +#: 值里可能带十六进制色、值得渲染成色卡的维度键。 +_SWATCH_DIM_KEYS: frozenset[str] = frozenset( + {"best_colors", "neutral_colors", "avoid_colors", "hair_color"} +) + +_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(_TEMPLATE_DIR), + autoescape=jinja2.select_autoescape(default=True, default_for_string=True), + trim_blocks=True, + lstrip_blocks=True, +) + + +@dataclass(frozen=True, slots=True) +class ReportPdfMeta: + """渲染元信息(报告主体之外的归属字段,由 worker 从 rp_report 行带入)。""" + + report_id: int + version: int + generated_at: datetime + brand: str = "美搭" + title: str = "个人形象美学报告" + + +@dataclass(frozen=True, slots=True) +class _DimView: + """单维度的展示模型:模板保持哑渲染,格式化全部在 Python 侧完成。""" + + number: int + key: str + name_cn: str + value_text: str + confidence_pct: int | None # skipped 维度无置信度 + evidence: str + skipped: bool + swatches: tuple[dict[str, str], ...] = field(default=()) + + +def _reject_all_urls(url: str, **_options: Any) -> NoReturn: + """WeasyPrint url_fetcher:本版模板零外部资源,任何拉取都是 bug 或注入。""" + raise ValueError(f"PDF 模板不允许加载外部资源:{url!r}") + + +def _fmt_scalar(value: Any) -> str: + if value is None: + return "—" + if isinstance(value, bool): + return "是" if value else "否" + if isinstance(value, str): + return value + if isinstance(value, int | float): + return str(value) + return json.dumps(value, ensure_ascii=False) + + +def _fmt_value(value: Any) -> str: + """维度结论值 → 一行展示文本(枚举/数值/列表/字典都可能出现)。""" + if isinstance(value, list): + parts = [] + for item in value: + if isinstance(item, Mapping): + # 色卡类条目优先用中文名,其次 hex;其余字典整体 JSON 化。 + parts.append(str(item.get("name_cn") or item.get("hex") or _fmt_scalar(dict(item)))) + else: + parts.append(_fmt_scalar(item)) + return "、".join(parts) if parts else "—" + return _fmt_scalar(value) + + +def _extract_swatches(key: str, value: Any) -> tuple[dict[str, str], ...]: + """色卡维度提取 ``{hex, name}`` 列表;非色卡维度或值形不符时为空。""" + if key not in _SWATCH_DIM_KEYS or not isinstance(value, list): + return () + swatches: list[dict[str, str]] = [] + for item in value: + if isinstance(item, Mapping) and isinstance(item.get("hex"), str): + swatches.append({"hex": item["hex"], "name": str(item.get("name_cn") or "")}) + return tuple(swatches) + + +def _build_dimensions(diagnosis: Mapping[str, Any]) -> list[_DimView]: + raw_dims = diagnosis.get("dimensions") or [] + views: list[_DimView] = [] + for raw in raw_dims: + if not isinstance(raw, Mapping): + continue + key = str(raw.get("key") or "") + spec = DIMENSION_BY_KEY.get(key) + skipped = raw.get("status") == "skipped" + value = raw.get("value") + confidence = raw.get("confidence") + views.append( + _DimView( + number=int(raw.get("number") or (spec.number if spec else 0)), + key=key, + name_cn=spec.name_cn if spec else key or "未知维度", + value_text="初级版未覆盖" if skipped else _fmt_value(value), + confidence_pct=None + if skipped or not isinstance(confidence, int | float) + else round(float(confidence) * 100), + evidence=str(raw.get("evidence") or ""), + skipped=skipped, + swatches=() if skipped else _extract_swatches(key, value), + ) + ) + views.sort(key=lambda view: view.number) + return views + + +def render_report_pdf(result: Mapping[str, Any], *, meta: ReportPdfMeta) -> bytes: + """渲染一份形象报告 PDF;产物结构不完整即 40900(内容由流水线保证)。 + + 只对 ``succeeded`` 报告调用(路由/worker 已把关),这里仍校验 copy + 存在——渲染器不知道上游纪律,结构缺口必须在自己手里变成结构化错误, + 而不是 Jinja 渲染一半的 500。 + """ + copy = result.get("copy") + diagnosis = result.get("diagnosis") + if not isinstance(copy, Mapping) or not isinstance(diagnosis, Mapping): + raise AppError(ErrorCode.STATE_CONFLICT, message="报告内容不完整,无法导出 PDF") + + summary = str(copy.get("summary") or "") + sections: list[dict[str, Any]] = [] + for section in copy.get("sections") or []: + if not isinstance(section, Mapping): + continue + sections.append( + { + "title": str(section.get("title") or ""), + "paragraphs": [str(p) for p in section.get("paragraphs") or []], + } + ) + dimensions = _build_dimensions(diagnosis) + # 核心卡片按字典键集合挑,保持档案摘要页聚焦画像锚点 + core_keys = set(_CORE_DIM_KEYS) + core_views = [v for v in dimensions if v.key in core_keys and not v.skipped] + + template = _env.get_template("report.html.j2") + html = template.render( + meta=meta, + date_text=meta.generated_at.strftime("%Y年%m月%d日"), + summary=summary, + sections=sections, + dimensions=dimensions, + core_dimensions=core_views, + ok_count=sum(1 for v in dimensions if not v.skipped), + ) + # weasyprint 的 write_pdf 声明返回 Any(其 typeshed 未收口),按声明类型收窄 + pdf: bytes = HTML(string=html, base_url=None, url_fetcher=_reject_all_urls).write_pdf() + return pdf diff --git a/packages/report/pdf/templates/report.html.j2 b/packages/report/pdf/templates/report.html.j2 new file mode 100644 index 0000000..b9a1b56 --- /dev/null +++ b/packages/report/pdf/templates/report.html.j2 @@ -0,0 +1,254 @@ + + + + +{{ meta.brand }} · {{ meta.title }} + + + + +
AI 生成内容
+ + +
+
{{ meta.brand }}
+

{{ meta.title }}

+
+
{{ date_text }}
+
报告编号 {{ meta.report_id }} · 第 {{ meta.version }} 版
+
AI 生成内容
+
+ + +
+

档案摘要

+ {% if summary %} +
+

{{ summary }}

+
+ {% endif %} + {% if core_dimensions %} + + {% for pair in core_dimensions | batch(2) %} + + {% for dim in pair %} + + {% endfor %} + + {% endfor %} +
+
{{ dim.name_cn }}
+
{{ dim.value_text }}
+ {% if dim.confidence_pct is not none %} + + 置信度 {{ dim.confidence_pct }}% + {% endif %} + {% for sw in dim.swatches %} + {{ sw.name }} + {% endfor %} +
+ {% endif %} +
+ + +{% if sections %} +
+

专属解读

+ {% for section in sections %} +

{{ loop.index }}. {{ section.title }}

+ {% for para in section.paragraphs %} +

{{ para }}

+ {% endfor %} + {% endfor %} +
+{% endif %} + + +{% if dimensions %} +
+

诊断明细({{ ok_count }}/{{ dimensions | length }} 项已覆盖)

+ + + + + + {% for dim in dimensions %} + + + + + + + + {% endfor %} + +
#维度结论置信度依据
{{ dim.number }}{{ dim.name_cn }} + {{ dim.value_text }} + {% for sw in dim.swatches %} + {{ sw.name }} + {% endfor %} + + {% if dim.confidence_pct is not none %} + + {{ dim.confidence_pct }}% + {% else %}—{% endif %} + {{ dim.evidence }}
+
+{% endif %} + + + diff --git a/packages/report/schemas.py b/packages/report/schemas.py index cac88ff..a18c4b5 100644 --- a/packages/report/schemas.py +++ b/packages/report/schemas.py @@ -29,6 +29,17 @@ class ReportRegenerateRequest(BaseModel): ) +class ReportExportRequest(BaseModel): + request_key: str | None = Field( + default=None, max_length=128, description="幂等键;相同键返回同一导出任务且不重复扣配额" + ) + + +class ReportExportResponse(BaseModel): + task_id: str + status: str = Field(description="tk_task 状态;导出完成后任务中心可取到 asset_key") + + class ReportSubmitResponse(BaseModel): task_id: str report_id: str diff --git a/packages/shared/config.py b/packages/shared/config.py index 78fd657..df11296 100644 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -230,6 +230,9 @@ class Settings(BaseSettings): # 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。 report_vision_provider: str = "deterministic" report_tier: str = "basic" + # PDF 导出落盘目录(相对进程 cwd;worker 与 API 同 cwd 启动,两边解析一致)。 + # rp_report.rendered_asset_key 存的是含该目录前缀的相对路径。 + report_export_dir: str = "var/exports" # --- Observability ----------------------------------------------------- otel_enabled: bool = False diff --git a/packages/shared/errors.py b/packages/shared/errors.py index 923e42f..15fd251 100644 --- a/packages/shared/errors.py +++ b/packages/shared/errors.py @@ -42,6 +42,7 @@ class ErrorCode(IntEnum): NOT_FOUND = 40400 TASK_BONUS_UNKNOWN = 40410 REPORT_NOT_FOUND = 40420 + REPORT_EXPORT_NOT_READY = 40421 STATE_CONFLICT = 40900 TASK_BONUS_ALREADY_CLAIMED = 40910 REPORT_ALREADY_EXISTS = 40920 @@ -103,6 +104,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = { ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"), ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"), ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"), + ErrorCode.REPORT_EXPORT_NOT_READY: ErrorMeta(404, "PDF 尚未生成", "请先在报告详情页发起导出"), ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"), ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta( 409, "这个周期的任务奖励已领取", "下个周期再来" diff --git a/pyproject.toml b/pyproject.toml index 6711ed8..2a3c97e 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "cos-python-sdk-v5>=1.9.44", "tencentcloud-sdk-python-sms>=3.1.153", "numpy>=2.5.2", + "weasyprint>=69.0", + "jinja2>=3.1.6", ] [project.optional-dependencies] diff --git a/scripts/seed_pdf_e2e.sql b/scripts/seed_pdf_e2e.sql new file mode 100644 index 0000000..84d6cb8 --- /dev/null +++ b/scripts/seed_pdf_e2e.sql @@ -0,0 +1,39 @@ +INSERT INTO rp_report (user_id, tier, status, version, result, model_info, content_code, cost_cents, completed_at) +VALUES ( + 219679774354706432, + 'basic', + 'succeeded', + 1, + '{ + "diagnosis": { + "dimensions": [ + {"number": 1, "key": "gender", "status": "ok", "value": "女", "confidence": 1.0, "evidence": "问卷填写"}, + {"number": 2, "key": "height_weight", "status": "ok", "value": "165cm / 52kg", "confidence": 1.0, "evidence": "问卷填写"}, + {"number": 5, "key": "body_shape", "status": "ok", "value": "沙漏型(X 型)", "confidence": 0.86, "evidence": "肩胯宽度接近,腰线明显,腰臀比约 0.72"}, + {"number": 8, "key": "face_shape", "status": "ok", "value": "鹅蛋脸", "confidence": 0.9, "evidence": "脸长宽比约 1.4,下颌线条流畅"}, + {"number": 12, "key": "skin_tone", "status": "ok", "value": "中性二白", "confidence": 0.82, "evidence": "面部 Lab 采样 L*=68.3,色相偏中性"}, + {"number": 17, "key": "season_code", "status": "ok", "value": "浅春型", "confidence": 0.78, "evidence": "中明度、中低彩度、偏暖,对比度中等"}, + {"number": 19, "key": "best_colors", "status": "ok", "value": [{"hex": "#F5CBA7", "name_cn": "杏色"}, {"hex": "#A9CCE3", "name_cn": "雾霾蓝"}, {"hex": "#F1948A", "name_cn": "珊瑚粉"}, {"hex": "#D5DBDB", "name_cn": "浅灰"}], "confidence": 0.8, "evidence": "浅春色板映射"}, + {"number": 21, "key": "avoid_colors", "status": "ok", "value": [{"hex": "#1B2631", "name_cn": "深炭黑"}, {"hex": "#7B241C", "name_cn": "酒红"}], "confidence": 0.75, "evidence": "高对比深色压气色"}, + {"number": 25, "key": "gene_style", "status": "ok", "value": "少女型", "confidence": 0.72, "evidence": "轮廓偏曲、量感中小、动静偏静"}, + {"number": 26, "key": "psyche_style", "status": "ok", "value": "优雅知性", "confidence": 0.88, "evidence": "问卷风格偏好聚类"}, + {"number": 27, "key": "fusion_style_name", "status": "ok", "value": "清甜优雅风", "confidence": 0.8, "evidence": "基因风格 × 心理风格规则融合"}, + {"number": 28, "key": "core_goals", "status": "ok", "value": ["通勤得体", "约会加分", "显瘦"], "confidence": 0.95, "evidence": "问卷核心诉求"}, + {"number": 31, "key": "hair_status", "status": "skipped", "value": null, "evidence": "初级版未覆盖"} + ] + }, + "copy": { + "summary": "你是典型的浅春型清甜优雅风:鹅蛋脸配沙漏身形,底色干净、对比柔和。穿搭上抓住「浅色暖调 + 柔线条 + 高腰线」三件事,通勤与约会场景都能稳定出彩。", + "sections": [ + {"title": "色彩建议", "paragraphs": ["最佳色以浅春暖调为主:杏色、珊瑚粉、雾霾蓝都能提亮气色;浅灰作为中性过渡色安全百搭。", "避雷深炭黑与酒红这类高对比深色——它们会让你的面部显得疲惫,如需深色,请用驼色或燕麦色替代。"]}, + {"title": "版型与搭配", "paragraphs": ["沙漏型身材的重点是「顺腰线」:收腰连衣裙、高腰 A 字裙、短外套配高腰下装都是你的优势版型。", "避免直身廓形与 oversized 上衣完全盖住腰线;若要穿宽松款,用腰带或塞衣角点出腰的位置。"]}, + {"title": "风格落地", "paragraphs": ["你的融合风格是「清甜优雅风」:少女感来自于柔线条与浅亮色,优雅感来自于简洁剪裁与低饱和配色。", "通勤场景建议:雾霾蓝衬衫 + 浅灰高腰西裤 + 杏色针织外套;约会场景建议:珊瑚粉收腰连衣裙 + 珍珠耳饰。"]} + ] + } + }'::jsonb, + '{"pipeline": "seed-for-pdf-e2e"}'::jsonb, + 'PDFE2E01', + 0, + now() +); +SELECT report_id, status, content_code FROM rp_report; diff --git a/tests/unit/test_report_export.py b/tests/unit/test_report_export.py new file mode 100644 index 0000000..75c49b6 --- /dev/null +++ b/tests/unit/test_report_export.py @@ -0,0 +1,233 @@ +"""报告 PDF 导出(Phase A)的单测:渲染器与路由接线,不连库不发网。 + +- 渲染器:构造样例 result(32 维,ok/skipped 混合 + 色卡维度)→ 断言 + 输出为合法 PDF 头且非空;产物缺 copy → 40900;url_fetcher 全拒。 +- 路由:参照 test_admin_app.py 的方式——create_app + ASGITransport + + dependency_overrides 换假服务,只验证接线与信封/错误码透传; + 真实的资格/幂等/配额语义由 E2E 与后续集成测试覆盖。 +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI + +from apps.api.deps import current_user_id +from apps.api.main import create_app +from apps.api.routers.reports import report_export_service_dep +from packages.report.dimensions import DIMENSIONS +from packages.report.export_service import ExportFile, ExportSubmission +from packages.report.pdf.renderer import ( + ReportPdfMeta, + _reject_all_urls, + render_report_pdf, +) +from packages.shared.errors import AppError, ErrorCode + +META = ReportPdfMeta(report_id=101, version=1, generated_at=datetime(2026, 8, 29, 8, 0, tzinfo=UTC)) + + +def _sample_result() -> dict[str, Any]: + """与流水线产物同构的最小样例:21 项 ok(含色卡维度),11 项 skipped。""" + dimensions: list[dict[str, Any]] = [] + for spec in DIMENSIONS: + if spec.basic_coverage == "none": + dimensions.append( + { + "number": spec.number, + "key": spec.key, + "value": None, + "confidence": 0.0, + "source": "none", + "evidence": "初级版报告不覆盖该维度", + "rule_hits": [], + "status": "skipped", + } + ) + continue + value: Any = f"{spec.name_cn}样例结论" + if spec.key == "best_colors": + value = [ + {"hex": "#A8C3A0", "name_cn": "鼠尾草绿"}, + {"hex": "#D8B4A0", "name_cn": "裸粉"}, + ] + dimensions.append( + { + "number": spec.number, + "key": spec.key, + "value": value, + "confidence": 0.9, + "source": spec.source, + "evidence": "问卷与视觉分析一致", + "rule_hits": [], + "status": "ok", + } + ) + return { + "diagnosis": {"dimensions": dimensions, "coverage": {"basic_full": 10, "total": 32}}, + "copy": { + "summary": "你的整体气质偏向自然优雅,适合柔和明亮的色彩搭配。", + "sections": [ + { + "title": "个人风格核心诊断", + "paragraphs": ["第一段样例文案。", "第二段样例文案。"], + }, + {"title": "身材分析与穿搭指南", "paragraphs": ["第三段样例文案。"]}, + ], + "model": "unit-test", + "prompt_version": "v1", + "dropped_keys": [], + }, + "validation": { + "passed": True, + "checks": {"pejorative": 0, "consistency": 0, "absolute": 0}, + }, + } + + +# --- 渲染器 --------------------------------------------------------------- + + +def test_render_produces_pdf_with_cjk_content() -> None: + pdf = render_report_pdf(_sample_result(), meta=META) + assert pdf.startswith(b"%PDF-") + # Noto CJK 子集嵌入后体积不可能只有几 KB;阈值防「渲染出空壳」退化。 + assert len(pdf) > 20_000 + + +def test_render_rejects_result_without_copy() -> None: + with pytest.raises(AppError) as excinfo: + render_report_pdf({"diagnosis": {"dimensions": []}}, meta=META) + assert excinfo.value.code is ErrorCode.STATE_CONFLICT + + +def test_url_fetcher_rejects_everything_including_file_scheme() -> None: + # SSRF 面:模板不允许任何外部资源,含 file:// 本地文件。 + for url in ("https://evil.example/x.png", "file:///etc/passwd"): + with pytest.raises(ValueError, match="不允许加载外部资源"): + _reject_all_urls(url) + + +# --- 路由接线(假服务,不连库) -------------------------------------------- + + +class _FakeExportService: + def __init__(self) -> None: + self.create_calls: list[dict[str, Any]] = [] + self.behavior: str = "ok" + self.export_file: ExportFile | None = None + + async def create_export(self, **kwargs: Any) -> ExportSubmission: + self.create_calls.append(kwargs) + if self.behavior == "not_succeeded": + raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的报告可以导出 PDF") + if self.behavior == "degraded": + raise AppError(ErrorCode.LLM_SCENE_DEGRADED) + return ExportSubmission( + task_id=987654321098765432, report_id=kwargs["report_id"], status="pending" + ) + + async def get_export_file(self, **kwargs: Any) -> ExportFile: + if self.export_file is None: + raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY) + return self.export_file + + +@asynccontextmanager +async def _client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: + """不进 lifespan:单测不建 DB engine / LLM 网关,只测装配与信封。""" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http: + yield http + + +def _build_app(service: _FakeExportService) -> FastAPI: + app = create_app() + app.dependency_overrides[current_user_id] = lambda: 42 + app.dependency_overrides[report_export_service_dep] = lambda: service + return app + + +_AUTH = {"Authorization": "Bearer unit-test-token"} + + +async def test_export_endpoint_returns_task_envelope() -> None: + service = _FakeExportService() + async with _client(_build_app(service)) as client: + resp = await client.post( + "/api/v1/reports/101/export", json={"request_key": "k-1"}, headers=_AUTH + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + assert body["data"]["task_id"] == "987654321098765432" + assert body["data"]["status"] == "pending" + assert service.create_calls == [{"user_id": 42, "report_id": 101, "request_key": "k-1"}] + + +async def test_export_endpoint_fills_request_key_when_absent() -> None: + service = _FakeExportService() + async with _client(_build_app(service)) as client: + resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH) + assert resp.status_code == 200 + key = service.create_calls[0]["request_key"] + assert str(key).startswith("report-export:42:101:") + + +async def test_export_endpoint_propagates_state_conflict_as_40900() -> None: + service = _FakeExportService() + service.behavior = "not_succeeded" + async with _client(_build_app(service)) as client: + resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH) + assert resp.status_code == 409 + assert resp.json()["code"] == 40900 + + +async def test_export_endpoint_propagates_scene_degraded_as_60006() -> None: + # 熔断(tier2 起 hd_export 被拦)经服务透传到统一信封。 + service = _FakeExportService() + service.behavior = "degraded" + async with _client(_build_app(service)) as client: + resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH) + assert resp.status_code == 503 + assert resp.json()["code"] == 60006 + + +async def test_export_requires_authentication() -> None: + async with _client(create_app()) as client: + resp = await client.post("/api/v1/reports/101/export", json={}) + assert resp.status_code == 401 + assert resp.json()["code"] == 40100 + resp = await client.get("/api/v1/reports/101/export/download") + assert resp.status_code == 401 + assert resp.json()["code"] == 40100 + + +async def test_download_streams_pdf_with_content_disposition(tmp_path: Path) -> None: + pdf_path = tmp_path / "var" / "exports" / "report_101_v1.pdf" + pdf_path.parent.mkdir(parents=True) + pdf_path.write_bytes(b"%PDF-1.7\nunit-test-bytes") + service = _FakeExportService() + service.export_file = ExportFile(path=pdf_path, filename="美搭形象报告-v1.pdf") + async with _client(_build_app(service)) as client: + resp = await client.get("/api/v1/reports/101/export/download", headers=_AUTH) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + assert resp.content.startswith(b"%PDF-") + # 中文文件名必须走 RFC 5987 的 filename* 编码段 + assert "filename*" in resp.headers["content-disposition"] + + +async def test_download_before_export_is_40421() -> None: + service = _FakeExportService() + async with _client(_build_app(service)) as client: + resp = await client.get("/api/v1/reports/101/export/download", headers=_AUTH) + assert resp.status_code == 404 + assert resp.json()["code"] == 40421 diff --git a/uv.lock b/uv.lock index 91c64bb..8462bc7 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", + "python_full_version < '3.13'", ] [[package]] @@ -176,6 +177,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, +] + [[package]] name = "celery" version = "5.6.3" @@ -659,6 +719,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] +[[package]] +name = "cssselect2" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, +] + [[package]] name = "detect-secrets" version = "1.5.0" @@ -714,6 +787,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "zopfli" }, +] + [[package]] name = "greenlet" version = "3.5.5" @@ -960,6 +1081,18 @@ 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 = "kombu" version = "5.6.2" @@ -1183,6 +1316,7 @@ dependencies = [ { name = "cos-python-sdk-v5" }, { name = "fastapi" }, { name = "httpx" }, + { name = "jinja2" }, { name = "numpy" }, { name = "orjson" }, { name = "pgvector" }, @@ -1196,6 +1330,7 @@ dependencies = [ { name = "tenacity" }, { name = "tencentcloud-sdk-python-sms" }, { name = "uvicorn", extra = ["standard"] }, + { name = "weasyprint" }, ] [package.optional-dependencies] @@ -1225,6 +1360,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13" }, { name = "numpy", specifier = ">=2.5.2" }, { name = "onnxruntime", marker = "extra == 'spike'", specifier = ">=1.20" }, @@ -1247,6 +1383,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0" }, { name = "tencentcloud-sdk-python-sms", specifier = ">=3.1.153" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, + { name = "weasyprint", specifier = ">=69.0" }, ] provides-extras = ["spike", "dev"] @@ -1517,6 +1654,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.11.2" @@ -1730,6 +1938,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pydyf" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1753,6 +1970,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyphen" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/47/8430452269cd28863d73b903d07d329d058cf762527ff211b3864ba61fc7/pyphen-0.18.1.tar.gz", hash = "sha256:dbae6fbbe4f01cb206108b43573d857c67107be9d0e38eb1b08d6fa2210634a7", size = 2116411, upload-time = "2026-08-14T11:30:12.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1d/23801cf008f71575f0a4800463f349afa5f04b27fe783178a45cdc4d5edf/pyphen-0.18.1-py3-none-any.whl", hash = "sha256:0aa9051e15928cecadd4c632cea0258ba57215b2a197a39baa46abcdb0f47e84", size = 2116143, upload-time = "2026-08-14T11:30:10.428Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -2053,6 +2279,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/ed/c7e77cc2c64d1b4270102c7cfdf6840c217e4c5f9e70d185ceaae6d2672e/tencentcloud_sdk_python_sms-3.1.153-py2.py3-none-any.whl", hash = "sha256:951dcdb0fb34ba6c2be4782ddfd87eb3b06820b9b6e8c6014429e4f6993ae631", size = 60246, upload-time = "2026-08-09T18:50:38.483Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tinyhtml5" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -2278,6 +2528,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] +[[package]] +name = "weasyprint" +version = "69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cssselect2" }, + { name = "fonttools", extra = ["woff"] }, + { name = "pillow" }, + { name = "pydyf" }, + { name = "pyphen" }, + { name = "tinycss2" }, + { name = "tinyhtml5" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" }, +] + [[package]] name = "websockets" version = "17.0.1" @@ -2375,3 +2653,19 @@ sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa6 wheels = [ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, ] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, +]