chore: 完成全量功能迭代与部署准备

- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
This commit is contained in:
2026-08-01 00:02:42 +08:00
parent f92eff6f65
commit 6c6f690788
19 changed files with 2000 additions and 485 deletions
+4 -2
View File
@@ -103,6 +103,7 @@ QMDSearch/
| GET | `/api/v1/auth/me` | 当前登录用户信息(脱敏) | Bearer |
| GET | `/api/v1/auth/users` | 用户列表(脱敏) | Bearer + admin |
| POST | `/api/v1/auth/users` | 创建用户(重名/非法用户名/弱密码 1001 | Bearer + admin |
| PATCH | `/api/v1/auth/users/{username}` | 更新用户角色/启用状态(仅 admin | Bearer + admin |
| POST | `/api/v1/auth/users/{username}/password` | 重置指定用户密码(成功后清除其全部 session | Bearer + admin |
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin | Bearer + admin |
| GET | `/admin` | 管理页面 | 页面登录门禁 |
@@ -113,16 +114,17 @@ QMDSearch/
## 管理页面
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含个区块:概览、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含创建/重置密码/删除)。
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码删除)。
## 认证与用户
会话制认证:登录签发 session tokenRedis 持久化,TTL 12h;Redis 不可用时降级为进程内存,重启失效),请求经 `Authorization: Bearer <token>` 携带,鉴权依赖见 `app/api/deps.py`
- **角色与权限边界**: `admin` 拥有全部权限(含 /auth/users* 用户管理);`user` 可登录并调用变更类文档端点(入库/上传/删除),访问用户管理端点返回 1006
- **启用状态与 PATCH 端点**: 用户记录含 `enabled` 字段(默认 true);admin 可经 `PATCH /auth/users/{username}` 更新角色或启用状态(请求体至少一项字段,空 body 1001;role 非法 1001;用户不存在 1004)。禁用用户时清除其全部 session(旧 token 立即失效 1005),被禁用用户登录拒绝 1005("账号已禁用");最后一个 admin 禁止降级 role 或被禁用(1001)。角色变更不清 session,同一 token 即时反映新角色
- **初始 admin 引导**: 空库启动时 `bootstrap_admin` 自动创建 admin 账号,随机明文密码仅在启动日志中打印一次(must_change_password=true),首次登录后须先经 POST /auth/password 改密,改密前访问其他端点返回 1006
- **免登录端点**: 查询类端点(POST /search、GET /documents*、GET /knowledge/*、GET /health)不需要 token
- **相关错误码**: 1005 未认证或凭证无效,1006 权限不足/首次登录须先改密
- **相关错误码**: 1005 未认证或凭证无效/账号已禁用,1006 权限不足/首次登录须先改密
## 编码规范
+4 -1
View File
@@ -80,9 +80,12 @@ async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
禁用用户被拦截(1005);must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
注:logout/password 端点经 _resolve_user 自解析,不在此拦截范围内(改密是已登录用户唯一可用接口)。
"""
user, _ = await _resolve_user(authorization)
if not user.enabled:
raise ApiError(1005, "账号已禁用")
if user.must_change_password:
raise ApiError(1006, "首次登录须先修改密码")
return user
+58 -2
View File
@@ -10,12 +10,12 @@ from typing import Any, Literal
import structlog
from fastapi import APIRouter, Depends, Header
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
from app.api import deps
from app.api.response import ApiError, ok
from app.core.sessions import SessionStoreError
from app.core.users import UserExistsError, UserRecord, UserStoreError
from app.core.users import UserExistsError, UserNotFoundError, UserRecord, UserStoreError
logger = structlog.get_logger()
@@ -50,12 +50,26 @@ class PasswordResetRequest(BaseModel):
new_password: str
class UserUpdateRequest(BaseModel):
"""管理员更新用户角色/启用状态(至少一项)"""
role: Literal["admin", "user"] | None = None
enabled: bool | None = None
@model_validator(mode="after")
def _at_least_one(self) -> "UserUpdateRequest":
if self.role is None and self.enabled is None:
raise ValueError("至少需要提供一项更新字段(role 或 enabled")
return self
def _public_user(user: UserRecord) -> dict[str, Any]:
"""用户记录脱敏:剔除 password_hash/salt"""
return {
"username": user.username,
"role": user.role,
"must_change_password": user.must_change_password,
"enabled": user.enabled,
"created_at": user.created_at,
}
@@ -69,6 +83,8 @@ async def login(req: LoginRequest) -> dict[str, Any]:
raise ApiError(2001, "认证服务暂不可用") from exc
if user is None:
raise ApiError(1005, "用户名或密码错误")
if not user.enabled:
raise ApiError(1005, "账号已禁用")
try:
token = await deps._get_session_store().create(user.username, user.role)
except SessionStoreError as exc:
@@ -151,6 +167,46 @@ async def create_user(
return ok(_public_user(user))
@router.patch("/users/{username}")
async def update_user(
username: str,
req: UserUpdateRequest,
admin: UserRecord = Depends(deps.require_admin),
) -> dict[str, Any]:
"""更新用户角色或启用状态
用户不存在 1004;role 非法 1001;最后一个 admin 降级 role 或被禁用 1001
禁用用户时清除其全部 session(已登录态立即失效)。
"""
store = deps._get_user_store()
try:
target = await store.get(username)
if target is None:
raise ApiError(1004, "用户不存在")
# 最后 admin 保护:目标当前是唯一 admin 且本次会使其失去 admin 身份或被禁用
if target.role == "admin" and await store.count_admins() <= 1:
will_lose_admin = (req.role is not None and req.role != "admin") or (req.enabled is False)
if will_lose_admin:
raise ApiError(1001, "禁止降级或禁用最后一个管理员")
updated = await store.update_user(username, role=req.role, enabled=req.enabled)
except UserStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
except UserNotFoundError as exc:
raise ApiError(1004, "用户不存在") from exc
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
# 禁用用户 → 清除其全部 session(已登录态立即失效)
if req.enabled is False:
try:
await deps._get_session_store().delete_by_username(username)
except SessionStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
logger.info(
"管理员更新用户", username=username, operator=admin.username, role=req.role, enabled=req.enabled
)
return ok(_public_user(updated))
@router.post("/users/{username}/password")
async def reset_user_password(
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
+4 -4
View File
@@ -4,7 +4,7 @@
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
"""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
@@ -67,7 +67,7 @@ def verify_password(password: str, hashed: str) -> bool:
def create_access_token(username: str, role: str) -> tuple[str, int]:
"""签发 JWT,返回 (token, expires_in_seconds)"""
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": username,
@@ -136,7 +136,7 @@ class UserStore:
user = StoredUser(
username=username,
role=role,
created_at=datetime.now(UTC),
created_at=datetime.now(timezone.utc),
hashed_password=hash_password(password),
)
try:
@@ -203,7 +203,7 @@ async def get_current_user(
)
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
return AuthUser(username=username, role=role, created_at=datetime.now(UTC))
return AuthUser(username=username, role=role, created_at=datetime.now(timezone.utc))
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
+44 -4
View File
@@ -11,7 +11,7 @@ import hmac
import json
import re
import secrets
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from redis import asyncio as redis_async
@@ -41,6 +41,10 @@ class UserStoreError(Exception):
"""用户存储后端读写异常"""
class UserNotFoundError(Exception):
"""用户不存在"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
@@ -50,7 +54,9 @@ class UserRecord:
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
created_at: str # UTC ISO8601
enabled: bool = True
# created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
class UserStore:
@@ -76,6 +82,7 @@ class UserStore:
password: str,
role: str = "user",
must_change_password: bool = False,
enabled: bool = True,
) -> UserRecord:
"""创建用户
@@ -94,6 +101,7 @@ class UserStore:
password_hash=self.hash_password(password, salt),
salt=salt.hex(),
must_change_password=must_change_password,
enabled=enabled,
created_at=datetime.now(UTC).isoformat(),
)
await self._write(record)
@@ -104,7 +112,7 @@ class UserStore:
raw = await self._read_raw(username)
if raw is None:
return None
return UserRecord(**json.loads(raw))
return self._from_raw(raw)
async def list(self) -> list[UserRecord]:
"""列出全部用户(扫描 user:* 键)"""
@@ -115,7 +123,15 @@ class UserStore:
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
except Exception as e:
raise UserStoreError("列出用户失败") from e
return [UserRecord(**json.loads(raw)) for raw in raws if raw is not None]
return [self._from_raw(raw) for raw in raws if raw is not None]
@staticmethod
def _from_raw(raw: str) -> UserRecord:
"""从 JSON 反序列化 UserRecord;存量记录无 enabled 字段时按 True 兼容"""
data = json.loads(raw)
if "enabled" not in data:
data["enabled"] = True
return UserRecord(**data)
async def delete(self, username: str) -> bool:
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
@@ -158,6 +174,30 @@ class UserStore:
"""统计 admin 角色用户数"""
return sum(1 for record in await self.list() if record.role == "admin")
async def update_user(
self,
username: str,
*,
role: str | None = None,
enabled: bool | None = None,
) -> UserRecord:
"""更新用户角色或启用状态
用户不存在抛 UserNotFoundErrorrole 非 admin/user 抛 ValueError。
role/enabled 为 None 表示不修改对应字段。更新后写回并返回最新记录。
"""
record = await self.get(username)
if record is None:
raise UserNotFoundError(f"用户不存在: {username}")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"非法角色: {role!r}(须为 admin/user")
if role is not None:
record.role = role
if enabled is not None:
record.enabled = enabled
await self._write(record)
return record
async def _read_raw(self, username: str) -> str | None:
if self._redis is None:
return self._memory.get(username)
+555 -20
View File
@@ -107,6 +107,10 @@
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; }
.fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; }
.toolbar { margin: 12px 0; }
.toolbar.users-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.users-toolbar input[type="text"], .users-toolbar select { width: auto; }
.users-toolbar input[type="text"] { min-width: 200px; }
.user-role-select { width: auto; display: inline-block; margin-right: 4px; }
.muted { color: #6b7280; font-size: 12px; }
.status-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
@@ -133,6 +137,20 @@
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
/* 通用 overlay 弹窗组件:固定全屏遮罩 + 居中卡片 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal-card {
background: #fff; border-radius: 8px; padding: 20px 24px; width: 380px;
max-width: calc(100vw - 32px); box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.modal-card .modal-title { font-size: 16px; margin: 0 0 14px; }
.modal-card .modal-content { font-size: 13px; }
.modal-card .modal-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-card .modal-strength-hint { margin-top: -6px; margin-bottom: 12px; font-size: 12px; }
.modal-card .modal-confirm-text { margin-bottom: 10px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
@@ -227,6 +245,8 @@
</form>
</div>
</div>
<!-- 通用弹窗挂载点:openModal 动态注入 .modal-overlay/.modal-card -->
<div id="modal-root"></div>
<header>
<h1>知识库管理后台</h1>
<div id="user-area" class="user-area hidden">
@@ -242,6 +262,7 @@
<button type="button" data-target="section-search">检索测试台</button>
<button type="button" data-target="section-categories">类目列表</button>
<button type="button" data-target="section-api-guide">API 指南</button>
<button type="button" data-target="section-profile" id="nav-profile">个人中心</button>
</nav>
</header>
<main>
@@ -256,6 +277,32 @@
<div id="overview-categories"></div>
</section>
<section id="section-profile" class="hidden">
<h2>个人中心</h2>
<div class="error-bar hidden" id="error-profile"></div>
<h3 style="font-size:14px;">账号信息</h3>
<div class="cards" id="profile-cards"></div>
<h3 style="font-size:14px; margin-top:24px;">修改密码</h3>
<form id="profile-password-form">
<div class="field">
<label for="profile-old-password">旧密码</label>
<input type="password" id="profile-old-password" name="old_password" required autocomplete="current-password">
</div>
<div class="field">
<label for="profile-new-password">新密码(至少 8 位)</label>
<input type="password" id="profile-new-password" name="new_password" required autocomplete="new-password">
</div>
<div class="field">
<label for="profile-confirm-password">确认新密码</label>
<input type="password" id="profile-confirm-password" name="confirm_password" required autocomplete="new-password">
</div>
<div class="muted" id="profile-strength-hint"></div>
<button type="submit" class="primary" id="btn-profile-password-submit">确认修改</button>
</form>
<h3 style="font-size:14px; margin-top:24px;">会话</h3>
<button type="button" class="action danger" id="btn-profile-logout">退出登录</button>
</section>
<section id="section-docs" class="hidden">
<h2>文档管理</h2>
<div class="error-bar hidden" id="error-docs"></div>
@@ -351,12 +398,19 @@
<section id="section-users" class="hidden">
<h2>用户管理</h2>
<div class="error-bar hidden" id="error-users"></div>
<div class="toolbar">
<div class="toolbar users-toolbar">
<input type="text" id="user-search" placeholder="按用户名搜索" autocomplete="off">
<select id="user-role-filter">
<option value="all" selected>全部角色</option>
<option value="admin">admin</option>
<option value="user">user</option>
</select>
<button type="button" class="action" id="btn-refresh-users">刷新</button>
<span class="muted" id="users-status"></span>
</div>
<table>
<thead>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>操作</th></tr>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>启用</th><th>操作</th></tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
@@ -454,7 +508,8 @@ function showLogin() {
unmountUsersSection();
}
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块) */
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块)
admin 默认进概览,user 角色默认进个人中心(user 无需访问概览统计) */
function enterApp(user) {
document.getElementById("login-overlay").classList.add("hidden");
document.getElementById("password-overlay").classList.add("hidden");
@@ -466,10 +521,11 @@ function enterApp(user) {
loadedOnce = {};
if (user.role === "admin") {
mountUsersSection();
activateSection("section-overview");
} else {
unmountUsersSection();
activateSection("section-profile");
}
activateSection("section-overview");
}
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
@@ -507,14 +563,17 @@ document.getElementById("login-form").addEventListener("submit", function (event
});
});
document.getElementById("btn-logout").addEventListener("click", function () {
/* 退出登录:调用后端 logout 端点,失败不阻塞本地登出(顶栏 + 个人中心共用) */
function doLogout() {
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
/* 服务端退出失败不阻塞本地登出 */
}).finally(function () {
clearAuth();
showLogin();
});
});
}
document.getElementById("btn-logout").addEventListener("click", doLogout);
/* ---------- 修改密码 ---------- */
@@ -575,6 +634,191 @@ document.getElementById("password-form").addEventListener("submit", function (ev
});
});
/* ---------- 个人中心 ---------- */
/* 进入区块时加载当前账号信息:GET /auth/me 返回 username/role/must_change_password/enabled/created_at */
function loadProfile() {
hideError("error-profile");
api("/api/v1/auth/me").then(function (user) {
renderProfile(user);
}).catch(function (err) { showError("error-profile", err); });
}
/* 账号信息卡片:用户名 / 角色徽章 / 创建时间 / 须改密 / 启用状态 */
function profileCard(label, value, valueClassName) {
var card = el("div", null, "card");
var num = el("div", null, "num");
if (valueClassName) {
var span = el("span", value, valueClassName);
num.appendChild(span);
} else {
num.textContent = value;
}
card.appendChild(num);
card.appendChild(el("div", label, "label"));
return card;
}
function renderProfile(user) {
var container = document.getElementById("profile-cards");
clearChildren(container);
container.appendChild(profileCard("用户名", user.username || ""));
var roleBadgeClass = "role-badge" + (user.role === "admin" ? " role-admin" : "");
container.appendChild(profileCard("角色", user.role || "", roleBadgeClass));
container.appendChild(profileCard("创建时间", user.created_at || ""));
container.appendChild(profileCard("须改密", user.must_change_password ? "是" : "否"));
container.appendChild(profileCard("启用状态", user.enabled ? "启用" : "禁用"));
}
/* 强度提示:复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强,纯文案) */
function updateProfileStrength() {
var pwd = document.getElementById("profile-new-password").value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
document.getElementById("profile-strength-hint").textContent = level ? "密码强度:" + level : "";
}
document.getElementById("profile-new-password").addEventListener("input", updateProfileStrength);
/* 个人中心改密表单:与顶栏改密弹窗同一端点 POST /auth/password
- 两次不一致本地拦截
- 旧密码错误 1005、新密码 <8 位 1001 由后端返回,统一进错误条
- 成功后:更新本地 user 状态(清除 must_change_password)、清空表单、刷新账号信息、成功提示 */
document.getElementById("profile-password-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-profile");
var newPwd = document.getElementById("profile-new-password").value;
if (newPwd !== document.getElementById("profile-confirm-password").value) {
showError("error-profile", { code: "VALIDATION", message: "两次输入的新密码不一致" });
return;
}
var submitBtn = document.getElementById("btn-profile-password-submit");
var origText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
api("/api/v1/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_password: document.getElementById("profile-old-password").value,
new_password: newPwd
})
}).then(function () {
/* 改密成功:更新本地 user 状态(清除 must_change_password 标志) */
var user = getStoredUser();
if (user && user.must_change_password) {
user.must_change_password = false;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/* 清空表单与强度提示 */
document.getElementById("profile-password-form").reset();
document.getElementById("profile-strength-hint").textContent = "";
/* 刷新账号信息展示 */
loadProfile();
alert("密码修改成功");
}).catch(function (err) {
showError("error-profile", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = origText;
});
});
/* 个人中心退出按钮:复用顶栏 doLogout 逻辑 */
document.getElementById("btn-profile-logout").addEventListener("click", doLogout);
/* ---------- 通用 overlay 弹窗组件 ----------
openModal(id, title, contentBuilder):在 #modal-root 下动态构造 .modal-overlay>.modal-card
- 关闭路径:点遮罩 / 点取消按钮 / ESC(提交中 data-submitting=true 时全部禁用)
- 提交 loading 态:setModalSubmitting 禁用按钮 + 文案改「提交中…」
- 动态内容全 textContent,无 innerHTML */
function openModal(id, title, contentBuilder) {
closeModal(id);
var overlay = el("div", null, "modal-overlay");
overlay.id = "modal-" + id;
overlay.setAttribute("data-modal-id", id);
overlay.setAttribute("data-submitting", "false");
var card = el("div", null, "modal-card");
card.appendChild(el("h3", title, "modal-title"));
var content = el("div", null, "modal-content");
contentBuilder(content);
card.appendChild(content);
overlay.appendChild(card);
/* 点遮罩关闭(提交中不可关闭) */
overlay.addEventListener("click", function (e) {
if (e.target === overlay && overlay.getAttribute("data-submitting") !== "true") {
closeModal(id);
}
});
document.getElementById("modal-root").appendChild(overlay);
}
function closeModal(id) {
var existing = document.getElementById("modal-" + id);
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function isModalSubmitting(id) {
var overlay = document.getElementById("modal-" + id);
return !!(overlay && overlay.getAttribute("data-submitting") === "true");
}
/* 提交 loading 态:data-submitting 标记 + 按钮禁用 + 文案改「提交中…」;
失败回滚时恢复按钮原始文案与可点状态 */
function setModalSubmitting(id, submitting, submitBtn) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
overlay.setAttribute("data-submitting", submitting ? "true" : "false");
if (submitBtn) {
if (submitting) {
if (!submitBtn.getAttribute("data-original-text")) {
submitBtn.setAttribute("data-original-text", submitBtn.textContent);
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
} else {
submitBtn.disabled = false;
var orig = submitBtn.getAttribute("data-original-text");
submitBtn.textContent = orig || submitBtn.textContent;
}
}
}
function showModelError(id, message) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.textContent = message || "";
box.classList.remove("hidden");
}
function hideModelError(id) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.classList.add("hidden");
box.textContent = "";
}
/* ESC 关闭最顶层弹窗(提交中不可关闭) */
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") { return; }
var modals = document.querySelectorAll(".modal-overlay");
if (modals.length === 0) { return; }
var top = modals[modals.length - 1];
if (top.getAttribute("data-submitting") === "true") { return; }
var mid = top.getAttribute("data-modal-id");
if (mid) { closeModal(mid); }
});
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
function api(path, options) {
@@ -629,6 +873,7 @@ function activateSection(targetId) {
if (targetId === "section-categories") { loadCategories(); }
if (targetId === "section-users") { loadUsers(); }
if (targetId === "section-api-guide") { renderApiGuide(); }
if (targetId === "section-profile") { loadProfile(); }
}
}
@@ -814,18 +1059,72 @@ function renderNodes(panel, nodes) {
});
}
/* 删除文档弹窗:复用通用弹窗组件,输入 doc_id 匹配后才可提交;
与用户管理删除保持一致的二次确认体验(不再使用原生弹窗) */
function deleteDocument(docId, title) {
if (!confirm("确定删除文档「" + (title || docId) + "」(" + docId + ") 吗?该操作将删除四层集合中的全部数据,不可恢复。")) {
hideError("error-docs");
openModal("delete-doc", "删除文档", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将删除四层集合中的全部数据";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "文档:" + (title || docId) + "doc_id=" + docId + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入 doc_id " + docId + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-doc-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-doc-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-doc")) { closeModal("delete-doc"); }
});
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== docId);
});
submitBtn.addEventListener("click", function () {
if (input.value !== docId) {
showModelError("delete-doc", "输入的 doc_id 不匹配");
return;
}
hideError("error-docs");
hideModelError("delete-doc");
setModalSubmitting("delete-doc", true, submitBtn);
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
closeModal("delete-doc");
document.getElementById("doc-detail").classList.add("hidden");
loadDocuments(true);
loadOverview();
var status = document.getElementById("docs-status");
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
}).catch(function (err) { showError("error-docs", err); });
}).catch(function (err) {
showModelError("delete-doc", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-doc", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
@@ -1125,6 +1424,9 @@ function mountUsersSection() {
document.getElementById("nav").appendChild(navBtn);
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
document.getElementById("user-create-form").addEventListener("submit", createUser);
/* 搜索框 input + 角色筛选 change:仅本地重新过滤渲染,不发后端请求 */
document.getElementById("user-search").addEventListener("input", applyUsersFilter);
document.getElementById("user-role-filter").addEventListener("change", applyUsersFilter);
}
function unmountUsersSection() {
@@ -1141,18 +1443,84 @@ function loadUsers() {
}).catch(function (err) { showError("error-users", err); });
}
var usersCache = [];
function renderUsers(users) {
usersCache = users || [];
applyUsersFilter();
}
/* 列表搜索筛选:按用户名(包含、不区分大小写)+ 角色下拉过滤,仅本地重新渲染不发请求 */
function applyUsersFilter() {
var tbody = document.getElementById("users-tbody");
clearChildren(tbody);
users.forEach(function (user) {
var keyword = (document.getElementById("user-search").value || "").trim().toLowerCase();
var roleFilter = document.getElementById("user-role-filter").value;
var currentUser = getStoredUser();
usersCache.forEach(function (user) {
if (keyword && (user.username || "").toLowerCase().indexOf(keyword) === -1) { return; }
if (roleFilter !== "all" && user.role !== roleFilter) { return; }
tbody.appendChild(buildUserRow(user, currentUser));
});
}
/* 单行渲染:行内角色 select + 保存按钮、启用开关;当前登录 admin 自身行禁用防自锁降级 */
function buildUserRow(user, currentUser) {
var isSelf = !!(currentUser && currentUser.username === user.username);
var tr = el("tr");
tr.appendChild(el("td", user.username));
/* 角色:行内 select + 保存按钮(自身行 select disabled + 无保存按钮) */
var roleTd = el("td");
roleTd.appendChild(el("span", user.role, "role-badge" + (user.role === "admin" ? " role-admin" : "")));
var roleSelect = el("select", null, "user-role-select");
var adminOpt = el("option", "admin");
adminOpt.value = "admin";
var userOpt = el("option", "user");
userOpt.value = "user";
roleSelect.appendChild(userOpt);
roleSelect.appendChild(adminOpt);
roleSelect.value = user.role;
if (isSelf) {
roleSelect.disabled = true;
} else {
var saveRoleBtn = el("button", "保存", "action user-role-save");
saveRoleBtn.type = "button";
saveRoleBtn.disabled = true;
saveRoleBtn.addEventListener("click", function () {
updateUserRole(user.username, roleSelect.value, saveRoleBtn);
});
roleSelect.addEventListener("change", function () {
saveRoleBtn.disabled = (roleSelect.value === user.role);
});
roleTd.appendChild(saveRoleBtn);
}
roleTd.appendChild(roleSelect);
tr.appendChild(roleTd);
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
tr.appendChild(el("td", user.created_at || ""));
/* 启用开关:当前状态文字 + 样式区分;自身行 disabled 不可点 */
var enabledTd = el("td");
var isEnabled = !!user.enabled;
var toggleBtn = el(
"button",
isEnabled ? "启用" : "禁用",
"action user-enabled-toggle" + (isEnabled ? "" : " danger")
);
toggleBtn.type = "button";
if (isSelf) {
toggleBtn.disabled = true;
toggleBtn.title = "不能禁用当前登录账号";
} else {
toggleBtn.addEventListener("click", function () {
toggleUserEnabled(user.username, isEnabled);
});
}
enabledTd.appendChild(toggleBtn);
tr.appendChild(enabledTd);
/* 操作:重置密码 + 删除(均走通用弹窗组件,无 prompt/confirm 调用) */
var opsTd = el("td");
var resetBtn = el("button", "重置密码", "action");
resetBtn.type = "button";
@@ -1164,10 +1532,50 @@ function renderUsers(users) {
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
tbody.appendChild(tr);
return tr;
}
function showUsersStatus(text) {
var span = document.getElementById("users-status");
if (!span) { return; }
span.textContent = text || "";
if (text) {
setTimeout(function () {
if (span.textContent === text) { span.textContent = ""; }
}, 2500);
}
}
/* 行内角色保存:PATCH {role},成功刷新列表 + 状态提示;失败错误条 */
function updateUserRole(username, newRole, btn) {
hideError("error-users");
if (btn) { btn.disabled = true; }
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole })
}).then(function () {
showUsersStatus("已更新 " + username + " 的角色");
loadUsers();
}).catch(function (err) {
showError("error-users", err);
if (btn) { btn.disabled = false; }
});
}
/* 行内启用/禁用开关:PATCH {enabled: !当前},成功刷新列表 + 状态提示;失败错误条 */
function toggleUserEnabled(username, currentEnabled) {
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !currentEnabled })
}).then(function () {
showUsersStatus("已" + (!currentEnabled ? "启用" : "禁用") + " 用户 " + username);
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
function createUser(event) {
event.preventDefault();
hideError("error-users");
@@ -1186,31 +1594,158 @@ function createUser(event) {
}).catch(function (err) { showError("error-users", err); });
}
/* 重置密码弹窗:新密码 + 确认密码 + 强度提示(<8 弱 / ≥8 中 / ≥12 强,纯文案)+
两次不一致本地拦截;提交按钮 loading 态;成功关闭弹窗 + 刷新列表 + 状态提示 */
function resetUserPassword(username) {
var newPwd = prompt("为用户「" + username + "」设置新密码(至少 8 位):");
if (newPwd === null) { return; }
hideError("error-users");
openModal("reset-password", "重置用户「" + username + "」密码", function (content) {
var newField = el("div", null, "field");
newField.appendChild(el("label", "新密码(至少 8 位)"));
var newInput = el("input");
newInput.type = "password";
newInput.id = "modal-reset-new";
newInput.setAttribute("autocomplete", "new-password");
newField.appendChild(newInput);
content.appendChild(newField);
var confirmField = el("div", null, "field");
confirmField.appendChild(el("label", "确认新密码"));
var confirmInput = el("input");
confirmInput.type = "password";
confirmInput.id = "modal-reset-confirm";
confirmInput.setAttribute("autocomplete", "new-password");
confirmField.appendChild(confirmInput);
content.appendChild(confirmField);
var hint = el("div", null, "modal-strength-hint muted");
hint.id = "modal-reset-strength";
content.appendChild(hint);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-reset-error";
content.appendChild(errBox);
/* 强度提示:纯文案,<8 弱 / ≥8 中 / ≥12 强 */
function updateStrength() {
var pwd = newInput.value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
hint.textContent = level ? "密码强度:" + level : "";
}
newInput.addEventListener("input", updateStrength);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "确认重置", "primary");
submitBtn.type = "button";
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("reset-password")) { closeModal("reset-password"); }
});
submitBtn.addEventListener("click", function () {
var newPwd = newInput.value;
var confirmPwd = confirmInput.value;
if (!newPwd) {
showError("error-users", { code: "VALIDATION", message: "新密码不能为空" });
showModelError("reset-password", "新密码不能为空");
return;
}
hideError("error-users");
/* 两次不一致本地拦截 */
if (newPwd !== confirmPwd) {
showModelError("reset-password", "两次输入的新密码不一致");
return;
}
hideModelError("reset-password");
setModalSubmitting("reset-password", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPwd })
}).then(function () {
closeModal("reset-password");
loadUsers();
}).catch(function (err) { showError("error-users", err); });
showUsersStatus("已重置 " + username + " 的密码");
}).catch(function (err) {
showModelError("reset-password", (err && err.message) ? err.message : String(err));
setModalSubmitting("reset-password", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { newInput.focus(); }, 0);
});
}
/* 删除用户弹窗:警告文案 + 「请输入用户名 {username} 以确认」+ 文本输入框;
输入 !== username 时删除按钮禁用;匹配后启用;提交按钮 loading 态 */
function deleteUser(username, role) {
if (!confirm("确定删除用户「" + username + "」(角色 " + role + ")吗?该操作不可恢复。")) {
hideError("error-users");
openModal("delete-user", "删除用户", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将清除该用户及其全部会话";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "目标用户:" + username + "(角色 " + role + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入用户名 " + username + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
/* 初始禁用:输入匹配后才启用 */
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-user")) { closeModal("delete-user"); }
});
/* 输入 !== username 时删除按钮禁用;匹配后启用 */
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== username);
});
submitBtn.addEventListener("click", function () {
if (input.value !== username) {
showModelError("delete-user", "输入的用户名不匹配");
return;
}
hideError("error-users");
hideModelError("delete-user");
setModalSubmitting("delete-user", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
closeModal("delete-user");
loadUsers();
}).catch(function (err) { showError("error-users", err); });
showUsersStatus("已删除用户 " + username);
}).catch(function (err) {
showModelError("delete-user", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-user", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 7. API 指南 ---------- */
+27 -2
View File
@@ -1,6 +1,23 @@
# ============================================================
# QMDSearch — 部署 compose(相对路径,可任意目录部署)
# ------------------------------------------------------------
# 数据库数据 / 配置 / 代码 / 前端 全部从本地目录挂载,
# 日常更新(改代码或前端)只需改文件 + `docker compose restart app`
# 无需重新 build 镜像。
#
# 说明:
# - 相对路径相对于本 compose 文件所在目录(即项目根目录)解析,
# 因此直接把整个项目目录放到 NAS(或任意主机)即可部署。
# - app 服务复用已构建的 qmdsearch-app 镜像作为「带依赖的运行时」
# (Python 依赖装在镜像内,代码与前端构建产物走挂载)。
# - 仅当 Python 依赖(pyproject.toml)变化时,才需 `docker build -t qmdsearch-app .` 一次。
# - 数据目录默认 ./data,可通过 .env 的 NAS_DATA_DIR 覆盖到其他磁盘。
# ============================================================
services:
app:
build: .
# 不再 build 项目代码,改用已含依赖的运行时镜像;代码与前端均走下方挂载
image: qmdsearch-app:latest
container_name: qmdsearch-app
restart: unless-stopped
ports:
@@ -13,7 +30,7 @@ services:
# 针对 16 线程 / 61GB 内存的 NAS 调优:放宽入库并发
- INGEST_MAX_CONCURRENCY=${INGEST_MAX_CONCURRENCY:-4}
env_file:
- .env
- ./.env
depends_on:
qdrant:
condition: service_started
@@ -22,6 +39,14 @@ services:
ollama:
condition: service_started
volumes:
# ---- 代码(本地,改后 restart 即生效,免 build----
- ./app:/app/app
- ./scripts:/app/scripts
# ---- 前端构建产物(本地 frontend/dist,改前端后 build 到此目录 + restart----
- ./frontend/dist:/app/app/static/admin
# ---- 配置(显式文件映射,只读)----
- ./.env:/app/.env:ro
# ---- 数据 / 日志(持久化)----
- ${NAS_DATA_DIR:-./data}/logs:/app/logs
- ${NAS_DATA_DIR:-./data}/uploads:/app/uploads
networks:
+46
View File
@@ -17,3 +17,49 @@ export function login(username, password) {
export function me() {
return http.get('/api/v1/auth/me')
}
/**
* 列出全部用户(admin 专用)
* @returns {Promise<Array<{username:string, role:string, enabled:boolean, must_change_password:boolean, created_at:string}>>}
*/
export function listUsers() {
return http.get('/api/v1/auth/users')
}
/**
* 创建用户(admin 专用)
* @param {string} username
* @param {string} password
* @param {'admin'|'user'} [role='user']
*/
export function createUser(username, password, role = 'user') {
return http.post('/api/v1/auth/users', { username, password, role })
}
/**
* 更新用户角色 / 启用状态(admin 专用,至少传一项)
* @param {string} username
* @param {{ role?: 'admin'|'user', enabled?: boolean }} payload
*/
export function updateUser(username, payload) {
return http.patch(`/api/v1/auth/users/${encodeURIComponent(username)}`, payload)
}
/**
* 重置指定用户密码(admin 专用)
* @param {string} username
* @param {string} newPassword
*/
export function resetPassword(username, newPassword) {
return http.post(`/api/v1/auth/users/${encodeURIComponent(username)}/password`, {
new_password: newPassword
})
}
/**
* 删除用户(admin 专用)
* @param {string} username
*/
export function deleteUser(username) {
return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`)
}
+13 -4
View File
@@ -13,7 +13,9 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
DatabaseOutlined
DatabaseOutlined,
ApiOutlined,
TeamOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
@@ -30,14 +32,21 @@ const selectedKeys = computed(() => {
const openKeys = ref(['main'])
const menuItems = [
const menuItems = computed(() => {
const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' }
]
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
]
if (authStore.isAdmin) {
items.push({ key: 'users', icon: () => h(TeamOutlined), label: '用户管理' })
}
return items
})
function handleMenuClick({ key }) {
if (key && key !== route.name) {
+16
View File
@@ -49,6 +49,18 @@ const routes = [
name: 'settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '设置', requiresAuth: true }
},
{
path: 'api-docs',
name: 'api-docs',
component: () => import('@/views/ApiDocs.vue'),
meta: { title: 'API 说明', requiresAuth: true }
},
{
path: 'users',
name: 'users',
component: () => import('@/views/Users.vue'),
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
}
]
},
@@ -81,6 +93,10 @@ router.beforeEach((to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta?.requiresAdmin && !authStore.isAdmin) {
return { name: 'overview' }
}
if (to.name === 'login' && authStore.isAuthenticated) {
return { name: 'overview' }
}
+208
View File
@@ -0,0 +1,208 @@
<script setup>
import { computed } from 'vue'
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined } from '@ant-design/icons-vue'
// 以下内容整理自项目 README.md 的「API 文档」章节
const apiList = [
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
{ method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭)', auth: false },
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false },
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true },
{ method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true },
{ method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart202 异步)', auth: true },
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true },
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true },
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true },
{ method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true },
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true },
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true },
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
]
const methodColor = {
GET: 'green',
POST: 'blue',
DELETE: 'red',
PUT: 'orange'
}
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
-H "Content-Type: application/json" \\
-d '{"username": "admin", "password": "your-password"}'`
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <token>" \\
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
-H "Authorization: Bearer <token>" \\
-F "file=@document.pdf"`
</script>
<template>
<div class="apidocs page-section">
<div class="apidocs__header">
<h2 class="page-title">
<ApiOutlined /> API 使用说明
</h2>
<span class="text-muted">接口前缀{{ baseUrl }}/api/v1</span>
</div>
<a-alert
class="apidocs__tip"
type="info"
show-icon
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取。"
/>
<!-- 统一响应格式 -->
<section class="apidocs__block">
<h3 class="section-subtitle">
<CodeOutlined /> 统一响应格式
</h3>
<ul class="apidocs__ul">
<li>所有接口返回统一 JSON 结构<code>code</code> / <code>data</code> / <code>message</code></li>
<li>错误码<code>0</code> 成功<code>1xxx</code> 客户端错误<code>2xxx</code> 服务端错误</li>
</ul>
<pre class="code-block">{
"code": 0,
"data": { ... },
"message": "ok"
}</pre>
</section>
<!-- API 列表 -->
<section class="apidocs__block">
<h3 class="section-subtitle">接口列表</h3>
<a-table
:columns="[
{ title: '方法', dataIndex: 'method', key: 'method', width: 90, align: 'center' },
{ title: '路径', dataIndex: 'path', key: 'path', width: 320, ellipsis: true },
{ title: '说明', dataIndex: 'desc', key: 'desc' },
{ title: '认证', dataIndex: 'auth', key: 'auth', width: 80, align: 'center' }
]"
:data-source="apiList"
:pagination="false"
row-key="path"
size="middle"
>
<template #bodyCell="{ column, text, record }">
<template v-if="column.key === 'method'">
<a-tag :color="methodColor[record.method]">{{ record.method }}</a-tag>
</template>
<template v-else-if="column.key === 'path'">
<code class="api-path">{{ text }}</code>
</template>
<template v-else-if="column.key === 'auth'">
<a-tag :color="record.auth ? 'volcano' : 'default'">
{{ record.auth ? '需鉴权' : '否' }}
</a-tag>
</template>
</template>
</a-table>
</section>
<!-- 认证说明 -->
<section class="apidocs__block">
<h3 class="section-subtitle">
<SafetyCertificateOutlined /> 获取 Token
</h3>
<p class="apidocs__p">先调用登录接口获取 JWT再在后续请求的 Header 中携带</p>
<pre class="code-block">{{ loginExample }}</pre>
</section>
<!-- 检索示例 -->
<section class="apidocs__block">
<h3 class="section-subtitle">
<CodeOutlined /> 检索示例
</h3>
<pre class="code-block">{{ searchExample }}</pre>
</section>
<!-- 文件上传示例 -->
<section class="apidocs__block">
<h3 class="section-subtitle">
<CodeOutlined /> 文件上传示例
</h3>
<pre class="code-block">{{ uploadExample }}</pre>
</section>
</div>
</template>
<style scoped>
.apidocs__header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.page-title :deep(.anticon) {
margin-right: 8px;
color: #1677ff;
}
.apidocs__tip {
margin-bottom: 20px;
}
.apidocs__block {
margin-bottom: 24px;
}
.section-subtitle :deep(.anticon) {
margin-right: 6px;
color: #1677ff;
}
.apidocs__ul {
margin: 0 0 12px;
padding-left: 20px;
color: #4b5563;
font-size: 13px;
line-height: 1.9;
}
.apidocs__ul code,
.api-path {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 1px 6px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #c0341d;
}
.api-path {
white-space: nowrap;
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
border-color: rgba(22, 119, 255, 0.2);
}
.apidocs__p {
color: #4b5563;
font-size: 13px;
margin: 0 0 10px;
}
.code-block {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
padding: 16px 18px;
overflow-x: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12.5px;
line-height: 1.7;
margin: 0;
white-space: pre;
}
</style>
+17
View File
@@ -1,6 +1,7 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { FileOutlined } from '@ant-design/icons-vue'
import {
list as fetchDocuments,
detail as fetchDocumentDetail,
@@ -8,6 +9,13 @@ import {
} from '@/api/documents'
import { truncate } from '@/utils/format'
function formatSize(bytes) {
if (bytes == null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const isLoading = ref(false)
const isLoadingDetail = ref(false)
const isDeleting = ref(false)
@@ -204,6 +212,15 @@ onMounted(() => {
<a-descriptions-item label="chunks_count">
{{ detailData.chunks_count ?? 0 }}
</a-descriptions-item>
<a-descriptions-item label="原文文件" v-if="detailData.file">
<a :href="detailData.file.url" target="_blank" rel="noopener">
<FileOutlined />
{{ detailData.file.filename }}
<span class="text-muted" v-if="detailData.file.size_bytes">
{{ formatSize(detailData.file.size_bytes) }}
</span>
</a>
</a-descriptions-item>
</a-descriptions>
<h3 class="documents__section-title">L1 全文</h3>
+289
View File
@@ -0,0 +1,289 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
TeamOutlined,
UserAddOutlined,
ReloadOutlined,
KeyOutlined,
DeleteOutlined,
StopOutlined,
CheckCircleOutlined
} from '@ant-design/icons-vue'
import {
listUsers,
createUser,
updateUser,
resetPassword,
deleteUser
} from '@/api/auth'
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
const currentUsername = computed(() => authStore.user?.username || '')
const isLoading = ref(false)
const users = ref([])
const createVisible = ref(false)
const creating = ref(false)
const createForm = reactive({ username: '', password: '', role: 'user' })
const resetVisible = ref(false)
const resetting = ref(false)
const resetTarget = ref('')
const resetForm = reactive({ new_password: '' })
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '角色', dataIndex: 'role', key: 'role', width: 100, align: 'center' },
{ title: '状态', key: 'enabled', width: 90, align: 'center' },
{ title: '强制改密', key: 'must_change_password', width: 90, align: 'center' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 230, fixed: 'right' }
]
async function loadUsers() {
isLoading.value = true
try {
users.value = await listUsers()
} catch (err) {
message.error(err?.message || '加载用户列表失败')
} finally {
isLoading.value = false
}
}
function formatTime(ts) {
if (!ts) return '-'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return String(ts)
return d.toLocaleString('zh-CN', { hour12: false })
}
// 不能对当前登录账号做删除/禁用(后端亦会拦截)
function isSelf(username) {
return username === currentUsername.value
}
function openCreate() {
createForm.username = ''
createForm.password = ''
createForm.role = 'user'
createVisible.value = true
}
async function handleCreate() {
if (!createForm.username.trim()) {
message.warning('请输入用户名')
return
}
if (createForm.password.length < 8) {
message.warning('密码至少 8 位')
return
}
creating.value = true
try {
await createUser(createForm.username.trim(), createForm.password, createForm.role)
message.success(`已创建用户 ${createForm.username.trim()}`)
createVisible.value = false
await loadUsers()
} catch (err) {
message.error(err?.message || '创建用户失败')
} finally {
creating.value = false
}
}
function openReset(username) {
resetTarget.value = username
resetForm.new_password = ''
resetVisible.value = true
}
async function handleReset() {
if (resetForm.new_password.length < 8) {
message.warning('新密码至少 8 位')
return
}
resetting.value = true
try {
await resetPassword(resetTarget.value, resetForm.new_password)
message.success(`已重置 ${resetTarget.value} 的密码`)
resetVisible.value = false
} catch (err) {
message.error(err?.message || '重置密码失败')
} finally {
resetting.value = false
}
}
async function toggleEnabled(record) {
const enabled = !record.enabled
try {
await updateUser(record.username, { enabled })
message.success(`${record.username}${enabled ? '启用' : '禁用'}`)
await loadUsers()
} catch (err) {
message.error(err?.message || '更新状态失败')
}
}
function handleDelete(record) {
Modal.confirm({
title: '确认删除用户',
content: `确定删除用户「${record.username}」吗?该操作不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await deleteUser(record.username)
message.success(`已删除 ${record.username}`)
await loadUsers()
} catch (err) {
message.error(err?.message || '删除用户失败')
}
}
})
}
onMounted(() => {
loadUsers()
})
</script>
<template>
<div class="users page-section">
<div class="users__header">
<h2 class="page-title">
<TeamOutlined /> 用户管理
</h2>
<a-space>
<a-button :loading="isLoading" @click="loadUsers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="openCreate">
<template #icon><UserAddOutlined /></template>
新建用户
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="users"
:pagination="false"
:loading="isLoading"
row-key="username"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'username'">
<span>{{ record.username }}</span>
<a-tag v-if="isSelf(record.username)" color="gold" style="margin-left: 6px">当前</a-tag>
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="record.role === 'admin' ? 'green' : 'blue'">{{ record.role }}</a-tag>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled ? 'success' : 'default'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'must_change_password'">
<a-tag v-if="record.must_change_password" color="orange">需改密</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'created_at'">
<span class="text-muted">{{ formatTime(record.created_at) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openReset(record.username)">
<template #icon><KeyOutlined /></template>
重置密码
</a-button>
<a-button
type="link"
size="small"
:disabled="isSelf(record.username)"
@click="toggleEnabled(record)"
>
<template #icon>
<StopOutlined v-if="record.enabled" />
<CheckCircleOutlined v-else />
</template>
{{ record.enabled ? '禁用' : '启用' }}
</a-button>
<a-button
type="link"
size="small"
danger
:disabled="isSelf(record.username)"
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新建用户 -->
<a-modal
v-model:open="createVisible"
title="新建用户"
ok-text="创建"
cancel-text="取消"
:confirm-loading="creating"
@ok="handleCreate"
>
<a-form layout="vertical">
<a-form-item label="用户名" required>
<a-input v-model:value="createForm.username" placeholder="登录用户名" />
</a-form-item>
<a-form-item label="密码" required>
<a-input-password v-model:value="createForm.password" placeholder="至少 8 位" />
</a-form-item>
<a-form-item label="角色">
<a-radio-group v-model:value="createForm.role">
<a-radio value="user">普通用户 (user)</a-radio>
<a-radio value="admin">管理员 (admin)</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
<!-- 重置密码 -->
<a-modal
v-model:open="resetVisible"
title="重置密码"
ok-text="重置"
cancel-text="取消"
:confirm-loading="resetting"
@ok="handleReset"
>
<p class="text-muted">目标用户{{ resetTarget }}</p>
<a-form-item label="新密码" required>
<a-input-password v-model:value="resetForm.new_password" placeholder="至少 8 位" />
</a-form-item>
</a-modal>
</div>
</template>
<style scoped>
.users__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title :deep(.anticon) {
margin-right: 8px;
color: #1677ff;
}
</style>
-5
View File
@@ -15,11 +15,6 @@ dependencies = [
"openai>=1.58.0",
"pyjwt>=2.13.0",
"bcrypt>=5.0.0",
"python-multipart>=0.0.20",
"pypdf>=5.1.0",
"python-docx>=1.1.2",
"pypdfium2>=4.0.0", # PDF 渲染为图片供 OCR 使用
"rapidocr-onnxruntime>=1.3.8", # 扫描件 PDF OCR 降级(首次调用时下载约 25MB 模型)
]
[project.optional-dependencies]
+163 -9
View File
@@ -5,11 +5,11 @@
2. 五区块可识别标记(文案与 section id)
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*
5. 删除操作的 confirm() 二次确认逻辑
5. 删除/重置操作的通用 overlay 弹窗组件(替换原生 prompt/confirm
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
9. 用户管理区块:列表/创建表单/角色下拉/重置/删除 confirm、admin 角色门禁
9. 用户管理区块:列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗、admin 角色门禁
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
@@ -140,9 +140,10 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
assert "disabled" in admin_html
def test_admin_page_has_confirm(admin_html: str) -> None:
"""删除操作包含 confirm() 二次确认逻辑"""
assert "confirm(" in admin_html
def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None:
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
assert "prompt(" not in admin_html
assert "confirm(" not in admin_html
def test_admin_page_login_card(admin_html: str) -> None:
@@ -193,8 +194,52 @@ def test_admin_page_password_form(admin_html: str) -> None:
assert "passwordForced" in admin_html
def test_admin_page_profile_section(admin_html: str) -> None:
"""个人中心区块:导航按钮、section、账号信息卡片、改密表单、退出按钮、
user 角色默认进入、GET /auth/me 与 POST /auth/password 路径"""
# section 与导航按钮(不限 admin,所有登录用户可见)
assert 'id="section-profile"' in admin_html
assert 'id="nav-profile"' in admin_html
assert 'data-target="section-profile"' in admin_html
assert "个人中心" in admin_html
# 账号信息卡片字段:用户名 / 角色 / 创建时间 / 须改密 / 启用状态
assert 'id="profile-cards"' in admin_html
for label in ("用户名", "角色", "创建时间", "须改密", "启用状态"):
assert label in admin_html
# 改密表单:旧密码 / 新密码 / 确认 / 强度提示 / 提交按钮
assert 'id="profile-password-form"' in admin_html
assert 'id="profile-old-password"' in admin_html
assert 'id="profile-new-password"' in admin_html
assert 'id="profile-confirm-password"' in admin_html
assert 'id="profile-strength-hint"' in admin_html
assert 'id="btn-profile-password-submit"' in admin_html
# 强度提示复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强)
assert "密码强度:" in admin_html
for level in ("", "", ""):
assert level in admin_html
# 两次不一致本地拦截
assert "两次输入的新密码不一致" in admin_html
# 提交按钮 loading 态
assert "提交中…" in admin_html
# 退出登录按钮(复用现有 logout 逻辑)
assert 'id="btn-profile-logout"' in admin_html
assert "退出登录" in admin_html
assert "doLogout" in admin_html
# 数据来源:GET /auth/me 加载账号信息;POST /auth/password 改密
assert "/api/v1/auth/me" in admin_html
assert "/api/v1/auth/password" in admin_html
# 个人中心加载与渲染函数
assert "loadProfile" in admin_html
assert "renderProfile" in admin_html
# user 角色默认进个人中心(admin 仍默认进概览)
assert 'activateSection("section-profile")' in admin_html
assert 'activateSection("section-overview")' in admin_html
# activateSection 触发 loadProfile
assert 'targetId === "section-profile"' in admin_html
def test_admin_page_users_section(admin_html: str) -> None:
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码/删除 confirm/角色门禁"""
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗/角色门禁"""
assert 'id="section-users"' in admin_html
assert 'id="users-tbody"' in admin_html
# 表头列:用户名/角色/须改密/创建时间/操作
@@ -208,10 +253,10 @@ def test_admin_page_users_section(admin_html: str) -> None:
assert '<option value="admin"' in admin_html
assert '<option value="user"' in admin_html
assert "创建用户" in admin_html
# 操作列:重置密码(弹输入)与删除(confirm 二次确认
# 操作列:重置密码 + 删除均走通用弹窗组件(无 prompt/confirm 调用
assert "重置密码" in admin_html
assert "prompt(" in admin_html
assert "确定删除用户" in admin_html
assert "resetUserPassword" in admin_html
assert "deleteUser" in admin_html
# 用户管理端点
assert "/api/v1/auth/users" in admin_html
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
@@ -220,6 +265,115 @@ def test_admin_page_users_section(admin_html: str) -> None:
assert "unmountUsersSection" in admin_html
def test_admin_page_users_filter_and_inline_edit(admin_html: str) -> None:
"""用户管理:列表搜索筛选 + 行内角色编辑 + 启用开关 + 当前 admin 自身行防降级"""
# 列表搜索筛选:用户名搜索框 + 角色筛选下拉
assert 'id="user-search"' in admin_html
assert 'id="user-role-filter"' in admin_html
assert '<option value="all"' in admin_html
# 输入/变更事件触发本地过滤(不重新请求后端)
assert 'addEventListener("input", applyUsersFilter)' in admin_html
assert 'addEventListener("change", applyUsersFilter)' in admin_html
assert "applyUsersFilter" in admin_html
# 表头加「启用」列
assert "启用" in admin_html
# 行内角色编辑:select + 保存按钮(仅当值改变时启用)
assert "user-role-select" in admin_html
assert "user-role-save" in admin_html
assert "updateUserRole" in admin_html
# 行内启用/禁用开关:按钮文字 + 样式区分
assert "user-enabled-toggle" in admin_html
assert "toggleUserEnabled" in admin_html
# PATCH /auth/users/{username} 端点调用(role 与 enabled 两条分支)
assert 'method: "PATCH"' in admin_html
assert "encodeURIComponent(username)" in admin_html
# 当前登录 admin 自身行防自锁降级:role select disabled + 启用开关 disabled
assert "isSelf" in admin_html
assert "不能禁用当前登录账号" in admin_html
def test_admin_page_modal_component(admin_html: str) -> None:
"""通用 overlay 弹窗组件:modal-overlay/modal-card 结构 + openModal/closeModal API"""
# CSS 类
assert "modal-overlay" in admin_html
assert "modal-card" in admin_html
assert "modal-title" in admin_html
assert "modal-content" in admin_html
assert "modal-actions" in admin_html
# 挂载点容器
assert 'id="modal-root"' in admin_html
# 通用 JS API
assert "function openModal(" in admin_html
assert "function closeModal(" in admin_html
assert "function isModalSubmitting(" in admin_html
assert "function setModalSubmitting(" in admin_html
assert "function showModelError(" in admin_html
assert "function hideModelError(" in admin_html
# 提交中标记属性
assert "data-submitting" in admin_html
def test_admin_page_modal_cancel_and_loading(admin_html: str) -> None:
"""弹窗可取消(点遮罩/取消按钮/ESC)+ 提交中不可取消 + loading 态(提交中禁用)"""
# 点遮罩关闭:e.target === overlay
assert "e.target === overlay" in admin_html
# 取消按钮存在
assert "取消" in admin_html
# ESC 键关闭最顶层弹窗
assert "Escape" in admin_html
# 提交中不可关闭:遮罩点击与 ESC 均检查 data-submitting !== "true"
assert 'data-submitting") !== "true"' in admin_html
assert 'data-submitting") === "true"' in admin_html
# 提交 loading 文案 + 按钮禁用
assert "提交中…" in admin_html
assert 'submitBtn.disabled = true' in admin_html
# isModalSubmitting 守卫:取消按钮在提交中不响应
assert "isModalSubmitting(" in admin_html
def test_admin_page_reset_password_modal(admin_html: str) -> None:
"""重置密码弹窗:新密码/确认/强度提示/两次不一致本地拦截/loading 态"""
# 弹窗标题含用户名占位
assert "重置用户" in admin_html
assert "密码" in admin_html
# 新密码 + 确认密码输入框(动态创建,id 经 JS 属性赋值)
assert 'newInput.id = "modal-reset-new"' in admin_html
assert 'confirmInput.id = "modal-reset-confirm"' in admin_html
# 密码强度提示节点
assert 'hint.id = "modal-reset-strength"' in admin_html
assert "modal-strength-hint" in admin_html
# 强度三档纯文案:<8 弱 / ≥8 中 / ≥12 强
assert "密码强度:" in admin_html
for level in ("", "", ""):
assert level in admin_html
# 两次不一致本地拦截
assert "两次输入的新密码不一致" in admin_html
# 重置密码端点
assert "/api/v1/auth/users/" in admin_html
assert "/password" in admin_html
# 提交按钮 loading 态
assert "setModalSubmitting" in admin_html
def test_admin_page_delete_user_modal(admin_html: str) -> None:
"""删除确认弹窗:警告文案 + 输入用户名匹配才可提交 + loading 态"""
# 弹窗标题
assert "删除用户" in admin_html
# 警告文案
assert "此操作不可恢复,将清除该用户及其全部会话" in admin_html
# 「请输入用户名 {username} 以确认」+ 文本输入框(动态创建,id 经 JS 属性赋值)
assert "请输入用户名" in admin_html
assert "以确认" in admin_html
assert 'input.id = "modal-delete-input"' in admin_html
# 输入 !== username 时删除按钮禁用;匹配后启用
assert "submitBtn.disabled = true" in admin_html
assert "input.value !== username" in admin_html
# 删除端点
assert 'method: "DELETE"' in admin_html
# 提交按钮 loading 态
assert "setModalSubmitting" in admin_html
def test_admin_page_auth_interceptor(admin_html: str) -> None:
"""请求拦截:统一注入 Authorization Bearer1005 回登录;1006 错误条提示"""
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
+168 -2
View File
@@ -245,8 +245,9 @@ class TestUsersCrud:
users = body["data"]
assert len(users) == 1
admin = users[0]
assert set(admin.keys()) == {"username", "role", "must_change_password", "created_at"}
assert set(admin.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
assert admin["username"] == "admin"
assert admin["enabled"] is True
assert "password_hash" not in admin
assert "salt" not in admin
@@ -271,10 +272,11 @@ class TestUsersCrud:
assert body["code"] == 0
data = body["data"]
assert set(data.keys()) == {"username", "role", "must_change_password", "created_at"}
assert set(data.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
assert data["username"] == "carol"
assert data["role"] == "user"
assert data["must_change_password"] is False
assert data["enabled"] is True
# 新用户可登录
assert _login(client, "carol", "carol-pass-123")["code"] == 0
@@ -386,6 +388,170 @@ class TestUsersCrud:
assert body["code"] == 1001
class TestUpdateUser:
"""PATCH /api/v1/auth/users/{username}:更新角色/启用状态"""
def test_update_role_success(self, client: TestClient, admin_headers: dict[str, str]) -> None:
client.post(
"/api/v1/auth/users",
json={"username": "carol", "password": "carol-pass-123"},
headers=admin_headers,
)
body = client.patch(
"/api/v1/auth/users/carol", json={"role": "admin"}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["role"] == "admin"
assert body["data"]["enabled"] is True
def test_update_enabled_success(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "dave", "dave-pass-123", role="user")
body = client.patch(
"/api/v1/auth/users/dave", json={"enabled": False}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["enabled"] is False
assert body["data"]["role"] == "user"
def test_update_both_role_and_enabled(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "erin", "erin-pass-123", role="user")
body = client.patch(
"/api/v1/auth/users/erin",
json={"role": "admin", "enabled": False},
headers=admin_headers,
).json()
assert body["code"] == 0
assert body["data"]["role"] == "admin"
assert body["data"]["enabled"] is False
def test_at_least_one_field_required(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/admin", json={}, headers=admin_headers
).json()
assert body["code"] == 1001
assert body["data"] is None
def test_user_not_found(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/ghost", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 1004
assert body["data"] is None
def test_invalid_role(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/admin", json={"role": "superuser"}, headers=admin_headers
).json()
assert body["code"] == 1001
def test_last_admin_demote_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
# 唯一 admin 降级为 user → 1001
body = client.patch(
"/api/v1/auth/users/admin", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 1001
# 未生效
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
def test_last_admin_disable_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
# 唯一 admin 禁用 → 1001
body = client.patch(
"/api/v1/auth/users/admin", json={"enabled": False}, headers=admin_headers
).json()
assert body["code"] == 1001
def test_non_admin_forbidden(self, client: TestClient, auth_stores) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
headers = _headers(session_store, "bob", "user")
body = client.patch(
"/api/v1/auth/users/bob", json={"role": "admin"}, headers=headers
).json()
assert body["code"] == 1006
def test_demote_when_multiple_admins_ok(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
# 存在第二个 admin 时,可降级其中一个
user_store, _ = auth_stores
_create_user(user_store, "admin2", "admin2-pass-123", role="admin")
body = client.patch(
"/api/v1/auth/users/admin2", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["role"] == "user"
class TestDisabledUser:
"""禁用用户:登录拦截 + 旧 token 失效 + 业务端点拦截"""
def test_disabled_user_login_blocked(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# 禁用 bob
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 登录 → 1005 账号已禁用
body = _login(client, "bob", "bob-pass-123")
assert body["code"] == 1005
assert body["data"] is None
def test_disabled_user_old_token_invalidated(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# bob 登录拿到 token
login_body = _login(client, "bob", "bob-pass-123")
assert login_body["code"] == 0
bob_headers = {"Authorization": f"Bearer {login_body['data']['token']}"}
# 禁用 bob → session 清除
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 旧 token 调业务端点 → 1005session 已清)
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert body["code"] == 1005
def test_disabled_user_blocked_even_with_valid_session(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
"""禁用用户即使持有效 session(直接签发绕过登录),业务端点仍拦截 1005"""
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# 禁用 bob → session 清除
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 直接为 bob 签发新 session 绕过登录与清理,验证 get_current_user 的 enabled 拦截
bob_headers = _headers(session_store, "bob", "user")
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert body["code"] == 1005
class TestDocumentAuth:
"""文档端点鉴权:变更类需登录,GET 系列免登录"""
+247
View File
@@ -0,0 +1,247 @@
"""Spec Task 5:用户管理增强(PATCH 端点 + enabled 字段)全链路集成验证
内存 UserStore/SessionStore 经 conftest.auth_stores 夹具注入 app.api.deps 单例,
通过 TestClient 走真实 HTTP 链路(不跑 lifespan,无需真实 Redis/Qdrant),覆盖:
- 改角色生效:admin 创建 user → PATCH 改 admin → GET /auth/me 与重新登录均反映新角色
- 禁用用户:旧 token 立即失效(session 已清,1005);重新登录 1005("账号已禁用"
- 重新启用:PATCH enabled=True → login 恢复成功
- 最后 admin 保护:唯一 admin 降级/禁用自己 → 1001(且自身状态未变)
- PATCH 校验:空 body / 非法 role → 1001;不存在用户 → 1004;非 admin 调用 → 1006
"""
from typing import Any
from fastapi.testclient import TestClient
from app.main import app
def _login(client: TestClient, username: str, password: str) -> dict[str, Any]:
"""调登录接口并返回响应体"""
return client.post("/api/v1/auth/login", json={"username": username, "password": password}).json()
def _bearer(token: str) -> dict[str, str]:
"""构造 Authorization Bearer 请求头"""
return {"Authorization": f"Bearer {token}"}
def _admin_login(client: TestClient) -> dict[str, str]:
"""以 auth_stores 预置的 adminadmin/admin-pass-123)登录,返回 Bearer 请求头"""
resp = _login(client, "admin", "admin-pass-123")
assert resp["code"] == 0, f"admin 登录失败: {resp}"
return _bearer(resp["data"]["token"])
def _create_user(
client: TestClient,
admin_headers: dict[str, str],
username: str,
password: str,
role: str = "user",
) -> None:
"""admin 创建用户并断言成功"""
resp = client.post(
"/api/v1/auth/users",
json={"username": username, "password": password, "role": role},
headers=admin_headers,
).json()
assert resp["code"] == 0, f"创建用户 {username} 失败: {resp}"
class TestRoleChange:
"""改角色生效全链路"""
def test_role_change_takes_effect(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# admin 创建 user 角色账号 alice
_create_user(client, admin_headers, "alice", "alice-pass-123", role="user")
# alice 登录,初始角色 user
alice_login = _login(client, "alice", "alice-pass-123")
assert alice_login["code"] == 0
assert alice_login["data"]["role"] == "user"
alice_headers = _bearer(alice_login["data"]["token"])
# 改角色前 GET /auth/me 反映 user 角色
me_before = client.get("/api/v1/auth/me", headers=alice_headers).json()
assert me_before["code"] == 0
assert me_before["data"]["role"] == "user"
# admin PATCH 改 alice 角色为 admin
patched = client.patch(
"/api/v1/auth/users/alice",
json={"role": "admin"},
headers=admin_headers,
).json()
assert patched["code"] == 0
assert patched["data"]["role"] == "admin"
# 同一 token 立即反映新角色(角色变更不清 session,用户记录实时读取)
me_after = client.get("/api/v1/auth/me", headers=alice_headers).json()
assert me_after["code"] == 0
assert me_after["data"]["role"] == "admin"
# 重新登录也反映新角色
relogin = _login(client, "alice", "alice-pass-123")
assert relogin["code"] == 0
assert relogin["data"]["role"] == "admin"
class TestDisableUser:
"""禁用用户:旧 session 清除 + 登录拒绝"""
def test_disable_user_clears_session_and_blocks_login(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "bob", "bob-pass-123")
# bob 登录拿 token
bob_login = _login(client, "bob", "bob-pass-123")
assert bob_login["code"] == 0
bob_headers = _bearer(bob_login["data"]["token"])
# admin 禁用 bob(清空其全部 session
disabled = client.patch(
"/api/v1/auth/users/bob",
json={"enabled": False},
headers=admin_headers,
).json()
assert disabled["code"] == 0
assert disabled["data"]["enabled"] is False
# bob 旧 token 调鉴权端点 → 1005(session 已清,凭证无效)
me = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert me["code"] == 1005
# bob 重新登录 → 1005(账号已禁用)
relogin = _login(client, "bob", "bob-pass-123")
assert relogin["code"] == 1005
assert "禁用" in relogin["message"]
class TestReenableUser:
"""重新启用:login 恢复成功"""
def test_reenable_user_allows_login(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "carol", "carol-pass-123")
# 先禁用 carol,确认登录被拒
disabled = client.patch(
"/api/v1/auth/users/carol",
json={"enabled": False},
headers=admin_headers,
).json()
assert disabled["code"] == 0
assert _login(client, "carol", "carol-pass-123")["code"] == 1005
# 重新启用
enabled = client.patch(
"/api/v1/auth/users/carol",
json={"enabled": True},
headers=admin_headers,
).json()
assert enabled["code"] == 0
assert enabled["data"]["enabled"] is True
# login 恢复成功
relogin = _login(client, "carol", "carol-pass-123")
assert relogin["code"] == 0
assert relogin["data"]["username"] == "carol"
class TestLastAdminProtection:
"""最后 admin 保护:唯一 admin 不可降级/禁用"""
def test_cannot_demote_last_admin(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 唯一 adminadmin)尝试降级自己 role=user → 1001
resp = client.patch(
"/api/v1/auth/users/admin",
json={"role": "user"},
headers=admin_headers,
).json()
assert resp["code"] == 1001
# admin 未被降级,仍可访问 admin 专属端点
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
def test_cannot_disable_last_admin(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 唯一 admin 尝试禁用自己 enabled=False → 1001
resp = client.patch(
"/api/v1/auth/users/admin",
json={"enabled": False},
headers=admin_headers,
).json()
assert resp["code"] == 1001
# admin 未被禁用,旧 token 仍可用
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
class TestPatchValidation:
"""PATCH 端点校验:空 body / 非法 role / 不存在用户 / 非 admin"""
def test_empty_body_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "dave", "dave-pass-123")
# 空 bodyrole 与 enabled 均缺)→ 模型校验 1001
resp = client.patch(
"/api/v1/auth/users/dave",
json={},
headers=admin_headers,
).json()
assert resp["code"] == 1001
def test_invalid_role_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "dave", "dave-pass-123")
# role 非法(非 admin/user)→ 模型校验 1001
resp = client.patch(
"/api/v1/auth/users/dave",
json={"role": "superuser"},
headers=admin_headers,
).json()
assert resp["code"] == 1001
def test_nonexistent_user_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 不存在用户 → 1004
resp = client.patch(
"/api/v1/auth/users/ghost",
json={"role": "admin"},
headers=admin_headers,
).json()
assert resp["code"] == 1004
def test_non_admin_forbidden(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "eve", "eve-pass-123", role="user")
# eveuser 角色)登录后调 PATCH → 1006
eve_login = _login(client, "eve", "eve-pass-123")
assert eve_login["code"] == 0
eve_headers = _bearer(eve_login["data"]["token"])
resp = client.patch(
"/api/v1/auth/users/eve",
json={"role": "admin"},
headers=eve_headers,
).json()
assert resp["code"] == 1006
+96
View File
@@ -17,6 +17,7 @@ import pytest
from app.core.users import (
UserExistsError,
UserNotFoundError,
UserStore,
UserStoreError,
bootstrap_admin,
@@ -225,6 +226,101 @@ class TestCountAdmins:
assert await store.count_admins() == 1
class TestEnabledField:
"""enabled 字段:默认 True、create 传参、get/list 回读、存量兼容"""
async def test_default_enabled_is_true(self):
record = await UserStore(FakeRedis()).create("alice", "password123")
assert record.enabled is True
async def test_create_with_enabled_false(self):
redis = FakeRedis()
store = UserStore(redis)
record = await store.create("alice", "password123", enabled=False)
assert record.enabled is False
# 持久化后回读仍为 False
assert (await store.get("alice")).enabled is False
# 落库 JSON 含 enabled 字段
assert json.loads(redis.store["user:alice"])["enabled"] is False
async def test_get_list_roundtrip_enabled(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", enabled=False)
await store.create("bob", "password123", role="admin")
assert (await store.get("alice")).enabled is False
assert (await store.get("bob")).enabled is True
records = {r.username: r for r in await store.list()}
assert records["alice"].enabled is False
assert records["bob"].enabled is True
async def test_legacy_record_without_enabled_defaults_true(self):
"""存量记录无 enabled 字段时按 True 兼容(get/list"""
redis = FakeRedis()
store = UserStore(redis)
# 直接写入无 enabled 字段的存量记录
redis.store["user:legacy"] = json.dumps(
{
"username": "legacy",
"role": "user",
"password_hash": "hash",
"salt": "00" * 16,
"must_change_password": False,
"created_at": "2024-01-01T00:00:00+00:00",
}
)
record = await store.get("legacy")
assert record is not None
assert record.enabled is True
records = await store.list()
assert records[0].enabled is True
class TestUpdateUser:
"""update_user:角色/启用状态更新与校验"""
async def test_update_role(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
updated = await store.update_user("alice", role="admin")
assert updated.role == "admin"
assert updated.enabled is True # 未改动
# 持久化
record = await store.get("alice")
assert record is not None
assert record.role == "admin"
async def test_update_enabled(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", role="admin")
updated = await store.update_user("alice", enabled=False)
assert updated.enabled is False
assert updated.role == "admin" # 未改动
async def test_update_both(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
updated = await store.update_user("alice", role="admin", enabled=False)
assert updated.role == "admin"
assert updated.enabled is False
async def test_user_not_found_raises(self):
with pytest.raises(UserNotFoundError):
await UserStore(FakeRedis()).update_user("nobody", role="admin")
async def test_invalid_role_raises_value_error(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
with pytest.raises(ValueError):
await store.update_user("alice", role="superuser")
async def test_no_fields_noop(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", role="admin")
updated = await store.update_user("alice")
assert updated.role == "admin"
assert updated.enabled is True
class TestBootstrapAdmin:
"""空库引导创建默认管理员"""
Generated
-389
View File
@@ -159,14 +159,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" },
]
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
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 = "grpcio"
version = "1.83.0"
@@ -403,86 +395,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "numpy"
version = "2.5.1"
@@ -534,38 +446,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
]
[[package]]
name = "onnxruntime"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" },
{ url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" },
{ url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" },
{ url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" },
{ url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" },
{ url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" },
{ url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" },
{ url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" },
{ url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" },
{ url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" },
{ url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" },
{ url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" },
]
[[package]]
name = "openai"
version = "2.49.0"
@@ -585,25 +465,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" },
]
[[package]]
name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -613,77 +474,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "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 = "pluggy"
version = "1.6.0"
@@ -720,36 +510,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
]
[[package]]
name = "pyclipper"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" },
{ url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" },
{ url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" },
{ url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" },
{ url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" },
{ url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" },
{ url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" },
{ url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" },
{ url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" },
{ url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -872,44 +632,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[[package]]
name = "pypdf"
version = "6.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" },
]
[[package]]
name = "pypdfium2"
version = "5.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/42/0b51bdf50ccf13f3deb3209ca996179a49761dc191748469cf0de55b0055/pypdfium2-5.12.1.tar.gz", hash = "sha256:d0e0648fb2e28f50efcd1ec0a5a18ced9f4d66b2c227fae9b603f0a883b2d13f", size = 274428, upload-time = "2026-07-17T10:01:22.713Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/7e/bd8df53b1131c582f6646372047b49162032bd01628d49b9a60cd94d2181/pypdfium2-5.12.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4", size = 3392276, upload-time = "2026-07-17T10:00:47.376Z" },
{ url = "https://files.pythonhosted.org/packages/b2/13/a2b71e17b0439d2af78c817a381e3557371c6c56098581da8485aef65ea6/pypdfium2-5.12.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d4ee061e566a6422b660cdddaaa799a2d1cbf2f016921bcaf24d61426d01d942", size = 2848776, upload-time = "2026-07-17T10:00:49.09Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a8/d7a61700db3022792b28bce5264be90a7d2e7104998362af6b93766f50b2/pypdfium2-5.12.1-py3-none-macosx_13_0_arm64.whl", hash = "sha256:66a9ed40d70a5d728cd42148fecb9d7a0917c6161d6bb67c844093a4ed1df089", size = 3480243, upload-time = "2026-07-17T10:00:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/01/2c/d7a38fad74b6da0947cf8763aee0f8e6c9d3c12fc8e137aa615f7f8ae76c/pypdfium2-5.12.1-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:847378a5ab41332998b2621b21bab2e96dc8c3eff36a08bce26695b964163983", size = 3643490, upload-time = "2026-07-17T10:00:52.236Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/aca739676323558595fcf8cdc8d7939d2b25aaaa6f538e829f1fee938cdd/pypdfium2-5.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eabf028ad8e7bc7811c9acf3a72718c180569b624b844d2c6cc974609784275", size = 3649734, upload-time = "2026-07-17T10:00:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/a4/e0/e4ecc05f4f1a11d11c8d684a24b2fc8be8207f341be985dac301caa4f6aa/pypdfium2-5.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7857cfa6642ec5a09db12ff8f5cf6b6494585b5e3a605399fddc4fb862837b63", size = 3380828, upload-time = "2026-07-17T10:00:55.377Z" },
{ url = "https://files.pythonhosted.org/packages/17/1b/c94c9d486791276e736350917a11fe2cf3acba2c6c7f03f9ac0d51f8952a/pypdfium2-5.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf", size = 3777202, upload-time = "2026-07-17T10:00:57.212Z" },
{ url = "https://files.pythonhosted.org/packages/14/aa/7f81f0c035fc32850dfab9daf78530814f215be226dad7491e3caa0a3e8c/pypdfium2-5.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f059f7bdbdf4352eb83691071096940d769d6ae5930b8734237fdb1bd78fbc2", size = 4186083, upload-time = "2026-07-17T10:00:59.022Z" },
{ url = "https://files.pythonhosted.org/packages/23/16/21420a6f2bc5f981299c336817dd5d72709dad5fda30ac38cbd5f0f7b372/pypdfium2-5.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10cbf41b21233ec5e20adfc170cf60edd77abead86a97dc708fff55a8a886c7", size = 3701734, upload-time = "2026-07-17T10:01:00.952Z" },
{ url = "https://files.pythonhosted.org/packages/36/a1/bb89f49e2b3ea3e945b67859d2ec6e73e722a83c006099c675692641e51d/pypdfium2-5.12.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07eeebb2784f4cd38d386b924235df43217a397442796673296bb6efbdaad1d0", size = 4030403, upload-time = "2026-07-17T10:01:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/80/a7/cea5eb0c39e9c6fdf9853a3008bf021d08f962228073af90354b60c5ccdc/pypdfium2-5.12.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c3e6cbe43581af79526184643920ab03a9401a0c79f2226bea9d4d1e3d34008", size = 3994411, upload-time = "2026-07-17T10:01:04.25Z" },
{ url = "https://files.pythonhosted.org/packages/fc/43/5e470213b27c13b0d94d03d97fc3740507edadea1d1e2ce2049bfbec4aa0/pypdfium2-5.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4648f0905441bcb141687ca2263bbf38a1aa056b943eef06019f91cff3e1da4a", size = 4993687, upload-time = "2026-07-17T10:01:05.811Z" },
{ url = "https://files.pythonhosted.org/packages/db/da/af7972ca72f24ed720db6501333fd67bc66aa6aa5a5ec698551bd30ae62e/pypdfium2-5.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bdff622181fab64f32328591c9c8287cdc745c9a1f2afc26ca3feba39e3e6645", size = 4534560, upload-time = "2026-07-17T10:01:07.291Z" },
{ url = "https://files.pythonhosted.org/packages/a9/63/06a7f2cd691f7e336cbc53fd65453fee516042c8ddb016bc50d1cd2bed45/pypdfium2-5.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:236dbdc88aa54f14b27937ccb2ebe3dcf08c10dbb8652f432ea982dc9af39732", size = 5237681, upload-time = "2026-07-17T10:01:08.997Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f5/937b080671758ab0b3d3d69b2657006682e4c6a0134b36774be4ed1afcfb/pypdfium2-5.12.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:9c8856ce7dd77a7827476c7d75afe1197d6cd505f5cb4167b6aacf661f3f8ea5", size = 5143027, upload-time = "2026-07-17T10:01:10.69Z" },
{ url = "https://files.pythonhosted.org/packages/32/02/094632700c24728fa443dc89d9d1c6e4fc05bb00778b22e8482fdb133da0/pypdfium2-5.12.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:5f257bb40fa44ce9ba18d2c919777dbd3f16bf22548b1d68fd56c7c92f1de530", size = 4647048, upload-time = "2026-07-17T10:01:12.559Z" },
{ url = "https://files.pythonhosted.org/packages/fe/d0/12d84bf55a4fcf2c0ed242afc94168933194f672067e1d162aa60a8e4426/pypdfium2-5.12.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:974082344172da76a5c3c0782eaedfe6069dbe88db77d8c671ef36b61e9b14e2", size = 5088747, upload-time = "2026-07-17T10:01:14.42Z" },
{ url = "https://files.pythonhosted.org/packages/92/9c/92a460bac1f6cfd6f96251802b3098a804fb251dfe0b5eb004ede958ae0e/pypdfium2-5.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715ae16b34ea1d64884d58800155179ba700e9ea65a2f583b020666acd2bfb12", size = 5049695, upload-time = "2026-07-17T10:01:15.961Z" },
{ url = "https://files.pythonhosted.org/packages/f7/67/53c61d366222550220b42a9212131407f49d3dbcf050178c620bf80fa899/pypdfium2-5.12.1-py3-none-win32.whl", hash = "sha256:e5358d2ce4ebc5c899aab1df9ca5d215357244e9168aa443225d3c1e649c7eac", size = 3725466, upload-time = "2026-07-17T10:01:17.773Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c3/08b62718faf2f6b6aa49207626e3113ff3ec1b3cd076c0ef8fd852f0e57c/pypdfium2-5.12.1-py3-none-win_amd64.whl", hash = "sha256:9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca", size = 3859845, upload-time = "2026-07-17T10:01:19.417Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d5/ad551e55790134c8bc87ea16e0866a3721def0620fbfa19112c8ad4a25a6/pypdfium2-5.12.1-py3-none-win_arm64.whl", hash = "sha256:afc0b7e0c975a429abc75875209ce17b66d749f6ac5cbe8ba72470e83901e304", size = 3674605, upload-time = "2026-07-17T10:01:21.008Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@@ -939,19 +661,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -961,15 +670,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pywin32"
version = "312"
@@ -1065,12 +765,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pypdf" },
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "qdrant-client" },
{ name = "rapidocr-onnxruntime" },
{ name = "redis" },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] },
@@ -1092,14 +787,9 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.7.0" },
{ name = "pyjwt", specifier = ">=2.13.0" },
{ name = "pypdf", specifier = ">=5.1.0" },
{ name = "pypdfium2", specifier = ">=4.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-docx", specifier = ">=1.1.2" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "qdrant-client", specifier = ">=1.12.0" },
{ name = "rapidocr-onnxruntime", specifier = ">=1.3.8" },
{ name = "redis", specifier = ">=5.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "structlog", specifier = ">=24.4.0" },
@@ -1107,25 +797,6 @@ requires-dist = [
]
provides-extras = ["dev"]
[[package]]
name = "rapidocr-onnxruntime"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "pyclipper" },
{ name = "pyyaml" },
{ name = "shapely" },
{ name = "six" },
{ name = "tqdm" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", hash = "sha256:971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", size = 14915192, upload-time = "2025-01-17T01:48:25.104Z" },
]
[[package]]
name = "redis"
version = "8.0.1"
@@ -1160,66 +831,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]]
name = "shapely"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
{ url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
{ url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
{ url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
{ url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
{ url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
{ url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
{ url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
{ url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"