feat(report): 报告 PDF 导出管线(Phase A,WeasyPrint 服务端渲染)
- POST /reports/{id}/export:request_key 幂等 + hd_export 日配额三段式(reserve/commit/refund)+ tier2 熔断场景闸(BudgetGuardDep),仅 succeeded 报告可导
- GET /reports/{id}/export/download:归属校验 + 路径收敛(rendered_asset_key 必须落在 report_export_dir 内),FileResponse 携带 UTF-8 文件名
- celery 任务 meida.report.export(cpu 队列):渲染→落盘 var/exports→回写 rendered_asset_key;业务性拒绝(报告状态变化)结案退款不重试,基础设施异常重试一次后兜底结案退款
- packages/report/pdf:Jinja2 模板 + WeasyPrint 渲染器(url_fetcher 全拒外部资源防 SSRF;维度中文名按 DIMENSION_BY_KEY 回填;色板维度渲染色卡)
- 单测 10 例(资格/幂等/并发同键/下载校验/渲染);scripts/seed_pdf_e2e.sql 供本地 E2E 造数
This commit is contained in:
@@ -32,6 +32,9 @@ htmlcov/
|
||||
logs/
|
||||
tmp/
|
||||
|
||||
# PDF 导出等本地落盘产物(不进库;线上应对接对象存储,Phase B 再处理)
|
||||
var/
|
||||
|
||||
# 算法 SPIKE 的模型缓存与输出(模型文件绝不入库)
|
||||
models/
|
||||
scripts/spikes/out/
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"))),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""报告 PDF 渲染(Phase A):Jinja2 HTML 模板 + WeasyPrint。
|
||||
|
||||
为什么偏离 TD-12 的 Playwright 方案:本版报告的勾选行 / 色卡 / 置信度条
|
||||
用 CSS Paged Media 足以表达,而本环境拉取 Chromium(Microsoft CDN)困难;
|
||||
WeasyPrint 纯 Python + Pango,无浏览器依赖、渲染确定性好。代价是复杂
|
||||
长图式交互布局表达力弱——Phase B 若要像素级复刻参考长图再评估切回。
|
||||
"""
|
||||
@@ -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://)
|
||||
直接拒绝——未来模板误加 ``<img src>`` 也只是渲染失败,绝不会把本地文件
|
||||
或内网资源打进 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
|
||||
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ meta.brand }} · {{ meta.title }}</title>
|
||||
<style>
|
||||
/* ── 页面与页脚:A4 分页,页脚带 AI 标识 + 免责语(合规 §合规与安全方案)── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 22mm 16mm;
|
||||
@bottom-center {
|
||||
content: "AI 生成内容,仅供参考 · 本报告由人工智能生成,不构成专业形象顾问意见 · 第 " counter(page) " / " counter(pages) " 页";
|
||||
font-size: 7.5pt;
|
||||
color: #9a8f8a;
|
||||
}
|
||||
}
|
||||
@page cover {
|
||||
/* 封面不显示页脚页码,保持仪式感 */
|
||||
@bottom-center { content: none; }
|
||||
}
|
||||
|
||||
:root {
|
||||
--accent: #a03d5a;
|
||||
--accent-soft: #f6ecef;
|
||||
--ink: #2b2624;
|
||||
--muted: #8a807b;
|
||||
--line: #e4dbd6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Noto Sans CJK SC', sans-serif;
|
||||
color: var(--ink);
|
||||
font-size: 10pt;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── 每页水印:fixed 元素在 WeasyPrint 里逐页重复 ─────────────────────── */
|
||||
.watermark {
|
||||
position: fixed;
|
||||
top: 42%;
|
||||
left: 8%;
|
||||
width: 84%;
|
||||
text-align: center;
|
||||
transform: rotate(-28deg);
|
||||
font-size: 52pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 12pt;
|
||||
color: rgba(160, 61, 90, 0.07);
|
||||
}
|
||||
|
||||
/* ── 封面 ─────────────────────────────────────────────────────────────── */
|
||||
.cover {
|
||||
page: cover;
|
||||
page-break-after: always;
|
||||
height: 240mm;
|
||||
text-align: center;
|
||||
padding-top: 62mm;
|
||||
}
|
||||
.cover .brand {
|
||||
font-size: 15pt;
|
||||
letter-spacing: 10pt;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4pt;
|
||||
margin: 0 0 10mm 0;
|
||||
}
|
||||
.cover .rule {
|
||||
width: 42mm;
|
||||
border-top: 1.2pt solid var(--accent);
|
||||
margin: 0 auto 10mm auto;
|
||||
}
|
||||
.cover .date { color: var(--muted); font-size: 11pt; letter-spacing: 2pt; }
|
||||
.cover .version { color: var(--muted); font-size: 9pt; margin-top: 2mm; }
|
||||
.cover .ai-badge {
|
||||
display: inline-block;
|
||||
margin-top: 52mm;
|
||||
padding: 2.5mm 8mm;
|
||||
border: 1pt solid var(--accent);
|
||||
border-radius: 12mm;
|
||||
color: var(--accent);
|
||||
font-size: 10pt;
|
||||
letter-spacing: 3pt;
|
||||
}
|
||||
|
||||
/* ── 章节 ─────────────────────────────────────────────────────────────── */
|
||||
h2.chapter {
|
||||
font-size: 15pt;
|
||||
color: var(--accent);
|
||||
border-left: 3.5pt solid var(--accent);
|
||||
padding-left: 4mm;
|
||||
margin: 9mm 0 4mm 0;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h3.sec-title {
|
||||
font-size: 12pt;
|
||||
margin: 7mm 0 2.5mm 0;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
p.para { margin: 0 0 3mm 0; text-align: justify; }
|
||||
|
||||
.summary-box {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
margin: 4mm 0 2mm 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ── 核心结论卡片(档案摘要页)────────────────────────────────────────── */
|
||||
.core-grid { width: 100%; border-collapse: separate; border-spacing: 2.5mm; }
|
||||
.core-card {
|
||||
background: #fbf8f7;
|
||||
border: 0.6pt solid var(--line);
|
||||
border-radius: 2.5mm;
|
||||
padding: 3.5mm 4mm;
|
||||
width: 50%;
|
||||
vertical-align: top;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.core-card .dim-name { font-size: 8.5pt; color: var(--muted); letter-spacing: 1pt; }
|
||||
.core-card .dim-value { font-size: 11.5pt; font-weight: bold; margin: 1mm 0; }
|
||||
.core-card .dim-value::before { content: "✓ "; color: var(--accent); }
|
||||
|
||||
/* ── 置信度条 ─────────────────────────────────────────────────────────── */
|
||||
.conf { background: #eee4e0; border-radius: 2mm; height: 2.2mm; width: 26mm; display: inline-block; vertical-align: middle; }
|
||||
.conf .fill { background: var(--accent); border-radius: 2mm; height: 2.2mm; }
|
||||
.conf-num { font-size: 8pt; color: var(--muted); margin-left: 1.5mm; }
|
||||
|
||||
/* ── 色卡 ─────────────────────────────────────────────────────────────── */
|
||||
.swatch { display: inline-block; width: 4.5mm; height: 4.5mm; border-radius: 1mm;
|
||||
border: 0.4pt solid #c9bcb6; margin-right: 1mm; vertical-align: middle; }
|
||||
.swatch-name { font-size: 8pt; color: var(--muted); margin-right: 2.5mm; }
|
||||
|
||||
/* ── 32 维明细表 ──────────────────────────────────────────────────────── */
|
||||
table.dims { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin-top: 3mm; }
|
||||
table.dims th {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: normal;
|
||||
padding: 2mm 2.5mm;
|
||||
text-align: left;
|
||||
}
|
||||
table.dims td {
|
||||
border-bottom: 0.5pt solid var(--line);
|
||||
padding: 2mm 2.5mm;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.dims tr { page-break-inside: avoid; }
|
||||
table.dims tr.skipped td { color: #b3a9a4; background: #faf8f7; }
|
||||
.dim-no { color: var(--muted); width: 8mm; }
|
||||
.dim-name { width: 24mm; font-weight: bold; }
|
||||
.dim-evidence { color: var(--muted); font-size: 8pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="watermark">AI 生成内容</div>
|
||||
|
||||
<!-- ── 封面 ─────────────────────────────────────────────────────────── -->
|
||||
<section class="cover">
|
||||
<div class="brand">{{ meta.brand }}</div>
|
||||
<h1>{{ meta.title }}</h1>
|
||||
<div class="rule"></div>
|
||||
<div class="date">{{ date_text }}</div>
|
||||
<div class="version">报告编号 {{ meta.report_id }} · 第 {{ meta.version }} 版</div>
|
||||
<div class="ai-badge">AI 生成内容</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 档案摘要 ───────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="chapter">档案摘要</h2>
|
||||
{% if summary %}
|
||||
<div class="summary-box">
|
||||
<p class="para" style="margin:0">{{ summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if core_dimensions %}
|
||||
<table class="core-grid">
|
||||
{% for pair in core_dimensions | batch(2) %}
|
||||
<tr>
|
||||
{% for dim in pair %}
|
||||
<td class="core-card">
|
||||
<div class="dim-name">{{ dim.name_cn }}</div>
|
||||
<div class="dim-value">{{ dim.value_text }}</div>
|
||||
{% if dim.confidence_pct is not none %}
|
||||
<span class="conf"><span class="fill" style="width: {{ dim.confidence_pct }}%"></span></span>
|
||||
<span class="conf-num">置信度 {{ dim.confidence_pct }}%</span>
|
||||
{% endif %}
|
||||
{% for sw in dim.swatches %}
|
||||
<span class="swatch" style="background: {{ sw.hex }}"></span><span class="swatch-name">{{ sw.name }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ── 正文(copy.sections 全文渲染)───────────────────────────────────── -->
|
||||
{% if sections %}
|
||||
<section>
|
||||
<h2 class="chapter">专属解读</h2>
|
||||
{% for section in sections %}
|
||||
<h3 class="sec-title">{{ loop.index }}. {{ section.title }}</h3>
|
||||
{% for para in section.paragraphs %}
|
||||
<p class="para">{{ para }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 32 维诊断明细 ──────────────────────────────────────────────────── -->
|
||||
{% if dimensions %}
|
||||
<section>
|
||||
<h2 class="chapter">诊断明细({{ ok_count }}/{{ dimensions | length }} 项已覆盖)</h2>
|
||||
<table class="dims">
|
||||
<thead>
|
||||
<tr><th class="dim-no">#</th><th class="dim-name">维度</th><th>结论</th><th style="width:30mm">置信度</th><th style="width:52mm">依据</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dim in dimensions %}
|
||||
<tr class="{{ 'skipped' if dim.skipped else '' }}">
|
||||
<td class="dim-no">{{ dim.number }}</td>
|
||||
<td class="dim-name">{{ dim.name_cn }}</td>
|
||||
<td>
|
||||
{{ dim.value_text }}
|
||||
{% for sw in dim.swatches %}
|
||||
<span class="swatch" style="background: {{ sw.hex }}"></span><span class="swatch-name">{{ sw.name }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if dim.confidence_pct is not none %}
|
||||
<span class="conf"><span class="fill" style="width: {{ dim.confidence_pct }}%"></span></span>
|
||||
<span class="conf-num">{{ dim.confidence_pct }}%</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="dim-evidence">{{ dim.evidence }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "这个周期的任务奖励已领取", "下个周期再来"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user