commit 3cd280d06869bfd9f68c8caac4f72eeddafa491e Author: yuxuanhui Date: Mon Sep 7 14:54:20 2026 +0800 feat: add initial frontend setup with styles, types, and testing framework - Created a global CSS file for styling the frontend with responsive design. - Introduced TypeScript types for various entities including Research, Alpha, and Account. - Implemented Playwright tests for account management and data synchronization workflows. - Configured TypeScript with strict settings and included necessary libraries. - Set up Vite as the build tool with React plugin and API proxy configuration. - Added a Python script to initialize environment variables securely. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9fe7967 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.env +.env.* +.local +**/.venv +**/node_modules +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +frontend/dist +frontend/test-results +frontend/playwright-report +*.dump +output +.playwright-cli +backend/tests +frontend/tests +**/*.tsbuildinfo +backups diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8956b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Generate real values with: python3 scripts/init_env.py +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-a-strong-password-at-least-12-characters +# Keep URL-safe characters (hex recommended) for the PostgreSQL connection URL. +POSTGRES_PASSWORD=replace-with-random-hex-password +# Fernet key: URL-safe base64 encoding of 32 random bytes; independent of passwords. +ENCRYPTION_KEY=replace-with-generated-fernet-key +LOCAL_PORT=8080 +# Public deployment only; bare DNS hostname, without scheme or path. +DOMAIN=alpha.example.com +# WorldQuant email/password are configured after system login, not in this file. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3091979 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.env +.env.* +!.env.example +**/.venv/ +**/__pycache__/ +**/.pytest_cache/ +**/.ruff_cache/ +**/*.pyc +frontend/node_modules/ +frontend/dist/ +frontend/test-results/ +frontend/playwright-report/ +*.db +*.dump +.DS_Store +.local/ +output/ +**/*.tsbuildinfo +.playwright-cli/ +backups/ + +account.json \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..685e3b1 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,18 @@ +{$SITE_ADDRESS:http://:80} { + encode zstd gzip + header { + X-Content-Type-Options nosniff + Referrer-Policy no-referrer + X-Frame-Options DENY + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + -Server + } + handle /api/* { + reverse_proxy backend:8000 + } + handle { + root * /srv + try_files {path} /index.html + file_server + } +} diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..745ec8d --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,15 @@ +FROM ghcr.io/astral-sh/uv:0.7.20 AS uv +FROM python:3.12-slim-bookworm +COPY --from=uv /uv /usr/local/bin/uv +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_COMPILE_BYTECODE=1 PATH="/app/.venv/bin:$PATH" +WORKDIR /app +COPY backend/pyproject.toml backend/uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project +COPY backend/app ./app +COPY backend/alembic.ini ./ +COPY backend/migrations ./migrations +RUN useradd --create-home --uid 10001 wq && chown -R wq:wq /app +USER wq +EXPOSE 8000 +# The durable runner has one owner. Multiple workers/replicas are not supported. +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8000 --workers 1 --proxy-headers"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..64a7312 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,12 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +FROM caddy:2-alpine +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv +EXPOSE 80 443 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e002838 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# WorldQuant Alpha 研究工作空间 + +个人单账户系统。首期实现平台资料、Alpha 同步与查询、PnL 缓存、本地备注/标签/收藏/研究状态。平台接口只读,认证除外;不会回测、触发检查、回写属性或提交 Alpha。 + +需求与后续路线图见 [项目方案](docs/project-plan.md)。前端 React 19 + TypeScript + Semi Design,后端 Python 3.12 + FastAPI + HTTPX + SQLAlchemy,PostgreSQL 保存数据,Caddy 提供 Web 入口。前后端独立依赖、独立构建,所有部署文件位于根目录。 + +## 本机启动 + +需要 Docker Engine/Desktop 和 Docker Compose v2;初始化脚本需要 Python 3。项目根目录执行: + +```bash +python3 scripts/init_env.py +docker compose up -d --build --wait +docker compose ps +``` + +打开 **http://localhost:8080**。用户名默认 `admin`,初始密码读取本机 `.env` 中的 `ADMIN_PASSWORD`。脚本以仅当前用户可读写的权限创建 `.env`,不会覆盖已有文件或打印密钥。已有 `.env` 时直接启动即可。 + +`web` 仅绑定 `127.0.0.1`;`backend` 和 `db` 没有宿主机端口。使用 `localhost` 访问以匹配请求来源校验。修改端口时更改 `.env` 中的 `LOCAL_PORT` 并重建容器。 + +首次使用: + +1. 登录系统,在“个人信息”保存 WorldQuant 邮箱和密码,点击“连接 WorldQuant”。 +2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。 +3. 在“Alpha 管理”手动同步平台数据,或导入指定 Alpha ID。任务面板显示进度、错误、取消和重试。 +4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。下次同步会更新平台数据并保留本地研究记录。 + +平台未返回的资料与指标保留为空。数值筛选采用平台原始单位,例如 Turnover `0.15` 表示 15%。日期筛选边界为 UTC;时间显示采用个人页的时区偏好。 + +## 公网 HTTPS 部署 + +`compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。 + +```bash +docker compose -f compose.public.yaml up -d --build --wait +docker compose -f compose.public.yaml logs --tail=100 web +``` + +访问 `https://你的域名`。Caddy 自动签发/续期证书并重定向 HTTP;后端固定启用 `Secure`、`HttpOnly`、`SameSite=Strict` 的服务端会话 Cookie,并校验写请求来源。无开放注册。数据库仅在 Compose 网络中可见。证书持久化到 `caddy_data` 卷。 + +不要使用示例域名申请证书。公网证书签发取决于实际 DNS、服务器网络与 ACME 服务;本地测试不能代替真实域名的 HTTPS 验收。生产环境请保持单个 backend 容器、单个 Uvicorn worker;首期任务执行不支持多副本。 + +## 配置与账户安全 + +| 配置 | 用途 | +| --- | --- | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 | +| `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 | +| `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码 | +| `LOCAL_PORT` | 本机入口端口,默认 8080 | +| `DOMAIN` | 公网域名 | + +WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码;切勿在正常升级时重新生成它。 + +修改系统密码(同时撤销所有系统会话): + +```bash +docker compose exec backend python -m app.cli reset-password +``` + +公网时为命令补上 `-f compose.public.yaml`。单账户一旦同步确认身份,不允许更换邮箱混入其他账户数据。断开平台连接会暂停未完成任务;重新连接并确认身份后恢复。 + +## 升级与数据库迁移 + +依赖锁文件分别是 `backend/uv.lock` 和 `frontend/pnpm-lock.yaml`。每次后端启动都会执行 `alembic upgrade head`,失败时不会启动 API。升级前先备份数据库与 `.env`,然后: + +```bash +docker compose up -d --build --wait +docker compose exec backend alembic current +docker compose logs --tail=100 backend +``` + +普通 `docker compose down` 不删除数据卷,重建后数据保留。**`down -v` 会删除数据库与证书卷,正常停机/升级不要使用。** + +新建迁移的开发命令为 `uv run alembic revision --autogenerate -m "说明"`;须人工审阅生成文件,再运行 `uv run alembic upgrade head` 和测试。已发布的迁移文件保持不变。 + +## 备份与恢复 + +以下为本机配置命令;公网统一补上 `-f compose.public.yaml`,自定义项目名时保持相同 `-p`。数据库备份包括平台快照、研究记录、账户密文和任务。备份文件仍属于私有数据。 + +```bash +mkdir -p backups +umask 077 +docker compose exec -T db pg_dump -U wq -d wq -Fc --no-owner > backups/wq.dump +``` + +先恢复到独立数据库校验,不覆盖正在使用的数据: + +```bash +docker compose exec -T db createdb -U wq wq_restore_check +docker compose exec -T db pg_restore -U wq -d wq_restore_check --no-owner --exit-on-error < backups/wq.dump +docker compose exec -T db psql -U wq -d wq_restore_check -c 'SELECT count(*) FROM alphas; SELECT count(*) FROM research;' +``` + +确认备份正确后,若需要恢复正式库,下面操作会覆盖现有库内容,应先另做当前备份并停止写入: + +```bash +docker compose stop web backend +docker compose exec -T db pg_restore -U wq -d wq --clean --if-exists --no-owner --exit-on-error < backups/wq.dump +docker compose up -d --wait +``` + +跨机器恢复时同时使用原来的 `ENCRYPTION_KEY`。恢复后运行中的任务自动回到队列,按已提交检查点继续;平台会话可能要求重新连接或人工验证。 + +## 本地开发与验证 + +后端使用 `uv` 和 Python 3.12,前端使用 Node 22.12+ 与 pnpm 9.15.0。 + +```bash +cd backend +uv sync --python 3.12 +uv run ruff check app tests +uv run pytest -q +``` + +自动化测试使用隔离的 SQLite 与 HTTPX 模拟上游,不读取真实凭据或请求 WorldQuant。涵盖 API 鉴权、加密、查询/导出一致性、同步重试与恢复、人工验证、类型差异和 PnL schema。部署验收另用真实 PostgreSQL 和 Docker。 + +```bash +cd frontend +corepack enable +pnpm install --frozen-lockfile +pnpm build +pnpm exec playwright install chromium +pnpm test +``` + +浏览器测试自动启动临时数据库、模拟平台 API 和 Vite,使用 620 条明确标记 `TEST` 的合成 Alpha。不会向正式数据库写入样例。测试验证系统登录、账户连接、多页同步、SUPER 详情、备注与收藏在刷新后保留、PnL、超过 500 条 CSV 及退出。截图写入忽略目录 `output/playwright/`。 + +需要重跑 Docker 持久化和备份验收时,先停止占用 8080 的本机实例(不删除卷),创建独立测试环境。以下脚本只接受 `wq-alpha-acceptance*` 项目名: + +```bash +mkdir -p .local +python3 scripts/init_env.py --output .local/docker-test.env +docker compose --env-file .local/docker-test.env -p wq-alpha-acceptance up -d --build --wait +python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env +# 仅删除上面新建的测试容器和测试卷 +docker compose --env-file .local/docker-test.env -p wq-alpha-acceptance down -v +``` + +独立启动模拟环境以开发 UI(两个终端): + +```bash +# 终端 1,backend/ 下 +uv run uvicorn tests.browser_server:create_test_app --factory --host 127.0.0.1 --port 18000 +# 终端 2,frontend/ 下 +WQ_DEV_API=http://127.0.0.1:18000 pnpm dev --port 5179 +``` + +访问 `http://127.0.0.1:5179`,系统测试密码 `browser-test-password`,平台邮箱 `test@example.com`、密码任意。每次停止服务即丢弃临时测试数据。 + +开发真实后端时显式配置 `DATABASE_URL` 指向自己的开发 PostgreSQL,设置 `ADMIN_PASSWORD`、`ENCRYPTION_KEY`、`PUBLIC_ORIGIN=http://localhost:5173`,执行迁移后用 `uv run uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000` 启动。`pnpm dev` 默认代理到此地址。生产 Compose 不开放开发数据库端口。 + +## 接口与数据约定 + +FastAPI 的 `/openapi.json` 与 `/docs` 可在后端开发端口访问;生产 Web 入口只代理 `/api/*`,不对外公开文档页面。 + +- `/api/v1/auth`:登录、退出、会话;除登录与健康检查外,业务接口都需要 Cookie。 +- `/api/v1/account`:偏好、加密凭据、连接/验证/断开/资料刷新。 +- `/api/v1/alphas`:服务端筛选与排序、详情、本地研究记录、批量编辑、流式 CSV。 +- `/api/v1/alphas/{id}/pnl`:只读缓存;刷新通过 `pnl_refresh` 任务。 +- `/api/v1/sync-jobs`:创建任务立即返回 202 和 ID,查询、取消与重试。 + +写请求需 `X-WQ-Request: 1`;浏览器跨站写入被拒绝。Alpha 平台快照、`research` 本地研究、`pnl_cache` 分开存储。研究状态固定为 `inbox/candidate/optimizing/archived`;平台类型、语言、状态按原值显示。 + +同步按“未提交/已提交 × 可见/隐藏”分页,每页数据与检查点同事务提交,Alpha ID 幂等更新。失败任务保留进度,重试只处理剩余页或失败 ID。上游 `Retry-After` 等待可被取消。分页过程中平台记录移动可能造成重复或遗漏,通过 ID 去重和再次全量同步校正;单次没有查到不自动删除本地记录。 + +## 日志排查与验证边界 + +```bash +docker compose ps +docker compose logs --tail=100 backend +docker compose logs --tail=100 web +docker compose logs --tail=100 db +curl -f http://localhost:8080/api/v1/health +``` + +平台任务失败时先查看任务面板的错误及失败 ID。401 登录失效需重新登录系统;平台人工验证需回个人页;429 会按平台等待时间自动重试。密钥损坏/丢失时重新配置平台凭据。不要为排错把密码、认证响应或 Cookie 加入日志。 + +实现使用旧项目已知请求形态并对模拟上游做自动化验证。WorldQuant 当前真实账号权限、人工验证页面行为、实际数据 schema、真实账户全量同步及公网证书签发,均需要在自己的账户/域名完成只读联调;未取得该证据前不宣称已验证。验收实测结果见 [验收记录](docs/verification.md)。 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..400d282 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = migrations +prepend_sys_path = . diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/alphas.py b/backend/app/alphas.py new file mode 100644 index 0000000..c036f48 --- /dev/null +++ b/backend/app/alphas.py @@ -0,0 +1,199 @@ +"""Normalize upstream business data, without making platform or research decisions.""" + +import math +import re +from datetime import datetime + +from sqlalchemy import or_, select + +from .models import Alpha, Research, ResearchTag, now + +SENSITIVE_KEYS = { + "password", + "token", + "cookies", + "cookie", + "authorization", + "credentials", + "secret", + "accesstoken", + "refreshtoken", + "sessiontoken", + "clientsecret", + "authorizationheader", + "setcookie", + "apikey", + "csrftoken", + "xsrftoken", + "authentication", +} + + +def sanitize(value): + if isinstance(value, dict): + return { + k: sanitize(v) for k, v in value.items() if re.sub(r"[^a-z]", "", k.lower()) not in SENSITIVE_KEYS + } + if isinstance(value, list): + return [sanitize(v) for v in value] + if isinstance(value, float) and not math.isfinite(value): + return None + return value + + +def number(value): + if value is None or isinstance(value, bool): + return None + try: + result = float(value) + return result if math.isfinite(result) else None + except (ValueError, TypeError): + return None + + +def date(value): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None + except (ValueError, TypeError, AttributeError): + return None + + +def code(value): + return value.get("code") if isinstance(value, dict) else value if isinstance(value, str) else None + + +async def upsert_alpha(db, raw: dict): + alpha_id = raw.get("id") + if not isinstance(alpha_id, str) or not alpha_id: + raise ValueError("Alpha 数据缺少 ID") + item = await db.get(Alpha, alpha_id) + if item is None: + item = Alpha(id=alpha_id) + db.add(item) + settings = raw.get("settings") or {} + metrics = raw.get("is") if isinstance(raw.get("is"), dict) else {} + item.name = raw.get("name") + item.expression = code(raw.get("regular")) + item.selection, item.combo = code(raw.get("selection")), code(raw.get("combo")) + item.alpha_type, item.language = raw.get("type"), settings.get("language") + item.stage, item.status, item.hidden = raw.get("stage"), raw.get("status"), raw.get("hidden") is True + item.region, item.universe = settings.get("region"), settings.get("universe") + item.settings, item.is_metrics = sanitize(settings), sanitize(metrics) + item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {} + item.checks = sanitize(metrics.get("checks") or raw.get("checks") or []) + for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): + setattr(item, key, number(metrics.get(key))) + item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted")) + item.synced_at, item.raw = now(), sanitize(raw) + await db.flush() + if await db.get(Research, alpha_id) is None: + db.add(Research(alpha_id=alpha_id)) + return item + + +def list_statement(filters): + query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id) + q = filters.q + if q: + pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + query = query.where( + or_( + *( + getattr(Alpha, f).ilike(pattern, escape="\\") + for f in ("id", "name", "expression", "selection", "combo") + ) + ) + ) + for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden"): + value = getattr(filters, name) + if value is not None: + query = query.where(getattr(Alpha, name) == value) + if filters.research_state: + query = query.where(Research.state == filters.research_state) + if filters.favorite is not None: + query = query.where(Research.favorite == filters.favorite) + if filters.tag: + query = query.where(Alpha.id.in_(select(ResearchTag.alpha_id).where(ResearchTag.tag == filters.tag))) + if filters.created_from: + query = query.where(Alpha.date_created >= filters.created_from) + if filters.created_to: + query = query.where(Alpha.date_created <= filters.created_to) + for name in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): + for suffix, compare in (("min", "ge"), ("max", "le")): + value = getattr(filters, f"{name}_{suffix}") + if value is not None: + column = getattr(Alpha, name) + query = query.where(column >= value if compare == "ge" else column <= value) + return query + + +def sorted_statement(query, sort, direction): + column = getattr(Alpha, sort) + order = column.desc() if direction == "desc" else column.asc() + return query.order_by(order.nullslast(), Alpha.id.asc()) + + +def summary(item: Alpha, research: Research): + keys = ( + "id", + "name", + "alpha_type", + "language", + "stage", + "status", + "hidden", + "region", + "universe", + "sharpe", + "fitness", + "returns", + "turnover", + "margin", + "drawdown", + "date_created", + "date_submitted", + "synced_at", + ) + result = {k: getattr(item, k) for k in keys} + result["expression_preview"] = (item.expression or item.selection or "")[:240] + result["research"] = { + k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at") + } + return result + + +def pnl_points(raw): + """Use schema column names, preserving missing values rather than creating zero PnL.""" + records = raw.get("records") + schema = raw.get("schema") or {} + properties = schema.get("properties", []) if isinstance(schema, dict) else schema + if isinstance(properties, dict): + names = list(properties) + else: + names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties] + normalized = [name.lower() for name in names] + if not isinstance(records, list): + raise ValueError("PnL 缺少 records") + points = [] + for row in records: + if isinstance(row, dict): + row = {str(k).lower(): v for k, v in row.items()} + timestamp = next((row[k] for k in ("date", "datetime", "timestamp") if k in row), None) + value = next((row[k] for k in ("pnl", "value") if k in row), None) + else: + date_i = next( + (i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None + ) + pnl_i = next((i for i, n in enumerate(normalized) if n in ("pnl", "value")), None) + if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= max(date_i, pnl_i): + raise ValueError("PnL schema 无法识别日期或数值列") + timestamp, value = row[date_i], row[pnl_i] + if timestamp is not None: + if isinstance(timestamp, (int, float)): + from datetime import timezone + + timestamp = datetime.fromtimestamp( + timestamp / 1000 if timestamp > 1e11 else timestamp, tz=timezone.utc + ).isoformat() + points.append({"date": str(timestamp), "value": number(value)}) + return sorted(points, key=lambda p: p["date"]) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..47b2302 --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,33 @@ +"""Administrative commands run inside the backend container.""" + +import argparse +import asyncio +import getpass + +from sqlalchemy import delete + +from .config import Settings +from .db import create_database +from .models import Admin, LoginSession +from .security import password_hasher + + +async def reset_password(): + password = getpass.getpass("New admin password (12+ characters): ") + if len(password) < 12 or password != getpass.getpass("Confirm password: "): + raise SystemExit("Password too short or confirmation does not match") + engine, sessions = create_database(Settings().database_url) + async with sessions() as db: + admin = await db.get(Admin, 1) + admin.password_hash = password_hasher.hash(password) + await db.execute(delete(LoginSession)) + await db.commit() + await engine.dispose() + print("Admin password updated; all system sessions revoked.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["reset-password"]) + parser.parse_args() + asyncio.run(reset_password()) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..6842659 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,28 @@ +"""Deployment configuration; secrets are required and never included in API responses.""" + +from cryptography.fernet import Fernet +from pydantic import Field, SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file="../.env", extra="ignore") + + database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq" + admin_username: str = "admin" + admin_password: SecretStr = Field(min_length=12) + encryption_key: SecretStr + public_origin: str = "http://localhost:8080" + cookie_secure: bool = False + session_hours: int = Field(default=24, ge=1, le=168) + wq_base_url: str = "https://api.worldquantbrain.com" + request_timeout: float = 30 + retry_attempts: int = Field(default=4, ge=1, le=8) + enable_runner: bool = True + + @model_validator(mode="after") + def validate_secrets(self): + Fernet(self.encryption_key.get_secret_value().encode()) + if self.cookie_secure and not self.public_origin.startswith("https://"): + raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN") + return self diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..ecc518d --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,8 @@ +"""Async database sessions; each request and background operation owns its transaction.""" + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + +def create_database(url: str): + engine = create_async_engine(url, pool_pre_ping=True, hide_parameters=True) + return engine, async_sessionmaker(engine, expire_on_commit=False) diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..6f6ccbe --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,390 @@ +"""Durable, single-process task runner. + +Each page and its checkpoint commit in one transaction. A single Uvicorn process +owns the runner and shared upstream session; do not scale replicas before adding +a distributed lease and session coordinator. +""" + +import asyncio +import logging +from contextlib import suppress +from datetime import timedelta +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.exc import SQLAlchemyError + +from .alphas import pnl_points, sanitize, upsert_alpha +from .models import Account, Alpha, Job, JobItem, Pnl, now +from .security import cipher +from .worldquant import VerificationRequired, WqClient, WqError + +logger = logging.getLogger(__name__) +ACTIVE = ("queued", "running", "waiting_auth", "waiting_connection") +AUTH_KINDS = ("connect", "verify", "profile") + + +async def create_job(db, kind, payload=None): + job = Job(id=str(uuid4()), kind=kind, payload=payload or {}, checkpoint={}) + db.add(job) + await db.commit() + return job + + +class Runner: + def __init__(self, sessions, settings, client=None): + self.sessions, self.settings = sessions, settings + self.client = client or WqClient(settings) + self.loop_task = None + self.active_task = None + self.active_id = None + self.stopping = False + self.disconnecting = False + self.control_lock = asyncio.Lock() + self.recover_database = False + self.wake = asyncio.Event() + + async def start(self): + async with self.sessions() as db: + await db.execute(update(Job).where(Job.status == "running").values(status="queued")) + account = await db.get(Account, 1) + if account.connection_status in ("connected", "connecting", "verification_required"): + account.connection_status = "expired" + account.verification_url = None + await db.commit() + self.loop_task = asyncio.create_task(self.run_loop()) + + async def stop(self): + self.stopping = True + self.wake.set() + if self.active_task: + self.active_task.cancel() + if self.loop_task: + await self.loop_task + await self.client.close() + + async def cancel(self, job_id): + if self.active_id == job_id and self.active_task: + self.active_task.cancel() + with suppress(asyncio.CancelledError): + await self.active_task + + async def disconnect(self): + # Prevent the scheduler from starting another request while credentials are cleared. + async with self.control_lock: + self.disconnecting = True + try: + if self.active_task: + await self.cancel(self.active_id) + self.client.disconnect() + async with self.sessions() as db: + account = await db.get(Account, 1) + account.connection_status = "disconnected" + account.verification_url = None + account.connection_error = None + await db.execute( + update(Job).where(Job.status.in_(ACTIVE)).values(status="waiting_connection") + ) + await db.commit() + finally: + self.disconnecting = False + + async def run_loop(self): + while not self.stopping: + try: + await self.run_next() + except (SQLAlchemyError, OSError): + # A temporary DB outage must not silently kill the scheduler. + self.recover_database = True + logger.warning("Task runner is waiting for database recovery") + await self.wait_for_work() + + async def wait_for_work(self): + self.wake.clear() + if not self.stopping: + try: + await asyncio.wait_for(self.wake.wait(), timeout=1) + except TimeoutError: + pass + + async def run_next(self): + async with self.control_lock: + async with self.sessions() as db: + if self.recover_database: + await db.execute(update(Job).where(Job.status == "running").values(status="queued")) + await db.commit() + self.recover_database = False + job = ( + await db.scalars( + select(Job) + .where(Job.status == "queued") + .order_by(Job.kind.in_(AUTH_KINDS).desc(), Job.created_at) + .limit(1) + ) + ).first() + job_id = job.id if job else None + if job_id and not self.stopping: + self.active_id = job_id + self.active_task = asyncio.create_task(self.execute(job_id)) + if job_id and self.active_task: + try: + with suppress(asyncio.CancelledError): + await self.active_task + finally: + self.active_id, self.active_task = None, None + else: + await self.wait_for_work() + + async def set_account(self, status, error=None, url=None): + async with self.sessions() as db: + account = await db.get(Account, 1) + account.connection_status, account.connection_error, account.verification_url = status, error, url + await db.commit() + + async def ensure_connected(self, force=False): + async with self.sessions() as db: + account = await db.get(Account, 1) + if ( + not account.email + or not account.password_encrypted + or (account.connection_status == "disconnected" and not force) + ): + raise WqError("请先配置并连接 WorldQuant", "disconnected") + password = cipher(self.settings).decrypt(account.password_encrypted.encode()).decode() + email = account.email + if self.client.verification_url and not force: + raise VerificationRequired(self.client.verification_url) + await self.client.authenticate(email, password, force=force) + await self.set_account("connected") + + async def refresh_profile(self): + raw = await self.client.profile() + async with self.sessions() as db: + account = await db.get(Account, 1) + user_id = raw.get("id") + if not user_id or (account.wq_user_id and account.wq_user_id != str(user_id)): + self.client.disconnect() + raise WqError("平台账户身份不匹配,请核对凭据", "identity_mismatch") + account.wq_user_id = str(user_id) + # An allowlist avoids persisting unknown personal/security fields. + account.profile = sanitize( + { + k: raw[k] + for k in ( + "id", + "username", + "name", + "email", + "firstName", + "lastName", + "role", + "roles", + "permissions", + "type", + ) + if k in raw + } + ) + account.connection_status, account.connection_error = "connected", None + account.verification_url, account.last_synced_at = None, now() + await db.execute( + update(Job) + .where(Job.status.in_(("waiting_auth", "waiting_connection")), ~Job.kind.in_(AUTH_KINDS)) + .values(status="queued", error=None) + ) + # Superseded connect/verify attempts are complete after identity confirmation. + await db.execute( + update(Job) + .where(Job.status.in_(("waiting_auth", "waiting_connection")), Job.kind.in_(AUTH_KINDS)) + .values(status="completed", error=None) + ) + await db.commit() + + async def checkpoint(self, job_id, values): + async with self.sessions() as db: + job = await db.get(Job, job_id) + if job.cancel_requested: + raise asyncio.CancelledError() + for key, value in values.items(): + setattr(job, key, value) + job.updated_at = now() + await db.commit() + + async def execute(self, job_id): + async def retrying(delay): + await self.checkpoint(job_id, {"next_retry_at": now() + timedelta(seconds=delay)}) + + self.client.on_retry = retrying + try: + await self.checkpoint(job_id, {"status": "running", "error": None, "next_retry_at": None}) + async with self.sessions() as db: + job = await db.get(Job, job_id) + kind, payload = job.kind, job.payload + if kind == "verify": + if not self.client.verification_url: + await self.ensure_connected(force=True) + else: + await self.client.verify() + await self.refresh_profile() + else: + await self.ensure_connected(force=kind == "connect") + if kind in ("connect", "profile"): + await self.refresh_profile() + elif kind == "full_sync": + await self.sync_all(job_id) + else: + await self.sync_ids(job_id, kind, payload["alpha_ids"]) + async with self.sessions() as db: + job = await db.get(Job, job_id) + job.status = "completed_with_errors" if job.failed else "completed" + job.next_retry_at, job.updated_at = None, now() + await db.commit() + except asyncio.CancelledError: + async with self.sessions() as db: + job = await db.get(Job, job_id) + job.status = ( + "cancelled" + if job.cancel_requested + else "waiting_connection" + if self.disconnecting + else "queued" + if self.stopping + else "cancelled" + ) + job.next_retry_at, job.updated_at = None, now() + await db.commit() + except VerificationRequired as exc: + await self.set_account("verification_required", str(exc), exc.url) + await self.checkpoint( + job_id, {"status": "waiting_auth", "error": str(exc), "next_retry_at": None} + ) + except WqError as exc: + waiting = exc.code in ("disconnected", "authentication_failed", "identity_mismatch") + if waiting: + await self.set_account("disconnected" if exc.code == "disconnected" else "error", str(exc)) + await self.checkpoint( + job_id, + { + "status": "waiting_connection" if waiting else "failed", + "error": str(exc), + "next_retry_at": None, + }, + ) + except Exception: + # Do not expose upstream bodies, decrypted credentials, or SQL bind parameters. + logger.error("Job %s failed with an internal error", job_id) + await self.checkpoint( + job_id, + {"status": "failed", "error": "任务内部错误,请检查服务日志后重试", "next_retry_at": None}, + ) + finally: + self.client.on_retry = None + + async def sync_all(self, job_id): + partitions = [ + ("UNSUBMITTED", False), + ("UNSUBMITTED", True), + ("SUBMITTED", False), + ("SUBMITTED", True), + ] + async with self.sessions() as db: + job = await db.get(Job, job_id) + checkpoint = job.checkpoint + before = job.created_at.isoformat() + start_partition, offset = checkpoint.get("partition", 0), checkpoint.get("offset", 0) + for partition in range(start_partition, len(partitions)): + submission, hidden = partitions[partition] + while True: + await self.checkpoint(job_id, {"next_retry_at": None}) + raw = await self.client.alphas(submission, hidden, offset, before) + rows = raw.get("results") + if not isinstance(rows, list): + raise WqError("Alpha 列表缺少 results,已保留当前进度", "invalid_response") + async with self.sessions() as db: + job = await db.get(Job, job_id) + if job.cancel_requested: + raise asyncio.CancelledError() + for raw_alpha in rows: + await upsert_alpha(db, raw_alpha) + if not await db.get(JobItem, (job_id, raw_alpha["id"])): + db.add(JobItem(job_id=job_id, alpha_id=raw_alpha["id"])) + job.processed += 1 + # Trust explicit next/count when available; otherwise an empty page ends iteration. + count = raw.get("count") + more = bool(rows) + if "next" in raw: + more = bool(raw["next"]) + elif isinstance(count, int): + more = offset + len(rows) < count and bool(rows) + if more and not rows: + raise WqError("平台分页未前进", "invalid_response") + offset += len(rows) + job.checkpoint = { + "partition": partition if more else partition + 1, + "offset": offset if more else 0, + } + job.updated_at = now() + await db.commit() + if not more: + break + offset = 0 + async with self.sessions() as db: + job = await db.get(Job, job_id) + job.total = job.processed + account = await db.get(Account, 1) + account.last_synced_at = now() + await db.commit() + + async def sync_ids(self, job_id, kind, alpha_ids): + await self.checkpoint(job_id, {"total": len(alpha_ids)}) + for alpha_id in alpha_ids: + async with self.sessions() as db: + previous = await db.get(JobItem, (job_id, alpha_id)) + if previous and not previous.error: + continue + await self.checkpoint(job_id, {"next_retry_at": None}) + error = None + try: + raw = await ( + self.client.pnl(alpha_id) if kind == "pnl_refresh" else self.client.alpha(alpha_id) + ) + if kind != "pnl_refresh" and raw.get("id") != alpha_id: + raise ValueError("平台返回的 Alpha ID 与请求不一致") + points = pnl_points(raw) if kind == "pnl_refresh" else None + except VerificationRequired: + raise + except WqError as exc: + if exc.code not in ("not_found", "access_denied"): + raise + error = str(exc) + except ValueError as exc: + error = str(exc) + async with self.sessions() as db: + job = await db.get(Job, job_id) + if job.cancel_requested: + raise asyncio.CancelledError() + previous = await db.get(JobItem, (job_id, alpha_id)) + if previous and previous.error: + job.failed -= 1 + if not previous: + previous = JobItem(job_id=job_id, alpha_id=alpha_id) + db.add(previous) + if not error: + if kind == "pnl_refresh": + if not await db.get(Alpha, alpha_id): + error = "请先导入此 Alpha" + else: + pnl = await db.get(Pnl, alpha_id) + if pnl is None: + pnl = Pnl(alpha_id=alpha_id) + db.add(pnl) + pnl.raw, pnl.points, pnl.fetched_at = sanitize(raw), points, now() + else: + await upsert_alpha(db, raw) + previous.error = error + if error: + job.failed += 1 + else: + job.processed += 1 + job.updated_at = now() + await db.commit() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..856bfa9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,498 @@ +"""FastAPI application and authenticated public API.""" + +import asyncio +import csv +import io +import time +from collections import defaultdict +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from sqlalchemy import delete, func, select, text + +from .alphas import list_statement, sorted_statement, summary +from .config import Settings +from .db import create_database +from .jobs import ACTIVE, AUTH_KINDS, Runner, create_job +from .models import Account, Admin, Alpha, Job, JobItem, LoginSession, Pnl, Research, ResearchTag, now +from .schemas import ( + AccountOutput, + AlphaDetail, + AlphaFilters, + AlphaPage, + BulkInput, + BulkOutput, + CredentialsInput, + ErrorOutput, + FacetsOutput, + HealthOutput, + JobErrorOutput, + JobInput, + JobOutput, + LoginInput, + OkOutput, + PnlOutput, + PreferencesInput, + ResearchInput, + SessionOutput, + normalize_tags, +) +from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password + + +def account_output(account): + keys = ( + "email", + "wq_user_id", + "profile", + "connection_status", + "connection_error", + "verification_url", + "last_synced_at", + "display_name", + "theme", + "timezone", + "page_size", + ) + return {**{k: getattr(account, k) for k in keys}, "configured": bool(account.password_encrypted)} + + +async def set_tags(db, record, tags): + record.tags = normalize_tags(tags) + await db.execute(delete(ResearchTag).where(ResearchTag.alpha_id == record.alpha_id)) + db.add_all(ResearchTag(alpha_id=record.alpha_id, tag=t) for t in record.tags) + + +def csv_cell(value): + """Neutralize spreadsheet formulas in untrusted names, expressions, notes and tags.""" + if value is None: + return "" + if isinstance(value, str) and ( + value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n")) + ): + return "'" + value + return value + + +def create_app(settings=None, wq_client=None): + settings = settings or Settings() + engine, sessions = create_database(settings.database_url) + runner = Runner(sessions, settings, client=wq_client) + + @asynccontextmanager + async def lifespan(app): + async with sessions() as db: + await bootstrap(db, settings) + if settings.enable_runner: + await runner.start() + yield + if settings.enable_runner: + await runner.stop() + else: + await runner.client.close() + await engine.dispose() + + app = FastAPI( + title="WorldQuant Alpha Research API", + version="0.1.0", + lifespan=lifespan, + responses={code: {"model": ErrorOutput} for code in (401, 403, 404, 409, 422, 429)}, + ) + app.state.engine, app.state.sessions, app.state.runner = engine, sessions, runner + app.state.settings = settings + login_failures = defaultdict(list) + + @app.exception_handler(RequestValidationError) + async def validation_error(request, exc): + # Pydantic's default error includes the submitted value, possibly a password. + return JSONResponse( + status_code=422, + content={ + "detail": "; ".join(f"{'.'.join(str(v) for v in e['loc'])}: {e['msg']}" for e in exc.errors()) + }, + ) + + @app.middleware("http") + async def browser_security(request, call_next): + if request.method not in ("GET", "HEAD", "OPTIONS"): + if request.headers.get("X-WQ-Request") != "1": + return JSONResponse({"detail": "缺少请求校验头"}, status_code=403) + origin = request.headers.get("Origin") + if origin and origin.rstrip("/") != settings.public_origin.rstrip("/"): + return JSONResponse({"detail": "请求来源不被允许"}, status_code=403) + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Cache-Control"] = "no-store" + return response + + @app.get("/api/v1/health", response_model=HealthOutput, tags=["health"]) + async def health(): + async with sessions() as db: + await db.execute(text("SELECT 1")) + return {"status": "ok"} + + @app.post("/api/v1/auth/login", response_model=SessionOutput, tags=["auth"]) + async def login(body: LoginInput, request: Request, response: Response): + key = request.client.host if request.client else "unknown" + current = time.monotonic() + # Bound the rate-limit map and remove expired entries. + for peer in list(login_failures): + login_failures[peer] = [t for t in login_failures[peer] if current - t < 60] + if not login_failures[peer]: + del login_failures[peer] + if len(login_failures[key]) >= 5: + raise HTTPException(429, "登录尝试过多,请一分钟后重试") + async with sessions() as db: + admin = await db.get(Admin, 1) + matches = await asyncio.to_thread(valid_password, admin.password_hash, body.password) + if body.username != admin.username or not matches: + login_failures[key].append(current) + raise HTTPException(401, "用户名或密码不正确") + login_failures.pop(key, None) + token = await issue_session(db, settings.session_hours) + response.set_cookie( + "wq_session", + token, + httponly=True, + secure=settings.cookie_secure, + samesite="strict", + max_age=settings.session_hours * 3600, + path="/", + ) + return {"username": admin.username} + + api = APIRouter(prefix="/api/v1", dependencies=[Depends(require_auth)]) + + @api.get("/auth/me", response_model=SessionOutput, tags=["auth"]) + async def me(): + async with sessions() as db: + return {"username": (await db.get(Admin, 1)).username} + + @api.post("/auth/logout", response_model=OkOutput, tags=["auth"]) + async def logout(request: Request, response: Response): + async with sessions() as db: + await db.execute( + delete(LoginSession).where( + LoginSession.token_hash == token_hash(request.cookies["wq_session"]) + ) + ) + await db.commit() + response.delete_cookie( + "wq_session", path="/", httponly=True, secure=settings.cookie_secure, samesite="strict" + ) + return {"ok": True} + + @api.get("/account", response_model=AccountOutput, tags=["account"]) + async def get_account(): + async with sessions() as db: + return account_output(await db.get(Account, 1)) + + @api.put("/account/credentials", response_model=AccountOutput, tags=["account"]) + async def credentials(body: CredentialsInput): + async with sessions() as db: + account = await db.get(Account, 1) + if account.wq_user_id and account.email.casefold() != body.email.casefold(): + raise HTTPException(409, "本系统已绑定一个账户;不允许混入其他账户的 Alpha") + await runner.disconnect() + async with sessions() as db: + account = await db.get(Account, 1) + account.email = body.email + account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode() + await db.commit() + return account_output(account) + + @api.patch("/account/preferences", response_model=AccountOutput, tags=["account"]) + async def preferences(body: PreferencesInput): + async with sessions() as db: + account = await db.get(Account, 1) + for key, value in body.model_dump().items(): + setattr(account, key, value) + await db.commit() + return account_output(account) + + async def account_job(kind): + async with sessions() as db: + account = await db.scalar(select(Account).where(Account.id == 1).with_for_update()) + if not account.password_encrypted: + raise HTTPException(409, "请先保存 WorldQuant 账户配置") + existing = ( + await db.scalars( + select(Job).where(Job.kind.in_(AUTH_KINDS), Job.status.in_(("running", "queued"))) + ) + ).first() + if existing: + return existing + if kind == "connect": + account.connection_status = "connecting" + account.connection_error = None + job = await create_job(db, kind) + runner.wake.set() + return job + + @api.post("/account/connect", status_code=202, response_model=JobOutput, tags=["account"]) + async def connect(): + return await account_job("connect") + + @api.post("/account/verify", status_code=202, response_model=JobOutput, tags=["account"]) + async def verify(): + return await account_job("verify") + + @api.post("/account/refresh", status_code=202, response_model=JobOutput, tags=["account"]) + async def refresh(): + return await account_job("profile") + + @api.post("/account/disconnect", response_model=OkOutput, tags=["account"]) + async def disconnect(): + await runner.disconnect() + return {"ok": True} + + @api.get("/alphas", response_model=AlphaPage, tags=["alphas"]) + async def get_alphas(filters: Annotated[AlphaFilters, Query()]): + query = list_statement(filters) + async with sessions() as db: + total = await db.scalar(select(func.count()).select_from(query.subquery())) + rows = ( + await db.execute( + sorted_statement(query, filters.sort, filters.direction) + .limit(filters.limit) + .offset(filters.offset) + ) + ).all() + return { + "items": [summary(a, r) for a, r in rows], + "total": total, + "limit": filters.limit, + "offset": filters.offset, + } + + @api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"]) + async def facets(): + async with sessions() as db: + result = {} + for key in ("region", "universe", "alpha_type", "language", "status", "stage"): + column = getattr(Alpha, key) + result[key] = list( + ( + await db.scalars( + select(column).where(column.is_not(None)).distinct().order_by(column) + ) + ).all() + ) + result["tags"] = list( + (await db.scalars(select(ResearchTag.tag).distinct().order_by(ResearchTag.tag))).all() + ) + result["total"] = await db.scalar(select(func.count()).select_from(Alpha)) + result["favorites"] = await db.scalar( + select(func.count()).select_from(Research).where(Research.favorite.is_(True)) + ) + result["last_sync"] = await db.scalar(select(func.max(Alpha.synced_at))) + return result + + @api.get( + "/alphas/export", + response_class=StreamingResponse, + responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}}, + tags=["alphas"], + ) + async def export(filters: Annotated[AlphaFilters, Query()]): + query = sorted_statement(list_statement(filters), filters.sort, filters.direction) + + async def content(): + yield "\ufeff" + buffer = io.StringIO() + writer = csv.writer(buffer) + columns = [ + "id", + "name", + "expression", + "selection", + "combo", + "alpha_type", + "language", + "stage", + "status", + "hidden", + "region", + "universe", + "sharpe", + "fitness", + "returns", + "turnover", + "margin", + "drawdown", + "date_created", + "date_submitted", + "synced_at", + ] + writer.writerow(columns + ["research_state", "favorite", "tags", "note"]) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + async with sessions() as db: + rows = await db.stream(query.execution_options(yield_per=200)) + async for a, r in rows: + writer.writerow( + [csv_cell(getattr(a, key)) for key in columns] + + [r.state, r.favorite, csv_cell(";".join(r.tags)), csv_cell(r.note)] + ) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + return StreamingResponse( + content(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="alphas.csv"'}, + ) + + @api.patch("/alphas/research/bulk", response_model=BulkOutput, tags=["alphas"]) + async def bulk(body: BulkInput): + async with sessions() as db: + rows = ( + await db.scalars( + select(Research).where(Research.alpha_id.in_(body.alpha_ids)).with_for_update() + ) + ).all() + if len(rows) != len(body.alpha_ids): + raise HTTPException(404, "部分 Alpha 尚未同步,本次未修改任何记录") + for row in rows: + tags = (set(row.tags) | set(body.add_tags)) - set(body.remove_tags) + try: + await set_tags(db, row, list(tags)) + except ValueError as exc: + raise HTTPException(422, str(exc)) from None + if body.state: + row.state = body.state + row.updated_at = now() + await db.commit() + return {"updated": len(rows)} + + @api.get("/alphas/{alpha_id}", response_model=AlphaDetail, tags=["alphas"]) + async def detail(alpha_id: str): + async with sessions() as db: + a, r = await db.get(Alpha, alpha_id), await db.get(Research, alpha_id) + if a is None: + raise HTTPException(404, "Alpha 尚未同步") + return { + **summary(a, r), + **{ + key: getattr(a, key) + for key in ( + "expression", + "selection", + "combo", + "settings", + "is_metrics", + "os_metrics", + "checks", + ) + }, + } + + @api.patch("/alphas/{alpha_id}/research", response_model=OkOutput, tags=["alphas"]) + async def research(alpha_id: str, body: ResearchInput): + async with sessions() as db: + row = await db.scalar(select(Research).where(Research.alpha_id == alpha_id).with_for_update()) + if row is None: + raise HTTPException(404, "Alpha 尚未同步") + for key, value in body.model_dump(exclude_unset=True).items(): + if key == "tags": + await set_tags(db, row, value) + else: + setattr(row, key, value) + row.updated_at = now() + await db.commit() + return {"ok": True} + + @api.get("/alphas/{alpha_id}/pnl", response_model=PnlOutput, tags=["alphas"]) + async def pnl(alpha_id: str): + async with sessions() as db: + if not await db.get(Alpha, alpha_id): + raise HTTPException(404, "Alpha 尚未同步") + record = await db.get(Pnl, alpha_id) + return { + "cached": record is not None, + "points": record.points if record else [], + "fetched_at": record.fetched_at if record else None, + } + + @api.post("/sync-jobs", status_code=202, response_model=JobOutput, tags=["sync-jobs"]) + async def new_job(body: JobInput): + async with sessions() as db: + # Serialize creation against the singleton account, avoiding duplicate full scans. + account = await db.scalar(select(Account).where(Account.id == 1).with_for_update()) + if not account.password_encrypted or account.connection_status in ("disconnected", "error"): + raise HTTPException(409, "请先连接 WorldQuant") + payload = {"alpha_ids": body.alpha_ids} + existing = ( + await db.scalars(select(Job).where(Job.kind == body.kind, Job.status.in_(ACTIVE))) + ).all() + for job in existing: + if job.payload == payload: + return job + job = await create_job(db, body.kind, payload) + runner.wake.set() + return job + + @api.get("/sync-jobs", response_model=list[JobOutput], tags=["sync-jobs"]) + async def get_jobs(): + async with sessions() as db: + return (await db.scalars(select(Job).order_by(Job.created_at.desc()).limit(100))).all() + + @api.get("/sync-jobs/{job_id}", response_model=JobOutput, tags=["sync-jobs"]) + async def get_job(job_id: str): + async with sessions() as db: + job = await db.get(Job, job_id) + if job is None: + raise HTTPException(404, "任务不存在") + return job + + @api.get("/sync-jobs/{job_id}/errors", response_model=list[JobErrorOutput], tags=["sync-jobs"]) + async def job_errors(job_id: str): + async with sessions() as db: + rows = ( + await db.scalars(select(JobItem).where(JobItem.job_id == job_id, JobItem.error.is_not(None))) + ).all() + return [{"alpha_id": row.alpha_id, "error": row.error} for row in rows] + + @api.post("/sync-jobs/{job_id}/cancel", response_model=OkOutput, tags=["sync-jobs"]) + async def cancel_job(job_id: str): + async with sessions() as db: + job = await db.get(Job, job_id) + if job is None: + raise HTTPException(404, "任务不存在") + if job.status not in ACTIVE: + return {"ok": True} + job.cancel_requested = True + if job.status != "running": + job.status = "cancelled" + await db.commit() + await runner.cancel(job_id) + return {"ok": True} + + @api.post("/sync-jobs/{job_id}/retry", response_model=JobOutput, tags=["sync-jobs"]) + async def retry_job(job_id: str): + async with sessions() as db: + job = await db.get(Job, job_id) + if job is None: + raise HTTPException(404, "任务不存在") + if job.status not in ( + "failed", + "cancelled", + "completed_with_errors", + "waiting_connection", + "waiting_auth", + ): + raise HTTPException(409, "该任务当前不需要重试") + job.status, job.error, job.cancel_requested, job.next_retry_at = "queued", None, False, None + job.updated_at = now() + await db.commit() + runner.wake.set() + return job + + app.include_router(api) + return app diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..4e717bc --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,123 @@ +"""Platform snapshots and local research are intentionally stored separately.""" + +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class Admin(Base): + __tablename__ = "admins" + id: Mapped[int] = mapped_column(primary_key=True, default=1) + username: Mapped[str] = mapped_column(String(100), unique=True) + password_hash: Mapped[str] = mapped_column(Text) + + +class LoginSession(Base): + __tablename__ = "login_sessions" + token_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + +class Account(Base): + __tablename__ = "accounts" + id: Mapped[int] = mapped_column(primary_key=True, default=1) + email: Mapped[str | None] = mapped_column(String(254)) + password_encrypted: Mapped[str | None] = mapped_column(Text) + wq_user_id: Mapped[str | None] = mapped_column(String(100)) + profile: Mapped[dict] = mapped_column(JSON, default=dict) + connection_status: Mapped[str] = mapped_column(String(30), default="disconnected") + connection_error: Mapped[str | None] = mapped_column(Text) + verification_url: Mapped[str | None] = mapped_column(Text) + last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + display_name: Mapped[str] = mapped_column(String(100), default="研究员") + theme: Mapped[str] = mapped_column(String(10), default="light") + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai") + page_size: Mapped[int] = mapped_column(Integer, default=25) + + +class Alpha(Base): + __tablename__ = "alphas" + id: Mapped[str] = mapped_column(String(100), primary_key=True) + name: Mapped[str | None] = mapped_column(Text) + expression: Mapped[str | None] = mapped_column(Text) + selection: Mapped[str | None] = mapped_column(Text) + combo: Mapped[str | None] = mapped_column(Text) + alpha_type: Mapped[str | None] = mapped_column(String(50), index=True) + language: Mapped[str | None] = mapped_column(String(50)) + stage: Mapped[str | None] = mapped_column(String(50), index=True) + status: Mapped[str | None] = mapped_column(String(80), index=True) + hidden: Mapped[bool] = mapped_column(Boolean, default=False) + region: Mapped[str | None] = mapped_column(String(50), index=True) + universe: Mapped[str | None] = mapped_column(String(100)) + settings: Mapped[dict] = mapped_column(JSON, default=dict) + is_metrics: Mapped[dict] = mapped_column(JSON, default=dict) + os_metrics: Mapped[dict] = mapped_column(JSON, default=dict) + checks: Mapped[list] = mapped_column(JSON, default=list) + sharpe: Mapped[float | None] = mapped_column(Float, index=True) + fitness: Mapped[float | None] = mapped_column(Float, index=True) + returns: Mapped[float | None] = mapped_column(Float) + turnover: Mapped[float | None] = mapped_column(Float) + margin: Mapped[float | None] = mapped_column(Float) + drawdown: Mapped[float | None] = mapped_column(Float) + date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + raw: Mapped[dict] = mapped_column(JSON, default=dict) + __table_args__ = (Index("ix_alphas_region_status", "region", "status"),) + + +class Research(Base): + __tablename__ = "research" + alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True) + note: Mapped[str] = mapped_column(Text, default="") + tags: Mapped[list] = mapped_column(JSON, default=list) + favorite: Mapped[bool] = mapped_column(Boolean, default=False) + state: Mapped[str] = mapped_column(String(30), default="inbox", index=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + +class Pnl(Base): + __tablename__ = "pnl_cache" + alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True) + raw: Mapped[dict] = mapped_column(JSON) + points: Mapped[list] = mapped_column(JSON) + fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + +class ResearchTag(Base): + __tablename__ = "research_tags" + alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True) + tag: Mapped[str] = mapped_column(String(60), primary_key=True, index=True) + + +class Job(Base): + __tablename__ = "sync_jobs" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + kind: Mapped[str] = mapped_column(String(30)) + status: Mapped[str] = mapped_column(String(30), default="queued", index=True) + payload: Mapped[dict] = mapped_column(JSON, default=dict) + checkpoint: Mapped[dict] = mapped_column(JSON, default=dict) + processed: Mapped[int] = mapped_column(Integer, default=0) + failed: Mapped[int] = mapped_column(Integer, default=0) + total: Mapped[int | None] = mapped_column(Integer) + error: Mapped[str | None] = mapped_column(Text) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + +class JobItem(Base): + __tablename__ = "job_items" + job_id: Mapped[str] = mapped_column(ForeignKey("sync_jobs.id"), primary_key=True) + alpha_id: Mapped[str] = mapped_column(String(100), primary_key=True) + error: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..74eef9e --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,281 @@ +"""Validated public API contracts. Platform state is intentionally not a closed enum.""" + +import re +from datetime import datetime, timezone +from typing import Literal +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] +SortField = Literal[ + "id", + "name", + "sharpe", + "fitness", + "returns", + "turnover", + "margin", + "drawdown", + "date_created", + "date_submitted", + "synced_at", +] + + +class Contract(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +class LoginInput(Contract): + username: str = Field(min_length=1, max_length=100) + password: str = Field(min_length=1, max_length=1024) + + +class CredentialsInput(Contract): + email: str = Field(max_length=254) + password: str = Field(min_length=1, max_length=1024) + + @field_validator("email") + @classmethod + def valid_email(cls, value): + value = value.strip() + if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value): + raise ValueError("邮箱格式不正确") + return value + + +class PreferencesInput(Contract): + display_name: str = Field(min_length=1, max_length=100) + theme: Literal["light", "dark"] + timezone: str + page_size: Literal[25, 50, 100] + + @field_validator("timezone") + @classmethod + def valid_timezone(cls, value): + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError): + raise ValueError("未知时区") from None + return value + + +class AlphaFilters(Contract): + q: str | None = Field(default=None, max_length=300) + region: str | None = None + universe: str | None = None + alpha_type: str | None = None + language: str | None = None + status: str | None = None + stage: str | None = None + hidden: bool | None = None + research_state: ResearchState | None = None + favorite: bool | None = None + tag: str | None = Field(default=None, max_length=60) + created_from: datetime | None = None + created_to: datetime | None = None + sharpe_min: float | None = None + sharpe_max: float | None = None + fitness_min: float | None = None + fitness_max: float | None = None + returns_min: float | None = None + returns_max: float | None = None + turnover_min: float | None = None + turnover_max: float | None = None + margin_min: float | None = None + margin_max: float | None = None + drawdown_min: float | None = None + drawdown_max: float | None = None + sort: SortField = "date_created" + direction: Literal["asc", "desc"] = "desc" + limit: int = Field(default=25, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + @field_validator("created_from", "created_to") + @classmethod + def utc_dates(cls, value): + return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value + + @model_validator(mode="after") + def range_order(self): + for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"): + lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max") + if lo is not None and hi is not None and lo > hi: + raise ValueError(f"{key} 最小值不能大于最大值") + if self.created_from and self.created_to and self.created_from > self.created_to: + raise ValueError("开始时间不能晚于结束时间") + return self + + +def normalize_tags(values): + result = sorted(set(v.strip() for v in values if v.strip())) + if len(result) > 30 or any(len(v) > 60 for v in result): + raise ValueError("最多 30 个标签,每个标签最多 60 字符") + return result + + +class ResearchInput(Contract): + note: str = Field(default="", max_length=20000) + tags: list[str] = Field(default_factory=list) + favorite: bool = False + state: ResearchState = "inbox" + _tags = field_validator("tags")(normalize_tags) + + +def valid_ids(values): + result = list(dict.fromkeys(values)) + if not result or len(result) > 100 or any(not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", v) for v in result): + raise ValueError("请输入 1–100 个合法 Alpha ID(字母、数字、下划线或短横线)") + return result + + +class BulkInput(Contract): + alpha_ids: list[str] + add_tags: list[str] = Field(default_factory=list) + remove_tags: list[str] = Field(default_factory=list) + state: ResearchState | None = None + _ids = field_validator("alpha_ids")(valid_ids) + _tags = field_validator("add_tags", "remove_tags")(normalize_tags) + + @model_validator(mode="after") + def disjoint(self): + if set(self.add_tags) & set(self.remove_tags): + raise ValueError("同一标签不能同时添加和移除") + return self + + +class JobInput(Contract): + kind: Literal["full_sync", "alpha_refresh", "pnl_refresh"] + alpha_ids: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_ids(self): + if self.kind == "full_sync": + if self.alpha_ids: + raise ValueError("全量同步不接受 Alpha ID") + else: + self.alpha_ids = valid_ids(self.alpha_ids) + return self + + +class ResearchOutput(ResearchInput): + updated_at: datetime + + +class AlphaSummary(BaseModel): + id: str + name: str | None + expression_preview: str + alpha_type: str | None + language: str | None + stage: str | None + status: str | None + hidden: bool + region: str | None + universe: str | None + sharpe: float | None + fitness: float | None + returns: float | None + turnover: float | None + margin: float | None + drawdown: float | None + date_created: datetime | None + date_submitted: datetime | None + synced_at: datetime + research: ResearchOutput + + +class AlphaDetail(AlphaSummary): + expression: str | None + selection: str | None + combo: str | None + settings: dict + is_metrics: dict + os_metrics: dict + checks: list + + +class AlphaPage(BaseModel): + items: list[AlphaSummary] + total: int + limit: int + offset: int + + +class JobOutput(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + kind: str + status: str + processed: int + failed: int + total: int | None + error: str | None + next_retry_at: datetime | None + created_at: datetime + updated_at: datetime + + +class AccountOutput(BaseModel): + email: str | None + configured: bool + wq_user_id: str | None + profile: dict + connection_status: str + connection_error: str | None + verification_url: str | None + last_synced_at: datetime | None + display_name: str + theme: Literal["light", "dark"] + timezone: str + page_size: int + + +class SessionOutput(BaseModel): + username: str + + +class OkOutput(BaseModel): + ok: bool + + +class ErrorOutput(BaseModel): + detail: str + + +class HealthOutput(BaseModel): + status: Literal["ok"] + + +class BulkOutput(BaseModel): + updated: int + + +class PnlPoint(BaseModel): + date: str + value: float | None + + +class PnlOutput(BaseModel): + cached: bool + points: list[PnlPoint] + fetched_at: datetime | None + + +class FacetsOutput(BaseModel): + region: list[str] + universe: list[str] + alpha_type: list[str] + language: list[str] + status: list[str] + stage: list[str] + tags: list[str] + total: int + favorites: int + last_sync: datetime | None + + +class JobErrorOutput(BaseModel): + alpha_id: str + error: str diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..d4a1095 --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,65 @@ +"""Server-side authentication and encryption utilities.""" + +import hashlib +import secrets +from datetime import timedelta + +from argon2 import PasswordHasher +from argon2.exceptions import VerificationError +from cryptography.fernet import Fernet +from fastapi import Depends, HTTPException, Request +from fastapi.security import APIKeyCookie +from sqlalchemy import delete + +from .models import Account, Admin, LoginSession, now + +password_hasher = PasswordHasher() +session_cookie = APIKeyCookie(name="wq_session", auto_error=False) + + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def cipher(settings) -> Fernet: + return Fernet(settings.encryption_key.get_secret_value().encode()) + + +async def bootstrap(db, settings): + """Only initialize missing singleton records; deployments never reset existing passwords.""" + if not await db.get(Admin, 1): + db.add( + Admin( + id=1, + username=settings.admin_username, + password_hash=password_hasher.hash(settings.admin_password.get_secret_value()), + ) + ) + if not await db.get(Account, 1): + db.add(Account(id=1)) + await db.execute(delete(LoginSession).where(LoginSession.expires_at < now())) + await db.commit() + + +def valid_password(encoded: str, value: str) -> bool: + try: + return password_hasher.verify(encoded, value) + except VerificationError: + return False + + +async def issue_session(db, hours: int) -> str: + token = secrets.token_urlsafe(32) + db.add(LoginSession(token_hash=token_hash(token), expires_at=now() + timedelta(hours=hours))) + await db.commit() + return token + + +async def require_auth(request: Request, token: str | None = Depends(session_cookie)): + if not token: + raise HTTPException(401, "请先登录系统") + async with request.app.state.sessions() as db: + row = await db.get(LoginSession, token_hash(token)) + # SQLite fixtures lose tzinfo; production PostgreSQL preserves it. + if row is None or row.expires_at.replace(tzinfo=now().tzinfo) <= now(): + raise HTTPException(401, "系统登录已过期,请重新登录") diff --git a/backend/app/worldquant.py b/backend/app/worldquant.py new file mode 100644 index 0000000..e0a63ec --- /dev/null +++ b/backend/app/worldquant.py @@ -0,0 +1,211 @@ +"""Read-only WorldQuant adapter. Authentication is the only allowed upstream POST. + +No upstream response body or request headers are included in exceptions: they may +contain credentials, cookies, or temporary authentication links. +""" + +import asyncio +import math +import random +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Awaitable, Callable +from urllib.parse import urljoin, urlparse + +import httpx + + +class WqError(Exception): + def __init__(self, message: str, code: str = "upstream_error"): + super().__init__(message) + self.code = code + + +class VerificationRequired(WqError): + def __init__(self, url: str): + super().__init__("请在 WorldQuant 完成人工验证,再点击继续验证", "verification_required") + self.url = url + + +class WqClient: + def __init__(self, settings, transport=None, sleep=asyncio.sleep): + self.settings = settings + self.client = httpx.AsyncClient( + base_url=settings.wq_base_url, + timeout=settings.request_timeout, + transport=transport, + follow_redirects=False, + ) + self.lock = asyncio.Lock() + self.authenticated = False + self.auth_generation = 0 + self.credentials: tuple[str, str] | None = None + self.verification_url: str | None = None + self.sleep = sleep + self.on_retry: Callable[[float], Awaitable[None]] | None = None + + async def close(self): + await self.client.aclose() + + def disconnect(self): + self.authenticated = False + self.credentials = None + self.verification_url = None + self.client.cookies.clear() + + def safe_verification_url(self, response: httpx.Response) -> str: + url = urljoin(str(response.url), response.headers.get("Location", "")) + base = urlparse(self.settings.wq_base_url) + parsed = urlparse(url) + if ( + not response.headers.get("Location") + or parsed.scheme != base.scheme + or parsed.netloc != base.netloc + ): + raise WqError("平台返回了无法确认的验证地址,请到 BRAIN 官网检查账户", "invalid_verification") + return url + + @staticmethod + def retry_delay(value: str | None, attempt: int) -> float: + if value: + try: + seconds = float(value) + except ValueError: + try: + seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds() + except (TypeError, ValueError): + seconds = -1 + if math.isfinite(seconds) and seconds >= 0: + return seconds + return min(2**attempt + random.uniform(0, 0.5), 30) + + async def _request(self, method: str, path: str, **kwargs) -> httpx.Response: + for attempt in range(self.settings.retry_attempts): + try: + response = await self.client.request(method, path, **kwargs) + except (httpx.TimeoutException, httpx.NetworkError): + if attempt + 1 == self.settings.retry_attempts: + raise WqError("WorldQuant 网络请求失败,请稍后重试", "network_error") from None + response = None + if response is not None and response.status_code not in (429, 500, 502, 503, 504): + return response + if attempt + 1 == self.settings.retry_attempts: + raise WqError("WorldQuant 暂时限流或不可用,重试预算已用完", "retry_exhausted") + delay = self.retry_delay( + response.headers.get("Retry-After") if response is not None else None, attempt + ) + if self.on_retry: + await self.on_retry(delay) + await self.sleep(delay) + raise WqError("重试失败") + + async def authenticate(self, email: str, password: str, force=False, stale_generation=None): + async with self.lock: + if ( + self.authenticated + and not force + and (stale_generation is None or stale_generation != self.auth_generation) + ): + return + if self.verification_url and not force: + raise VerificationRequired(self.verification_url) + self.credentials = (email, password) + self.authenticated = False + if force: + self.client.cookies.clear() + self.verification_url = None + response = await self._request("POST", "/authentication", auth=httpx.BasicAuth(email, password)) + if ( + response.status_code == 401 + and response.headers.get("WWW-Authenticate", "").lower() == "persona" + ): + self.verification_url = self.safe_verification_url(response) + raise VerificationRequired(self.verification_url) + if response.status_code in (401, 403): + raise WqError("平台认证未通过,请检查凭据或账户访问权限", "authentication_failed") + if response.status_code not in (200, 201): + raise WqError(f"平台认证返回异常状态 {response.status_code}", "authentication_failed") + self.authenticated = True + self.auth_generation += 1 + self.verification_url = None + + async def verify(self): + """Continue the same server-side challenge session after the human visits the URL.""" + if not self.verification_url: + if not self.credentials: + raise WqError("验证会话已结束,请重新连接", "authentication_failed") + await self.authenticate(*self.credentials, force=True) + return + response = await self._request("POST", self.verification_url) + if response.status_code in (200, 201): + self.authenticated = True + self.auth_generation += 1 + self.verification_url = None + elif response.status_code in (401, 403, 202): + raise VerificationRequired(self.verification_url) + else: + raise WqError("验证会话已失效,请重新连接", "authentication_failed") + + async def get(self, path: str, params=None): + if not self.credentials: + raise WqError("请先连接 WorldQuant", "disconnected") + if not self.authenticated: + await self.authenticate(*self.credentials) + refreshed = False + for attempt in range(self.settings.retry_attempts): + generation = self.auth_generation + response = await self._request("GET", path, params=params) + if response.status_code == 401 and not refreshed: + await self.authenticate(*self.credentials, stale_generation=generation) + refreshed = True + continue + if response.status_code in (401, 403): + raise WqError("无权访问该平台资源", "access_denied") + if response.status_code == 404: + raise WqError("平台资源不存在或不可访问", "not_found") + if response.status_code not in (200, 201, 202): + raise WqError(f"WorldQuant 请求返回状态 {response.status_code}") + # Recordsets may return 200/202 with Retry-After before results exist. + if ( + response.headers.get("Retry-After") + and self.retry_delay(response.headers["Retry-After"], 0) > 0 + ): + if attempt + 1 == self.settings.retry_attempts: + raise WqError("平台数据仍在准备,请稍后重试", "pending") + delay = self.retry_delay(response.headers["Retry-After"], attempt) + if self.on_retry: + await self.on_retry(delay) + await self.sleep(delay) + continue + try: + result = response.json() + if not isinstance(result, dict): + raise ValueError() + return result + except ValueError: + raise WqError("平台返回的数据格式无法识别", "invalid_response") from None + raise WqError("平台数据尚未就绪", "pending") + + async def profile(self): + return await self.get("/users/self") + + async def alphas(self, submission, hidden, offset, before): + # Cover every platform stage; submitted records are not assumed to be OS only. + status_key = "status" if submission == "UNSUBMITTED" else "status!" + return await self.get( + "/users/self/alphas", + { + status_key: "UNSUBMITTED", + "hidden": str(hidden).lower(), + "limit": 100, + "offset": offset, + "order": "dateCreated", + "dateCreated<": before, + }, + ) + + async def alpha(self, alpha_id): + return await self.get(f"/alphas/{alpha_id}") + + async def pnl(self, alpha_id): + return await self.get(f"/alphas/{alpha_id}/recordsets/pnl") diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..51268be --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,28 @@ +import asyncio + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import Settings +from app.models import Base + + +def migrations(connection): + context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run(): + engine = create_async_engine(Settings().database_url) + async with engine.connect() as connection: + await connection.run_sync(migrations) + await engine.dispose() + + +if context.is_offline_mode(): + context.configure(url=Settings().database_url, target_metadata=Base.metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() +else: + asyncio.run(run()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..6a5506a --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,14 @@ +"""${message}""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +def upgrade(): + ${upgrades if upgrades else "pass"} + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/0001_initial_account_alpha_and_durable_job_.py b/backend/migrations/versions/0001_initial_account_alpha_and_durable_job_.py new file mode 100644 index 0000000..8474f0c --- /dev/null +++ b/backend/migrations/versions/0001_initial_account_alpha_and_durable_job_.py @@ -0,0 +1,153 @@ +"""Initial account alpha and durable job schema""" +from alembic import op +import sqlalchemy as sa + +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=254), nullable=True), + sa.Column('password_encrypted', sa.Text(), nullable=True), + sa.Column('wq_user_id', sa.String(length=100), nullable=True), + sa.Column('profile', sa.JSON(), nullable=False), + sa.Column('connection_status', sa.String(length=30), nullable=False), + sa.Column('connection_error', sa.Text(), nullable=True), + sa.Column('verification_url', sa.Text(), nullable=True), + sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('display_name', sa.String(length=100), nullable=False), + sa.Column('theme', sa.String(length=10), nullable=False), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('page_size', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('admins', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('password_hash', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('alphas', + sa.Column('id', sa.String(length=100), nullable=False), + sa.Column('name', sa.Text(), nullable=True), + sa.Column('expression', sa.Text(), nullable=True), + sa.Column('selection', sa.Text(), nullable=True), + sa.Column('combo', sa.Text(), nullable=True), + sa.Column('alpha_type', sa.String(length=50), nullable=True), + sa.Column('language', sa.String(length=50), nullable=True), + sa.Column('stage', sa.String(length=50), nullable=True), + sa.Column('status', sa.String(length=80), nullable=True), + sa.Column('hidden', sa.Boolean(), nullable=False), + sa.Column('region', sa.String(length=50), nullable=True), + sa.Column('universe', sa.String(length=100), nullable=True), + sa.Column('settings', sa.JSON(), nullable=False), + sa.Column('is_metrics', sa.JSON(), nullable=False), + sa.Column('os_metrics', sa.JSON(), nullable=False), + sa.Column('checks', sa.JSON(), nullable=False), + sa.Column('sharpe', sa.Float(), nullable=True), + sa.Column('fitness', sa.Float(), nullable=True), + sa.Column('returns', sa.Float(), nullable=True), + sa.Column('turnover', sa.Float(), nullable=True), + sa.Column('margin', sa.Float(), nullable=True), + sa.Column('drawdown', sa.Float(), nullable=True), + sa.Column('date_created', sa.DateTime(timezone=True), nullable=True), + sa.Column('date_submitted', sa.DateTime(timezone=True), nullable=True), + sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('raw', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_alphas_alpha_type'), 'alphas', ['alpha_type'], unique=False) + op.create_index(op.f('ix_alphas_date_created'), 'alphas', ['date_created'], unique=False) + op.create_index(op.f('ix_alphas_fitness'), 'alphas', ['fitness'], unique=False) + op.create_index(op.f('ix_alphas_region'), 'alphas', ['region'], unique=False) + op.create_index('ix_alphas_region_status', 'alphas', ['region', 'status'], unique=False) + op.create_index(op.f('ix_alphas_sharpe'), 'alphas', ['sharpe'], unique=False) + op.create_index(op.f('ix_alphas_stage'), 'alphas', ['stage'], unique=False) + op.create_index(op.f('ix_alphas_status'), 'alphas', ['status'], unique=False) + op.create_table('login_sessions', + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('token_hash') + ) + op.create_index(op.f('ix_login_sessions_expires_at'), 'login_sessions', ['expires_at'], unique=False) + op.create_table('sync_jobs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('kind', sa.String(length=30), nullable=False), + sa.Column('status', sa.String(length=30), nullable=False), + sa.Column('payload', sa.JSON(), nullable=False), + sa.Column('checkpoint', sa.JSON(), nullable=False), + sa.Column('processed', sa.Integer(), nullable=False), + sa.Column('failed', sa.Integer(), nullable=False), + sa.Column('total', sa.Integer(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('cancel_requested', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sync_jobs_status'), 'sync_jobs', ['status'], unique=False) + op.create_table('job_items', + sa.Column('job_id', sa.String(length=36), nullable=False), + sa.Column('alpha_id', sa.String(length=100), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['job_id'], ['sync_jobs.id'], ), + sa.PrimaryKeyConstraint('job_id', 'alpha_id') + ) + op.create_table('pnl_cache', + sa.Column('alpha_id', sa.String(length=100), nullable=False), + sa.Column('raw', sa.JSON(), nullable=False), + sa.Column('points', sa.JSON(), nullable=False), + sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ), + sa.PrimaryKeyConstraint('alpha_id') + ) + op.create_table('research', + sa.Column('alpha_id', sa.String(length=100), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.Column('tags', sa.JSON(), nullable=False), + sa.Column('favorite', sa.Boolean(), nullable=False), + sa.Column('state', sa.String(length=30), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ), + sa.PrimaryKeyConstraint('alpha_id') + ) + op.create_index(op.f('ix_research_state'), 'research', ['state'], unique=False) + op.create_table('research_tags', + sa.Column('alpha_id', sa.String(length=100), nullable=False), + sa.Column('tag', sa.String(length=60), nullable=False), + sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ), + sa.PrimaryKeyConstraint('alpha_id', 'tag') + ) + op.create_index(op.f('ix_research_tags_tag'), 'research_tags', ['tag'], unique=False) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_research_tags_tag'), table_name='research_tags') + op.drop_table('research_tags') + op.drop_index(op.f('ix_research_state'), table_name='research') + op.drop_table('research') + op.drop_table('pnl_cache') + op.drop_table('job_items') + op.drop_index(op.f('ix_sync_jobs_status'), table_name='sync_jobs') + op.drop_table('sync_jobs') + op.drop_index(op.f('ix_login_sessions_expires_at'), table_name='login_sessions') + op.drop_table('login_sessions') + op.drop_index(op.f('ix_alphas_status'), table_name='alphas') + op.drop_index(op.f('ix_alphas_stage'), table_name='alphas') + op.drop_index(op.f('ix_alphas_sharpe'), table_name='alphas') + op.drop_index('ix_alphas_region_status', table_name='alphas') + op.drop_index(op.f('ix_alphas_region'), table_name='alphas') + op.drop_index(op.f('ix_alphas_fitness'), table_name='alphas') + op.drop_index(op.f('ix_alphas_date_created'), table_name='alphas') + op.drop_index(op.f('ix_alphas_alpha_type'), table_name='alphas') + op.drop_table('alphas') + op.drop_table('admins') + op.drop_table('accounts') + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..38c1530 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "wq-alpha-system" +version = "0.1.0" +description = "Personal WorldQuant Alpha research workspace" +requires-python = ">=3.12,<3.13" +dependencies = [ + "fastapi>=0.115,<1", "uvicorn[standard]>=0.34,<1", + "httpx>=0.28,<1", "sqlalchemy[asyncio]>=2.0.38,<2.1", + "asyncpg>=0.30,<1", "alembic>=1.15,<2", + "pydantic-settings>=2.8,<3", "cryptography>=44,<50", "argon2-cffi>=23.1,<26" +] + +[dependency-groups] +dev = ["pytest>=8,<10", "pytest-asyncio>=0.25,<2", "aiosqlite>=0.21,<1", "ruff>=0.11,<1"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +target-version = "py312" +line-length = 110 + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = ["E501"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/browser_server.py b/backend/tests/browser_server.py new file mode 100644 index 0000000..bba4c00 --- /dev/null +++ b/backend/tests/browser_server.py @@ -0,0 +1,135 @@ +"""Isolated browser acceptance server: temporary database, synthetic upstream, no real network.""" + +import tempfile +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone + +import httpx +from cryptography.fernet import Fernet + +from app.config import Settings +from app.main import create_app +from app.models import Base +from app.worldquant import WqClient + +TEST_PASSWORD = "browser-test-password" + + +def sample(index): + super_alpha = index % 7 == 0 + language = "PYTHON" if index % 11 == 0 and not super_alpha else "FASTEXPR" + names = [ + "短期价格反转", + "成交量与价格背离", + "分析师预期修正", + "行业中性动量", + "低波动质量信号", + "资金流向偏离", + "组合选择实验", + ] + return { + "id": f"TEST{index:04}", + "name": names[index % len(names)] + f" · {index:03}", + "type": "SUPER" if super_alpha else "REGULAR", + "stage": "OS" if index % 3 == 0 else "IS", + "status": "ACTIVE" if index % 3 == 0 else "UNSUBMITTED", + "hidden": index % 5 == 0, + "regular": None + if super_alpha + else { + "code": "def alpha(data):\n return -data.close.rank()" + if language == "PYTHON" + else "group_neutralize(-rank(ts_delta(close, 5)), industry)" + }, + "selection": {"code": "self_correlation < 0.5"} if super_alpha else None, + "combo": {"code": "alpha"} if super_alpha else None, + "settings": { + "region": ["USA", "CHN", "EUR"][index % 3], + "universe": "TOP3000", + "language": language, + "delay": 1, + "decay": 4, + "neutralization": "INDUSTRY", + "truncation": 0.08, + "pasteurization": "ON", + }, + "is": { + "sharpe": None if index % 13 == 0 else round(0.8 + (index % 25) * 0.1, 2), + "fitness": round(0.3 + (index % 20) * 0.06, 2), + "turnover": round(0.08 + (index % 12) * 0.015, 3), + "returns": 0.09, + "margin": 0.0002, + "drawdown": 0.11, + "checks": [{"name": "LOW_SHARPE", "result": "PASS", "value": 2.1, "limit": 1.58}], + }, + "os": {"sharpe": 1.1} if index % 3 == 0 else None, + "dateCreated": (datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(days=index)).isoformat(), + } + + +def create_test_app(): + temporary = tempfile.TemporaryDirectory(prefix="wq-browser-test-") + settings = Settings( + _env_file=None, + database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db", + admin_password=TEST_PASSWORD, + encryption_key=Fernet.generate_key().decode(), + public_origin="http://127.0.0.1:5179", + ) + records = [sample(i) for i in range(620)] + + def upstream(request): + path = request.url.path + if path == "/authentication" and request.method == "POST": + return httpx.Response( + 201, json={"user": {"id": "TEST_USER"}}, headers={"Set-Cookie": "mock=only; Path=/"} + ) + if request.method != "GET": + raise AssertionError("Browser acceptance attempted an upstream mutation") + if path == "/users/self": + return httpx.Response( + 200, json={"id": "TEST_USER", "name": "模拟研究员", "email": "test@example.com"} + ) + if path == "/users/self/alphas": + unsubmitted = "status" in request.url.params + hidden = request.url.params.get("hidden") == "true" + matched = [ + r for r in records if (r["status"] == "UNSUBMITTED") == unsubmitted and r["hidden"] == hidden + ] + offset, limit = ( + int(request.url.params.get("offset", 0)), + int(request.url.params.get("limit", 100)), + ) + return httpx.Response( + 200, json={"count": len(matched), "results": matched[offset : offset + limit]} + ) + if path.endswith("/recordsets/pnl"): + return httpx.Response( + 200, + json={ + "schema": {"properties": [{"name": "date"}, {"name": "pnl"}]}, + "records": [ + [ + (datetime(2025, 1, 1) + timedelta(days=i)).date().isoformat(), + i * 250 + (i % 13) * 85, + ] + for i in range(180) + ], + }, + ) + record = next((r for r in records if path == f"/alphas/{r['id']}"), None) + return httpx.Response(200, json=record) if record else httpx.Response(404) + + application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream))) + original_lifespan = application.router.lifespan_context + + @asynccontextmanager + async def lifespan(app): + async with app.state.engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + async with original_lifespan(app): + yield + temporary.cleanup() + + application.router.lifespan_context = lifespan + return application diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1e51fec --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,71 @@ +import httpx +import pytest +from cryptography.fernet import Fernet + +from app.config import Settings +from app.main import create_app +from app.models import Base +from app.worldquant import WqClient + +ADMIN_PASSWORD = "test-admin-password-only" + + +@pytest.fixture +def settings(tmp_path): + return Settings( + _env_file=None, + database_url=f"sqlite+aiosqlite:///{tmp_path}/test.db", + admin_password=ADMIN_PASSWORD, + encryption_key=Fernet.generate_key().decode(), + enable_runner=False, + public_origin="http://testserver", + ) + + +@pytest.fixture +async def app(settings): + def unexpected_request(request): + raise AssertionError("Unit tests must never access the real platform") + + application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(unexpected_request))) + async with application.state.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with application.router.lifespan_context(application): + yield application + + +@pytest.fixture +async def client(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"} + ) as client: + yield client + + +@pytest.fixture +async def logged_in(client): + response = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}) + assert response.status_code == 200 + return client + + +def alpha(alpha_id="alpha1", **changes): + raw = { + "id": alpha_id, + "name": "Reversal", + "type": "REGULAR", + "stage": "IS", + "status": "UNSUBMITTED", + "hidden": False, + "regular": {"code": "-rank(ts_delta(close, 5))"}, + "settings": {"region": "USA", "universe": "TOP3000", "language": "FASTEXPR", "delay": 1}, + "is": { + "sharpe": 1.5, + "fitness": 0.8, + "turnover": 0.2, + "returns": 0.04, + "checks": [{"name": "LOW_SHARPE", "result": "FAIL", "value": 1.5, "limit": 1.58}], + }, + "dateCreated": "2025-01-01T00:00:00Z", + } + return {**raw, **changes} diff --git a/backend/tests/docker_acceptance.py b/backend/tests/docker_acceptance.py new file mode 100644 index 0000000..3d989ac --- /dev/null +++ b/backend/tests/docker_acceptance.py @@ -0,0 +1,198 @@ +"""Run against an isolated acceptance Compose project; never use a personal data stack. + +From the repository root: + python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env +Requires images already built and services started with -p wq-alpha-acceptance. +""" + +import argparse +import http.cookiejar +import json +import os +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--env-file", required=True, type=Path) + parser.add_argument("--project", default="wq-alpha-acceptance") + args = parser.parse_args() + if not args.project.startswith("wq-alpha-acceptance"): + raise SystemExit("Only an isolated wq-alpha-acceptance* project is allowed") + values = dict( + line.split("=", 1) + for line in args.env_file.read_text().splitlines() + if "=" in line and not line.startswith("#") + ) + compose = ["docker", "compose", "--env-file", str(args.env_file), "-p", args.project] + + def run(arguments, data=None): + result = subprocess.run(compose + arguments, input=data, capture_output=True, check=True) + return result.stdout + + # Assert that we are addressing the isolated project rather than an unrelated localhost app. + published = run(["port", "web", "80"]).decode().strip() + assert published == f"127.0.0.1:{values.get('LOCAL_PORT', '8080')}" + base = f"http://localhost:{values.get('LOCAL_PORT', '8080')}" + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()) + ) + + def request(path, method="GET", payload=None): + body = json.dumps(payload).encode() if payload is not None else None + return opener.open( + urllib.request.Request( + base + path, + data=body, + method=method, + headers={"Content-Type": "application/json", "X-WQ-Request": "1", "Origin": base}, + ), + timeout=10, + ) + + assert json.load(request("/api/v1/health")) == {"status": "ok"} + html = request("/") + assert "frame-ancestors 'none'" in html.headers["Content-Security-Policy"] + assert b'
' in html.read() + try: + request("/api/v1/alphas") + raise AssertionError("Unauthenticated data was exposed") + except urllib.error.HTTPError as error: + assert error.code == 401 + json.load( + request( + "/api/v1/auth/login", + "POST", + {"username": values.get("ADMIN_USERNAME", "admin"), "password": values["ADMIN_PASSWORD"]}, + ) + ) + code = b"""import asyncio +from app.config import Settings +from app.db import create_database +from app.alphas import upsert_alpha +async def seed(): + engine, sessions = create_database(Settings().database_url) + async with sessions() as db: + await upsert_alpha(db, {"id":"DOCKER_ACCEPTANCE", "name":"Synthetic acceptance record", "type":"REGULAR", "stage":"IS", "status":"UNSUBMITTED", "regular":{"code":"rank(close)"}, "settings":{"region":"USA", "language":"FASTEXPR"}, "is":{"sharpe":2.0}}) + await db.commit() + await engine.dispose() +asyncio.run(seed()) +""" + run(["exec", "-T", "backend", "python", "-"], code) + json.load( + request( + "/api/v1/alphas/DOCKER_ACCEPTANCE/research", + "PATCH", + { + "note": "persistent local note", + "tags": ["docker-verified"], + "state": "candidate", + "favorite": True, + }, + ) + ) + print("PASS: PostgreSQL migration, login, API authorization, local research write") + run(["up", "-d", "--force-recreate", "--wait"]) + # A persisted server session and all database rows survive container replacement. + detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE")) + assert detail["research"]["note"] == "persistent local note" + run(["exec", "-T", "backend", "python", "-"], code.replace(b'"sharpe":2.0', b'"sharpe":3.0')) + detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE")) + assert detail["sharpe"] == 3 and detail["research"]["state"] == "candidate" + assert detail["research"]["tags"] == ["docker-verified"] + print("PASS: container replacement preserves session and data; snapshot update preserves research") + dump = run(["exec", "-T", "db", "pg_dump", "-U", "wq", "-d", "wq", "-Fc", "--no-owner"]) + backup = Path(".local/docker-acceptance.dump") + fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as output: + output.write(dump) + # Only this script's scratch restore database is replaced on repeated acceptance runs. + run(["exec", "-T", "db", "dropdb", "-U", "wq", "--if-exists", "wq_acceptance_restore"]) + run(["exec", "-T", "db", "createdb", "-U", "wq", "wq_acceptance_restore"]) + run( + [ + "exec", + "-T", + "db", + "pg_restore", + "-U", + "wq", + "-d", + "wq_acceptance_restore", + "--no-owner", + "--exit-on-error", + ], + dump, + ) + rows = ( + run( + [ + "exec", + "-T", + "db", + "psql", + "-U", + "wq", + "-d", + "wq_acceptance_restore", + "-At", + "-c", + "SELECT note || '|' || state FROM research WHERE alpha_id = 'DOCKER_ACCEPTANCE'", + ] + ) + .decode() + .strip() + ) + assert rows == "persistent local note|candidate" + print("PASS: PostgreSQL custom-format backup restores records into independent database") + config = json.loads(run(["-f", "compose.public.yaml", "config", "--format", "json"])) + assert config["services"]["backend"]["environment"]["COOKIE_SECURE"] == "true" + assert config["services"]["backend"]["environment"]["PUBLIC_ORIGIN"].startswith("https://") + assert "ports" not in config["services"]["db"] and "ports" not in config["services"]["backend"] + run( + [ + "exec", + "-T", + "web", + "caddy", + "validate", + "--config", + "/etc/caddy/Caddyfile", + "--adapter", + "caddyfile", + ] + ) + print("PASS: Caddy configuration and public HTTPS/Secure-cookie configuration checks") + # Keep the backend process alive while its database disappears and returns. + run(["stop", "db"]) + run(["up", "-d", "--wait", "db"]) + probe = b"""import asyncio +from app.config import Settings +from app.db import create_database +from app.jobs import create_job +async def enqueue(): + engine, sessions = create_database(Settings().database_url) + async with sessions() as db: + job = await create_job(db, "profile") + print(job.id) + await engine.dispose() +asyncio.run(enqueue()) +""" + job_id = run(["exec", "-T", "backend", "python", "-"], probe).decode().strip() + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + status = json.load(request(f"/api/v1/sync-jobs/{job_id}"))["status"] + if status == "waiting_connection": + break + time.sleep(0.25) + else: + raise AssertionError("Scheduler did not resume after database restart") + print("PASS: live backend resumes task processing after database stop/start") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..9c242c1 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,232 @@ +import csv +import io +from datetime import timedelta + +import pytest +from sqlalchemy import select + +from app.alphas import upsert_alpha +from app.models import Account, Admin, LoginSession, Research, now +from app.security import cipher +from tests.conftest import ADMIN_PASSWORD, alpha + +PREFIX = "/api/v1" + + +async def seed(app, count=1): + async with app.state.sessions() as db: + for i in range(count): + await upsert_alpha(db, alpha(f"a{i:04}", name=f"Signal {i:04}", **{"is": {"sharpe": i / 100}})) + await db.commit() + + +async def test_login_logout_expiry_and_password_hash(app, client): + assert (await client.get(f"{PREFIX}/alphas")).status_code == 401 + wrong = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"}) + assert wrong.status_code == 401 + login = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}) + assert login.status_code == 200 + assert "HttpOnly" in login.headers["set-cookie"] and "SameSite=strict" in login.headers["set-cookie"] + token = client.cookies.get("wq_session") + async with app.state.sessions() as db: + admin = await db.get(Admin, 1) + session = (await db.scalars(select(LoginSession))).one() + assert admin.password_hash.startswith("$argon2id$") and ADMIN_PASSWORD not in admin.password_hash + assert session.token_hash != token + assert (await client.post(f"{PREFIX}/auth/logout")).status_code == 200 + client.cookies.set("wq_session", token) + assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401 + await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}) + async with app.state.sessions() as db: + session = (await db.scalars(select(LoginSession))).one() + session.expires_at = now() - timedelta(seconds=1) + await db.commit() + assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401 + + +async def test_login_rate_limit_and_csrf(client): + for _ in range(5): + assert ( + await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"}) + ).status_code == 401 + assert ( + await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}) + ).status_code == 429 + assert ( + await client.post(f"{PREFIX}/auth/login", headers={"X-WQ-Request": ""}, json={}) + ).status_code == 403 + assert ( + await client.post(f"{PREFIX}/auth/login", headers={"Origin": "https://evil.example"}, json={}) + ).status_code == 403 + + +async def test_public_session_cookie_is_secure(app, client): + app.state.settings.cookie_secure = True + response = await client.post( + f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD} + ) + assert response.status_code == 200 + assert "; Secure" in response.headers["set-cookie"] and "; HttpOnly" in response.headers["set-cookie"] + + +async def test_credentials_encrypted_and_never_returned(app, logged_in): + secret = "private-worldquant-password" + r = await logged_in.put( + f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": secret} + ) + assert r.status_code == 200 and r.json()["configured"] is True + assert "password" not in r.text and secret not in r.text + async with app.state.sessions() as db: + account = await db.get(Account, 1) + assert secret not in account.password_encrypted + assert cipher(app.state.settings).decrypt(account.password_encrypted.encode()).decode() == secret + account.wq_user_id = "person" + await db.commit() + assert ( + await logged_in.put( + f"{PREFIX}/account/credentials", json={"email": "other@example.com", "password": secret} + ) + ).status_code == 409 + invalid = await logged_in.put( + f"{PREFIX}/account/credentials", json={"email": "invalid", "password": secret} + ) + assert invalid.status_code == 422 and secret not in invalid.text + response = await logged_in.get(f"{PREFIX}/account") + assert response.json()["profile"] == {} and "password" not in response.text + + +async def test_preferences_and_openapi(app, logged_in): + values = {"display_name": "My Lab", "theme": "dark", "timezone": "UTC", "page_size": 100} + r = await logged_in.patch(f"{PREFIX}/account/preferences", json=values) + assert r.status_code == 200 and all(r.json()[k] == v for k, v in values.items()) + values["timezone"] = "invalid/timezone" + assert (await logged_in.patch(f"{PREFIX}/account/preferences", json=values)).status_code == 422 + contract = app.openapi() + assert contract["paths"]["/api/v1/alphas"]["get"]["security"] == [{"APIKeyCookie": []}] + assert "text/csv" in contract["paths"]["/api/v1/alphas/export"]["get"]["responses"]["200"]["content"] + assert contract["paths"]["/api/v1/alphas"]["get"]["responses"]["422"]["content"]["application/json"][ + "schema" + ]["$ref"].endswith("/ErrorOutput") + + +async def test_local_research_survives_snapshot_and_bulk_is_atomic(app, logged_in): + await seed(app, 3) + r = await logged_in.patch( + f"{PREFIX}/alphas/a0000/research", + json={"note": "keep hypothesis", "tags": ["a", "a"], "state": "candidate", "favorite": True}, + ) + assert r.status_code == 200 + async with app.state.sessions() as db: + await upsert_alpha(db, alpha("a0000", **{"is": {"sharpe": 9.25}})) + await db.commit() + detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json() + assert detail["sharpe"] == 9.25 and detail["fitness"] is None + assert detail["research"]["note"] == "keep hypothesis" and detail["research"]["favorite"] + assert detail["research"]["tags"] == ["a"] and detail["research"]["state"] == "candidate" + invalid = await logged_in.patch( + f"{PREFIX}/alphas/research/bulk", json={"alpha_ids": ["a0000", "missing"], "add_tags": ["bad"]} + ) + assert invalid.status_code == 404 + assert (await logged_in.get(f"{PREFIX}/alphas?tag=bad")).json()["total"] == 0 + r = await logged_in.patch( + f"{PREFIX}/alphas/research/bulk", + json={ + "alpha_ids": ["a0000", "a0001"], + "add_tags": ["new"], + "remove_tags": ["a"], + "state": "optimizing", + }, + ) + assert r.json()["updated"] == 2 + result = (await logged_in.get(f"{PREFIX}/alphas?tag=new&research_state=optimizing")).json() + assert result["total"] == 2 + assert (await logged_in.get(f"{PREFIX}/alphas?tag=ne")).json()["total"] == 0 + await logged_in.patch(f"{PREFIX}/alphas/a0000/research", json={"note": "updated"}) + detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json() + assert detail["research"]["favorite"] and detail["research"]["tags"] == ["new"] + + +async def test_filter_pagination_sort_and_export_over_500(app, logged_in): + await seed(app, 603) + query = "region=USA&sharpe_min=0.02&sharpe_max=6&sort=sharpe&direction=desc" + page = (await logged_in.get(f"{PREFIX}/alphas?{query}&offset=10&limit=25")).json() + assert page["total"] == 599 and len(page["items"]) == 25 + assert page["items"][0]["id"] == "a0590" + exported = await logged_in.get(f"{PREFIX}/alphas/export?{query}&offset=10&limit=25") + rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff")))) + assert len(rows) == 599 and rows[0]["id"] == "a0600" and rows[-1]["id"] == "a0002" + assert (await logged_in.get(f"{PREFIX}/alphas?q=Signal%200001")).json()["total"] == 1 + assert (await logged_in.get(f"{PREFIX}/alphas?q=%25")).json()["total"] == 0 + for query in ( + "sort=raw", + "sharpe_min=nan", + "sharpe_min=inf", + "sharpe_min=5&sharpe_max=2", + "limit=1000", + "created_from=2026-01-01&created_to=2025-01-01T00:00:00Z", + ): + assert (await logged_in.get(f"{PREFIX}/alphas?{query}")).status_code == 422 + + +async def test_export_formula_injection_and_detail_variants(app, logged_in): + async with app.state.sessions() as db: + await upsert_alpha( + db, + alpha( + "super1", + name="=DANGEROUS()", + type="SUPER", + regular=None, + selection={"code": "self_correlation < 0.5"}, + combo={"code": "alpha"}, + settings={"language": "FASTEXPR"}, + **{"is": {"sharpe": None, "fitness": float("nan")}}, + ), + ) + await upsert_alpha( + db, + alpha( + "python1", regular={"code": "def alpha():\n return close"}, settings={"language": "PYTHON"} + ), + ) + await db.commit() + row = await db.get(Research, "super1") + row.note = " @formula()" + await db.commit() + detail = (await logged_in.get(f"{PREFIX}/alphas/super1")).json() + assert detail["selection"] == "self_correlation < 0.5" and detail["combo"] == "alpha" + assert detail["expression"] is None and detail["sharpe"] is None and detail["fitness"] is None + assert (await logged_in.get(f"{PREFIX}/alphas/python1")).json()["language"] == "PYTHON" + csv_data = (await logged_in.get(f"{PREFIX}/alphas/export?q=super1")).text + row = list(csv.DictReader(io.StringIO(csv_data.lstrip("\ufeff"))))[0] + assert row["name"] == "'=DANGEROUS()" and row["note"] == "' @formula()" + assert (await logged_in.get(f"{PREFIX}/alphas/super1/pnl")).json() == { + "cached": False, + "points": [], + "fetched_at": None, + } + + +async def test_job_api_deduplicates_and_retries(app, logged_in): + assert (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).status_code == 409 + await logged_in.put( + f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": "only-testing"} + ) + connect = await logged_in.post(f"{PREFIX}/account/connect") + assert connect.status_code == 202 + first = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json() + again = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json() + assert first["id"] == again["id"] + await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/cancel") + assert (await logged_in.get(f"{PREFIX}/sync-jobs/{first['id']}")).json()["status"] == "cancelled" + assert (await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/retry")).json()["status"] == "queued" + assert ( + await logged_in.post( + f"{PREFIX}/sync-jobs", json={"kind": "alpha_refresh", "alpha_ids": ["../authentication"]} + ) + ).status_code == 422 + + +@pytest.mark.parametrize("endpoint", ["account", "alphas", "alphas/export", "alphas/a1/pnl", "sync-jobs"]) +async def test_every_data_endpoint_requires_session(client, endpoint): + assert (await client.get(f"{PREFIX}/{endpoint}")).status_code == 401 diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py new file mode 100644 index 0000000..a429471 --- /dev/null +++ b/backend/tests/test_jobs.py @@ -0,0 +1,251 @@ +import asyncio + +from sqlalchemy import func, select +from sqlalchemy.exc import OperationalError + +from app.jobs import Runner, create_job +from app.models import Account, Alpha, Job, JobItem, Pnl, Research +from app.security import cipher +from app.worldquant import VerificationRequired, WqError +from tests.conftest import alpha + + +class FakePlatform: + def __init__(self): + self.verification_url = None + self.on_retry = None + self.page_calls = [] + self.fail_page = True + self.fail_id = True + self.require_verification = False + self.block = None + + async def authenticate(self, *args, **kwargs): + if self.require_verification: + self.verification_url = "https://api.worldquantbrain.com/authentication/persona/test" + raise VerificationRequired(self.verification_url) + + async def verify(self): + self.require_verification = False + self.verification_url = None + + async def profile(self): + return {"id": "user1", "email": "test@example.com", "password": "never-save", "unknown": 1} + + async def alphas(self, submission, hidden, offset, before): + self.page_calls.append((submission, hidden, offset, before)) + if self.block: + self.block.set() + await asyncio.Future() + if submission == "UNSUBMITTED" and not hidden: + if offset == 0: + return {"results": [alpha("a1"), alpha("a2")], "count": 4} + if self.fail_page: + raise WqError("模拟第二页网络故障", "network_error") + return {"results": [alpha("a2", **{"is": {"sharpe": 7.0}}), alpha("a3")], "count": 4} + if submission == "SUBMITTED" and hidden: + return {"results": [alpha("hidden1", hidden=True, stage="OS", status="ACTIVE")], "next": None} + return {"results": [], "next": None} + + async def alpha(self, alpha_id): + if alpha_id == "bad" and self.fail_id: + raise WqError("模拟无权访问", "access_denied") + return alpha(alpha_id, **{"is": {"sharpe": 8.0}}) + + async def pnl(self, alpha_id): + return { + "schema": {"properties": [{"name": "date"}, {"name": "pnl"}]}, + "records": [["2025-01-01", 100]], + } + + def disconnect(self): + self.verification_url = None + + async def close(self): + pass + + +async def ready_runner(app): + async with app.state.sessions() as db: + account = await db.get(Account, 1) + account.email = "test@example.com" + account.password_encrypted = cipher(app.state.settings).encrypt(b"test-password").decode() + account.connection_status = "connected" + await db.commit() + return Runner(app.state.sessions, app.state.settings, FakePlatform()) + + +async def job_for(runner, kind="full_sync", ids=None): + async with runner.sessions() as db: + return (await create_job(db, kind, {"alpha_ids": ids or []})).id + + +async def result(runner, job_id): + async with runner.sessions() as db: + return await db.get(Job, job_id) + + +async def test_pages_commit_resume_dedupe_and_preserve_research(app): + runner = await ready_runner(app) + job_id = await job_for(runner) + await runner.execute(job_id) + failed = await result(runner, job_id) + assert failed.status == "failed" and failed.processed == 2 + assert failed.checkpoint == {"partition": 0, "offset": 2} + async with runner.sessions() as db: + research = await db.get(Research, "a2") + research.note, research.tags, research.state = "keep me", ["keep"], "candidate" + await db.commit() + runner.client.fail_page = False + runner.client.page_calls.clear() + await runner.execute(job_id) + finished = await result(runner, job_id) + assert finished.status == "completed" and finished.processed == 4 and finished.total == 4 + assert runner.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2) + assert len({call[3] for call in runner.client.page_calls}) == 1 + async with runner.sessions() as db: + assert await db.scalar(select(func.count()).select_from(Alpha)) == 4 + assert (await db.get(Alpha, "a2")).sharpe == 7.0 + assert (await db.get(Alpha, "hidden1")).hidden is True + research = await db.get(Research, "a2") + assert (research.note, research.tags, research.state) == ("keep me", ["keep"], "candidate") + assert await db.scalar(select(func.count()).select_from(JobItem)) == 4 + # A later full scan updates existing IDs, without deleting unseen local records. + second = await job_for(runner) + await runner.execute(second) + assert (await result(runner, second)).processed == 4 + + +async def test_partial_ids_retry_only_errors_and_pnl_cache(app): + runner = await ready_runner(app) + job_id = await job_for(runner, "alpha_refresh", ["good", "bad"]) + await runner.execute(job_id) + partial = await result(runner, job_id) + assert (partial.status, partial.processed, partial.failed, partial.total) == ( + "completed_with_errors", + 1, + 1, + 2, + ) + runner.client.fail_id = False + await runner.execute(job_id) + complete = await result(runner, job_id) + assert (complete.status, complete.processed, complete.failed) == ("completed", 2, 0) + pnl_job = await job_for(runner, "pnl_refresh", ["good", "missing"]) + await runner.execute(pnl_job) + assert (await result(runner, pnl_job)).failed == 1 + async with runner.sessions() as db: + pnl = await db.get(Pnl, "good") + assert pnl.points == [{"date": "2025-01-01", "value": 100.0}] + + +async def test_human_verification_waits_then_resumes_pending_jobs(app): + runner = await ready_runner(app) + runner.client.require_verification = True + job_id = await job_for(runner, "alpha_refresh", ["good"]) + await runner.execute(job_id) + assert (await result(runner, job_id)).status == "waiting_auth" + async with runner.sessions() as db: + account = await db.get(Account, 1) + assert account.connection_status == "verification_required" and account.verification_url + verification = await job_for(runner, "verify") + await runner.execute(verification) + assert (await result(runner, verification)).status == "completed" + assert (await result(runner, job_id)).status == "queued" + async with runner.sessions() as db: + account = await db.get(Account, 1) + assert ( + account.wq_user_id == "user1" + and "password" not in account.profile + and "unknown" not in account.profile + ) + await runner.execute(job_id) + assert (await result(runner, job_id)).status == "completed" + + +async def wait_status(runner, job_id, expected): + async with asyncio.timeout(5): + while (await result(runner, job_id)).status not in expected: + await asyncio.sleep(0.02) + + +async def test_user_cancel_interrupts_request_and_disconnect_pauses(app): + runner = await ready_runner(app) + runner.client.block = asyncio.Event() + job_id = await job_for(runner) + await runner.start() + await asyncio.wait_for(runner.client.block.wait(), 3) + async with runner.sessions() as db: + job = await db.get(Job, job_id) + job.cancel_requested = True + await db.commit() + await runner.cancel(job_id) + await wait_status(runner, job_id, {"cancelled"}) + runner.client.block.clear() + pending = await job_for(runner) + runner.wake.set() + await asyncio.wait_for(runner.client.block.wait(), 3) + await runner.disconnect() + assert (await result(runner, pending)).status == "waiting_connection" + async with runner.sessions() as db: + assert (await db.get(Account, 1)).connection_status == "disconnected" + await runner.stop() + + +async def test_restart_recovers_running_job_and_keeps_checkpoint(app): + runner = await ready_runner(app) + runner.client.block = asyncio.Event() + job_id = await job_for(runner) + await runner.start() + await asyncio.wait_for(runner.client.block.wait(), 3) + await runner.stop() + assert (await result(runner, job_id)).status == "queued" + # Simulate abrupt process loss after a committed page, before the running flag was cleared. + async with runner.sessions() as db: + job = await db.get(Job, job_id) + job.status, job.checkpoint = "running", {"partition": 0, "offset": 2} + await db.commit() + resumed = Runner(runner.sessions, runner.settings, FakePlatform()) + resumed.client.fail_page = False + await resumed.start() + await wait_status(resumed, job_id, {"completed", "failed"}) + await resumed.stop() + assert (await result(resumed, job_id)).status == "completed" + assert resumed.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2) + + +async def test_identity_change_fails_without_overwriting_profile(app): + runner = await ready_runner(app) + async with runner.sessions() as db: + account = await db.get(Account, 1) + account.wq_user_id = "original-user" + await db.commit() + job_id = await job_for(runner, "connect") + await runner.execute(job_id) + assert (await result(runner, job_id)).status == "waiting_connection" + async with runner.sessions() as db: + assert (await db.get(Account, 1)).wq_user_id == "original-user" + + +async def test_scheduler_recovers_after_database_outage(app): + runner = await ready_runner(app) + job_id = await job_for(runner, "alpha_refresh", ["good"]) + original = runner.run_next + attempts = 0 + + async def failing_once(): + nonlocal attempts + attempts += 1 + if attempts == 1: + async with runner.sessions() as db: + job = await db.get(Job, job_id) + job.status = "running" + await db.commit() + raise OperationalError("", {}, Exception("simulated database restart")) + await original() + + runner.run_next = failing_once + await runner.start() + await wait_status(runner, job_id, {"completed", "failed"}) + await runner.stop() + assert attempts >= 2 and (await result(runner, job_id)).status == "completed" diff --git a/backend/tests/test_worldquant.py b/backend/tests/test_worldquant.py new file mode 100644 index 0000000..57acd69 --- /dev/null +++ b/backend/tests/test_worldquant.py @@ -0,0 +1,186 @@ +import asyncio +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime + +import httpx +import pytest + +from app.alphas import pnl_points, sanitize +from app.worldquant import VerificationRequired, WqClient, WqError + + +async def test_cookie_auth_expiry_and_read_only_boundary(settings): + calls, auth_count = [], 0 + + def handler(request): + nonlocal auth_count + calls.append((request.method, request.url.path)) + if request.url.path == "/authentication": + auth_count += 1 + return httpx.Response(201, headers={"Set-Cookie": f"t=session{auth_count}; Path=/"}, json={}) + assert "Authorization" not in request.headers + if request.headers.get("Cookie") == "t=session1": + return httpx.Response(401) + return httpx.Response(200, json={"id": "user1"}) + + client = WqClient(settings, transport=httpx.MockTransport(handler)) + await client.authenticate("person@example.com", "secret") + assert await client.profile() == {"id": "user1"} + assert auth_count == 2 + await asyncio.gather(*(client.authenticate("person@example.com", "secret") for _ in range(5))) + assert auth_count == 2 + assert all(method == "GET" or path == "/authentication" for method, path in calls) + client.disconnect() + assert client.credentials is None and not list(client.client.cookies.items()) + await client.close() + + +async def test_persona_verification_uses_same_cookie_and_safe_location(settings): + complete = False + + def handler(request): + if request.url.path == "/authentication": + return httpx.Response( + 401, + headers={ + "WWW-Authenticate": "persona", + "Location": "/authentication/persona/challenge", + "Set-Cookie": "challenge=abc; Path=/", + }, + ) + assert request.headers.get("Cookie") == "challenge=abc" + return httpx.Response(201 if complete else 202, json={}) + + client = WqClient(settings, transport=httpx.MockTransport(handler)) + with pytest.raises(VerificationRequired) as error: + await client.authenticate("test@example.com", "secret") + assert error.value.url.endswith("/authentication/persona/challenge") + with pytest.raises(VerificationRequired): + await client.verify() + complete = True + await client.verify() + assert client.authenticated and client.verification_url is None + await client.close() + unsafe = WqClient( + settings, + transport=httpx.MockTransport( + lambda r: httpx.Response( + 401, headers={"WWW-Authenticate": "persona", "Location": "https://evil.example/secret"} + ) + ), + ) + with pytest.raises(WqError) as error: + await unsafe.authenticate("test@example.com", "secret") + assert error.value.code == "invalid_verification" + await unsafe.close() + + +async def test_retry_after_network_budget_pending_and_permissions(settings): + delays, requests = [], 0 + + async def sleep(delay): + delays.append(delay) + + def handler(request): + nonlocal requests + requests += 1 + if requests == 1: + return httpx.Response(429, headers={"Retry-After": "120"}) + if requests == 2: + raise httpx.ConnectError("contains private body", request=request) + return httpx.Response(200, json={"id": "ready"}) + + client = WqClient(settings, transport=httpx.MockTransport(handler), sleep=sleep) + client.credentials, client.authenticated = ("test@example.com", "secret"), True + assert await client.profile() == {"id": "ready"} + assert delays[0] == 120 and len(delays) == 2 + date = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=60), usegmt=True) + assert 58 < client.retry_delay(date, 0) <= 60 + await client.close() + for status, code in [(403, "access_denied"), (404, "not_found"), (503, "retry_exhausted")]: + c = WqClient( + settings, + transport=httpx.MockTransport(lambda r: httpx.Response(status, text="private data")), + sleep=sleep, + ) + c.credentials, c.authenticated = ("test@example.com", "secret"), True + with pytest.raises(WqError) as error: + await c.profile() + assert error.value.code == code and "private" not in str(error.value) + await c.close() + responses = iter( + [httpx.Response(202, headers={"Retry-After": "1"}), httpx.Response(200, json={"records": []})] + ) + c = WqClient(settings, transport=httpx.MockTransport(lambda r: next(responses)), sleep=sleep) + c.credentials, c.authenticated = ("test@example.com", "secret"), True + assert await c.pnl("abc") == {"records": []} + await c.close() + + +@pytest.mark.parametrize( + "raw,expected", + [ + ( + { + "schema": {"properties": [{"name": "date"}, {"name": "pnl"}]}, + "records": [["2025-01-01", 10], ["2025-01-02", None]], + }, + [{"date": "2025-01-01", "value": 10.0}, {"date": "2025-01-02", "value": None}], + ), + ( + {"schema": {"properties": [{"name": "pnl"}, {"name": "date"}]}, "records": [[20, "2025-01-01"]]}, + [{"date": "2025-01-01", "value": 20.0}], + ), + ( + {"schema": {"properties": {"date": {}, "pnl": {}}}, "records": [["2025-01-01", "NaN"]]}, + [{"date": "2025-01-01", "value": None}], + ), + ( + {"records": [{"timestamp": 1735689600000, "value": "5"}]}, + [{"date": "2025-01-01T00:00:00+00:00", "value": 5.0}], + ), + ], +) +def test_pnl_schemas_preserve_null(raw, expected): + assert pnl_points(raw) == expected + + +def test_unknown_pnl_schema_fails_instead_of_fabricating(): + with pytest.raises(ValueError): + pnl_points({"records": [["2025-01-01", 100]]}) + + +def test_sensitive_response_keys_are_removed_recursively(): + assert sanitize( + { + "id": "a1", + "accessToken": "secret", + "nested": [{"refresh_token": "secret", "client-secret": "secret", "value": 1}], + "Set-Cookie": "secret", + } + ) == {"id": "a1", "nested": [{"value": 1}]} + + +async def test_concurrent_expiry_authenticates_once(settings): + auth_count = 0 + first_requests = 0 + both_expired = asyncio.Event() + + async def handler(request): + nonlocal auth_count, first_requests + if request.method == "POST": + auth_count += 1 + return httpx.Response(201, json={}) + if first_requests < 2: + first_requests += 1 + if first_requests == 2: + both_expired.set() + await both_expired.wait() + return httpx.Response(401) + return httpx.Response(200, json={"id": "user"}) + + client = WqClient(settings, transport=httpx.MockTransport(handler)) + await client.authenticate("test@example.com", "secret") + assert await asyncio.gather(client.profile(), client.profile()) == [{"id": "user"}, {"id": "user"}] + assert auth_count == 2 # Initial connection plus one shared reauthentication. + await client.close() diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..fab700f --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,699 @@ +version = 1 +revision = 2 +requires-python = "==3.12.*" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/10/181eecdd552217d0342492bd6f3b8a96e973083379aace3d3402830ddc03/alembic-1.19.2.tar.gz", hash = "sha256:297950a8a91f6770eb82bfbce9bea55c728b90a5386c6e81430191a319d138b0", size = 2082643, upload-time = "2026-09-04T17:10:11.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2", size = 267399, upload-time = "2026-09-04T17:10:12.796Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +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-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + +[[package]] +name = "wq-alpha-system" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "aiosqlite" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.15,<2" }, + { name = "argon2-cffi", specifier = ">=23.1,<26" }, + { name = "asyncpg", specifier = ">=0.30,<1" }, + { name = "cryptography", specifier = ">=44,<50" }, + { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "pydantic-settings", specifier = ">=2.8,<3" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38,<2.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "aiosqlite", specifier = ">=0.21,<1" }, + { name = "pytest", specifier = ">=8,<10" }, + { name = "pytest-asyncio", specifier = ">=0.25,<2" }, + { name = "ruff", specifier = ">=0.11,<1" }, +] diff --git a/compose.public.yaml b/compose.public.yaml new file mode 100644 index 0000000..acf007c --- /dev/null +++ b/compose.public.yaml @@ -0,0 +1,59 @@ +# Standalone public deployment: docker compose -f compose.public.yaml up -d --build +name: wq-alpha +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: wq + POSTGRES_DB: wq + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U wq -d wq"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + backend: + build: + context: . + dockerfile: Dockerfile.backend + environment: + DATABASE_URL: postgresql+asyncpg://wq:${POSTGRES_PASSWORD:?required}@db:5432/wq + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?required} + PUBLIC_ORIGIN: https://${DOMAIN:?Set DOMAIN to your real hostname} + COOKIE_SECURE: "true" + depends_on: + db: + condition: service_healthy + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + restart: unless-stopped + web: + build: + context: . + dockerfile: Dockerfile.frontend + environment: + SITE_ADDRESS: ${DOMAIN:?required} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - caddy_data:/data + - caddy_config:/config + depends_on: + backend: + condition: service_healthy + restart: unless-stopped +volumes: + postgres_data: + caddy_data: + caddy_config: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dbfa497 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,57 @@ +name: wq-alpha +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: wq + POSTGRES_DB: wq + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run scripts/init_env.py first} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U wq -d wq"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + backend: + build: + context: . + dockerfile: Dockerfile.backend + environment: + DATABASE_URL: postgresql+asyncpg://wq:${POSTGRES_PASSWORD:?required}@db:5432/wq + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?required} + PUBLIC_ORIGIN: http://localhost:${LOCAL_PORT:-8080} + COOKIE_SECURE: "false" + WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} + depends_on: + db: + condition: service_healthy + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + restart: unless-stopped + web: + build: + context: . + dockerfile: Dockerfile.frontend + environment: + SITE_ADDRESS: http://:80 + ports: + - "127.0.0.1:${LOCAL_PORT:-8080}:80" + volumes: + - caddy_data:/data + - caddy_config:/config + depends_on: + backend: + condition: service_healthy + restart: unless-stopped +volumes: + postgres_data: + caddy_data: + caddy_config: diff --git a/docs/project-plan.md b/docs/project-plan.md new file mode 100644 index 0000000..6845a81 --- /dev/null +++ b/docs/project-plan.md @@ -0,0 +1,67 @@ +# WorldQuant Alpha 研究系统 + +确认日期:2026-09-07。项目位于 `wq-alpha-system`,面向个人单个 WorldQuant 账户。 + +## 已确认范围 + +- 首期:个人信息与会话、Alpha 列表与详情、本地研究记录、可靠同步、Docker 部署。 +- Python + React + TypeScript + Semi Design,前后端分别位于 backend/ 和 frontend/,独立依赖与测试。 +- PostgreSQL 存储数据,FastAPI 提供 OpenAPI 契约,HTTPX 统一异步调用 WorldQuant。 +- React 19 使用 @douyinfe/semi-ui-19。Caddy 提供静态资源、API 代理与公网 HTTPS。 +- 根目录包含两套完整 Compose、Dockerfile、Caddyfile、环境模板和部署说明。 +- 从 WorldQuant 重新建立数据,不迁移旧库,不承担旧系统的兼容或维护。 +- 平台数据只读;不回测、不触发检查、不修改平台属性、不正式提交。 + +## 首期产品能力 + +### 个人信息 + +系统登录与平台连接分开。单管理员,无开放注册;部署时设置初始密码,服务端哈希保存,HttpOnly Cookie 管理会话。 +在个人页配置平台邮箱及密码,连接、重新连接、人工验证、断开与刷新资料。 +平台凭据加密存储,解密密钥独立配置,不在前端持久化,也不进入日志或响应。 +展示平台 ID、昵称、邮箱、身份与权限(以返回字段为准)、连接状态、最后同步时间。 +提供本地显示名称、主题、时区和分页偏好,默认中文、浅色、Asia/Shanghai、25 条/页。 +缺失资料显示“未提供”,不得虚构数据。 + +### Alpha 管理 + +- 主界面为侧栏、筛选区、可排序分页表格、列显隐、多选、详情抽屉;任务进度在独立面板查看。 +- 全量同步已提交/未提交、隐藏/可见 Alpha;指定 ID 导入及选中刷新;仅手动触发。 +- 筛选 ID/名称/表达式、地区、Universe、类型、语言、平台状态、隐藏、日期、核心指标、本地标签/状态/收藏。 +- 展示 REGULAR/SUPER、FASTEXPR/PYTHON、Selection/Combo、完整 settings、IS/OS、已有 checks。 +- PnL 按需获取、缓存、曲线展示、刷新;可打开 BRAIN 原页面。 +- 本地备注、标签、收藏与研究状态单独保存,同步不能覆盖。研究状态:inbox/candidate/optimizing/archived。 +- 批量加减标签及改研究状态;CSV 按当前筛选与排序导出全部结果,不限制为 500 条。 +- 指标缺失保留 null,不伪装成零;本地研究状态与平台状态、检查结果分开。 + +## 模块与接口 + +模块为账户、Alpha、同步任务、WorldQuant 集成。所有上游认证、会话、分页和退避集中封装。 +页面读取本地数据库。`/api/v1/auth` 管理登录,`/account` 管理配置与资料,`/alphas` 管理查询及研究记录,`/alphas/{id}/pnl` 读取缓存,`/sync-jobs` 创建、查询、取消和重试任务。 +长任务返回 job ID;前端轮询。首期单后端进程运行异步任务,任务及分页检查点持久化。 +每页原子落库、按 Alpha ID 更新、失败重试及重启恢复;429 遵守 Retry-After,其余暂时性错误有界退避。 +原始业务响应与结构化摘要分别保存,不存认证敏感字段。上游数据移动可能影响 offset 分页,通过 ID 去重及再次全量同步校正,不因一次未查到就删除本地记录。 + +## 路线图 + +| 阶段 | 能力 | 依据 | +| --- | --- | --- | +| 一 | 账户、列表、研究记录、同步、部署 | 本文件首期范围 | +| 二 | 数据集/字段/算子、模板、批次队列、AST 校验、实验去重、暂停恢复 | 旧系统采样→密度→深度回测,以及 [回测台账](https://mail.google.com/mail/#all/19ea68a7dde5ceaa) | +| 三 | PnL 稳定性、比较、相关性、稳健性、跨区变体、Super Alpha 组合 | 旧系统有效分析能力 | +| 四 | 假设与实验记录、CLI/MCP、论坛检索、模型接入、预算及停止条件 | [决策摘要](https://mail.google.com/mail/#all/19fc16ce3f17311c)、[可复盘流程](https://mail.google.com/mail/#all/1a00ee69df671d4c) | +| 后续 | 平台回写、检查、提交、顾问表现 | 另行确认业务范围 | + +AI 复用系统接口,不直接写数据库,模型可替换。论坛效果与阈值必须验证后才可成为规则。首期不提前建设后续空模块。 + +## 验收 + +1. 登录成功/失败、退出、越权防护;凭据不泄露,公网 HTTPS。 +2. 平台连接、过期、人工验证、拒绝及网络错误。 +3. 多页、重复、更新、429、部分失败、取消与重启恢复;同步保留本地研究记录。 +4. 各 Alpha/语言类型、缺失指标和不同 PnL schema。 +5. 筛选、分页、排序、批量编辑和超 500 条 CSV 导出一致。 +6. Docker 空环境启动、重建持久化、备份恢复。 +7. 模拟上游自动化测试后,使用真实账户只读联调;未验证部分如实记录。 + +完成标准:空部署后连接个人账户、同步 Alpha、查询和查看详情、维护本地研究记录,重启与再次同步后数据仍然可靠。 diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..f19ae4c --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,56 @@ +# 首期验收记录 + +日期:2026-09-07。验证范围为本地实现、模拟 WorldQuant 上游、实际 PostgreSQL/Docker。未访问真实 WorldQuant 账户,没有调用平台回测、检查、属性修改或提交接口。 + +## 自动化与运行实测 + +| 检查 | 结果与证据 | +| --- | --- | +| 后端静态检查 | `uv run ruff check app tests` 通过 | +| 后端自动化测试 | `uv run pytest -q`:31 项通过 | +| 前端类型与生产构建 | `pnpm build` 通过;React 19.2.8、Semi UI 19 2.103.0,含 React 19 adapter | +| 浏览器验收 | `pnpm test`:2 项端到端测试通过,使用 620 条合成记录 | +| 空库部署与迁移 | Docker `web + backend + db` 从新卷启动成功,`alembic upgrade head` 成功 | +| 迁移与模型一致性 | PostgreSQL 上 `alembic check` 返回 `No new upgrade operations detected` | +| 持久化 | 强制替换三个容器后,服务端登录会话、Alpha、备注、标签与研究状态保留 | +| 备份恢复 | `pg_dump -Fc` 备份恢复到独立数据库,研究记录内容一致 | +| 数据库中断 | 后端不重启,单独停止/启动 PostgreSQL 后仍能消费新任务 | +| 公网配置 | 独立 Compose 正确配置 HTTPS Origin、Secure Cookie;db/backend 无宿主机端口;Caddy 配置校验通过 | +| 本机隔离 | Web 仅绑定 `127.0.0.1:8080`,数据库与后端只有容器网络端口 | + +Docker 运行检查脚本为 `backend/tests/docker_acceptance.py`,仅允许操作独立 `wq-alpha-acceptance*` 项目。测试容器与测试卷已清理;合成数据不进入正式本机实例。 + +正式本机项目 `wq-alpha` 已启动于 `http://localhost:8080`,空库登录/退出、迁移一致性和健康检查通过;Alpha 与任务表初始均为 0,WorldQuant 尚未配置。初始凭据保存在权限为 `0600` 的项目 `.env` 中。 + +## 已覆盖行为 + +- 系统正确/错误登录、速率限制、未授权访问、退出失效、Cookie 到期、HttpOnly/SameSite/Secure、请求来源校验。 +- WorldQuant 密码密文保存、不返回认证信息;嵌套 raw 业务数据过滤常见 token/cookie/secret 字段。 +- 平台认证、Cookie 失效后共享重新认证、人工验证保留同一挑战会话、拒绝访问、网络错误、429/5xx、Retry-After 与数据准备等待。 +- 多页同步、重叠 ID 去重、已有指标更新、每页检查点、部分失败/重试、取消、断开暂停、进程恢复及数据库恢复。 +- 平台快照更新后,本地备注、标签、收藏、研究状态不变。固定状态与平台状态分别展示。 +- REGULAR/SUPER、FASTEXPR/PYTHON、Selection/Combo、完整 settings、IS/OS、已有检查结果、缺失指标为 null。 +- 不同 PnL 列顺序、对象型 schema、对象记录、毫秒时间戳、空值保留、未知 schema 报错;浏览器实际获取并绘制缓存曲线。 +- 筛选、排序、分页与导出共用查询逻辑;后端筛选导出 599 条、浏览器下载 620 条,均不受列表页大小限制;CSV 文本公式转义。 +- 批量标签、列显隐、固定列、分页、个人偏好保存与页面刷新保留;同步后台更新不会覆盖详情里尚未保存的备注。 +- OpenAPI 记录 Cookie 鉴权和响应类型,包括错误、CSV、任务与 PnL。 + +## 页面核验 + +通过浏览器实际检查登录页、Alpha 列表、详情/PnL、个人信息页。截图在忽略目录 `output/playwright/`,截图中的 `TEST` Alpha、测试邮箱和统计数均为合成数据。生产 Web 使用 Caddy 提供构建后的静态文件。 + +构建仍有 Semi 间接依赖 `lottie-web` 的 `eval` 提示,构建成功;当前页面不使用该表达式动画能力,未放宽生产 CSP 的 `script-src`。端到端测试未发现 JavaScript 运行异常。 + +## 待真实环境验证 + +这些项目需要用户自己的账户或域名,尚未取得实测证据: + +1. 当前 WorldQuant 账户的实际认证、人工验证页面及权限限制。 +2. 真实账户的完整 Alpha 分页、实际 REGULAR/SUPER/PYTHON 字段和 PnL schema。 +3. 真实域名的 DNS、ACME 证书签发/续期和公网 HTTPS 访问。 + +只读联调步骤:在正式本机页面登录并配置 WorldQuant,连接成功后先刷新个人资料、导入一个已知 Alpha ID、获取其 PnL,再执行全量同步。对一条记录保存本地备注后再次刷新,并重启后检查记录和任务。未完成上述步骤前,不把模拟测试结论视为真实平台兼容性保证。 + +## 已知架构边界 + +首期只有一个任务执行进程。长 Retry-After 会让后续任务排队,任务面板显示下次尝试时间并支持取消;不会为了缩短等待而提前请求平台。offset 分页遇到平台记录移动时可能遗漏,后续全量同步校正,单次未见记录不自动删除。数据库保存原始业务快照,后续研究、回测和 MCP 能力尚未实现。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1639bfb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,10 @@ + + + + + + + Alpha Workspace · WorldQuant 研究系统 + +
+ diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1301d87 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "wq-alpha-frontend", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@9.15.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "playwright test", + "test:e2e": "playwright test" + }, + "dependencies": { + "@douyinfe/semi-icons": "2.103.0", + "@douyinfe/semi-ui-19": "2.103.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^22", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "prettier": "3.6.2", + "typescript": "^5.9.0", + "vite": "8.2.2" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..a87b122 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + workers: 1, + timeout: 60000, + use: { + actionTimeout: 10000, + baseURL: "http://127.0.0.1:5179", + viewport: { width: 1440, height: 1000 }, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: [ + { + command: + "cd ../backend && uv run uvicorn tests.browser_server:create_test_app --factory --host 127.0.0.1 --port 18000", + url: "http://127.0.0.1:18000/api/v1/health", + reuseExistingServer: false, + timeout: 30000, + }, + { + command: "WQ_DEV_API=http://127.0.0.1:18000 pnpm dev --port 5179", + url: "http://127.0.0.1:5179", + reuseExistingServer: false, + timeout: 30000, + }, + ], +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..4001cbe --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3068 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@douyinfe/semi-icons': + specifier: 2.103.0 + version: 2.103.0(react@19.2.8) + '@douyinfe/semi-ui-19': + specifier: 2.103.0 + version: 2.103.0(@floating-ui/dom@1.8.0)(@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@playwright/test': + specifier: ^1.55.0 + version: 1.63.0 + '@types/node': + specifier: ^22 + version: 22.20.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.0.0 + version: 5.2.0(vite@8.2.2(@types/node@22.20.1)) + prettier: + specifier: 3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: 8.2.2 + version: 8.2.2(@types/node@22.20.1) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@7.0.2': + resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==} + peerDependencies: + '@dnd-kit/core': ^6.0.7 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@douyinfe/semi-animation-react@2.103.0': + resolution: {integrity: sha512-drJrG/jnsjs++t+bUtqeUVDJfR2QTeyDykF3UJdcIE6P9drJLQ5zq8hIr+BqxyGLtaiaJotkA54t2XIv+8rS1g==} + + '@douyinfe/semi-animation-styled@2.103.0': + resolution: {integrity: sha512-Or52Mupg1nepG8a3yemKtbqja5mwUZK5VDAOgUWuydiD1MDg77RP8M5bVUzCNXLCxoAODMUuLRXN7YIEIF2i0A==} + + '@douyinfe/semi-animation@2.103.0': + resolution: {integrity: sha512-eqfG0y9S9SI2gQi8dNRl2ksobef2Zsa+NONtJJz+1wQUCvb/kKLBNXyORIUrOnFar4n6upiCllNe+JG/9avIdA==} + + '@douyinfe/semi-foundation@2.103.0': + resolution: {integrity: sha512-8oKwXWLIN8Url7nSChiqpZc9HpehF8Vp80oUXbT1BVKR3ThuXBCMCW5MDj1lnZ+FiPlbOkrltN3v9kXS0hemlQ==} + + '@douyinfe/semi-icons@2.103.0': + resolution: {integrity: sha512-+B4W58IVguHdb+Ml4Knl+KYdB3r0+AMvmi44jKia+s7DnxJCVj7o6ZRddBexCLCLeXWWf6iYMW50yDEdeyAF2Q==} + peerDependencies: + react: '>=16.0.0' + + '@douyinfe/semi-illustrations@2.103.0': + resolution: {integrity: sha512-L1ZoKQ9XNzfhiosbW7A2g+wpndKmLCFvM7ZhtQqRcJQrc71F/VorxbE8MmKRElT3MLZW8btLs/Plp6H4QaG6nw==} + peerDependencies: + react: '>=16.0.0' + + '@douyinfe/semi-json-viewer-core@2.103.0': + resolution: {integrity: sha512-8kur7dNJ4NAfYLNoXIlGOA5mzRqj64GP21I2kTgIAkX2AZYpTYyOL3vA4UWlGONB0VSwmtHXOeR470ITbU99hA==} + + '@douyinfe/semi-theme-default@2.103.0': + resolution: {integrity: sha512-fa1P3fkhCFtpXNp4ZNQE4J1cv1RMv/NkmCw7/7TSho9F4L6C2/D6BPuuieqUYeKwHJ5ENIij2xl1VWGls1PMWw==} + + '@douyinfe/semi-ui-19@2.103.0': + resolution: {integrity: sha512-swvnZQSyJq74xwe+RTbV9zuZindfwca+UqngMXWSLtOLiu3NlqjnAAQzLjIUiEmbziXM795kuqpPYqz+Fui3ag==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} + peerDependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-mention@3.31.3': + resolution: {integrity: sha512-ygOPc3nQijSMUTbEdMeng12P1szk3znubA9xNi84f31VnR18VjmU0we8SIOSSVwDfm1GppSjRER2qHmX/wGIIw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + '@tiptap/suggestion': 3.31.3 + + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-text-align@3.31.3': + resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} + + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} + + '@tiptap/suggestion@3.31.3': + resolution: {integrity: sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + async-validator@3.5.2: + resolution: {integrity: sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bezier-easing@2.1.0: + resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-text-to-clipboard@2.2.0: + resolution: {integrity: sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ==} + engines: {node: '>=6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns-tz@1.3.8: + resolution: {integrity: sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==} + peerDependencies: + date-fns: '>=2.0.0' + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.1: + resolution: {integrity: sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-copy@3.1.0: + resolution: {integrity: sha512-IuCOCDFM1F/s4LtG7Bjr3yUUwPHybyhqN74xk2cMYoP51vVeErMy8+6HGFchi/+45lW8uzhs6lBeEqks2GWt6A==} + + fast-equals@5.4.2: + resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} + engines: {node: '>=6.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lottie-web@5.13.0: + resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + prosemirror-changeset@2.4.2: + resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} + + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + prosemirror-dropcursor@1.8.3: + resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} + + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-draggable@4.7.1: + resolution: {integrity: sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-resizable@3.2.0: + resolution: {integrity: sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + + react-window@1.8.11: + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@douyinfe/semi-animation-react@2.103.0': + dependencies: + '@douyinfe/semi-animation': 2.103.0 + '@douyinfe/semi-animation-styled': 2.103.0 + classnames: 2.5.1 + + '@douyinfe/semi-animation-styled@2.103.0': {} + + '@douyinfe/semi-animation@2.103.0': + dependencies: + bezier-easing: 2.1.0 + + '@douyinfe/semi-foundation@2.103.0': + dependencies: + '@douyinfe/semi-animation': 2.103.0 + '@douyinfe/semi-json-viewer-core': 2.103.0 + '@mdx-js/mdx': 3.1.1 + async-validator: 3.5.2 + classnames: 2.5.1 + date-fns: 2.30.0 + date-fns-tz: 1.3.8(date-fns@2.30.0) + fast-copy: 3.1.0 + lodash: 4.18.1 + lottie-web: 5.13.0 + memoize-one: 5.2.1 + prismjs: 1.30.0 + remark-gfm: 4.0.1 + scroll-into-view-if-needed: 2.2.31 + transitivePeerDependencies: + - supports-color + + '@douyinfe/semi-icons@2.103.0(react@19.2.8)': + dependencies: + classnames: 2.5.1 + react: 19.2.8 + + '@douyinfe/semi-illustrations@2.103.0(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@douyinfe/semi-json-viewer-core@2.103.0': + dependencies: + jsonc-parser: 3.3.1 + + '@douyinfe/semi-theme-default@2.103.0': {} + + '@douyinfe/semi-ui-19@2.103.0(@floating-ui/dom@1.8.0)(@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + '@douyinfe/semi-animation': 2.103.0 + '@douyinfe/semi-animation-react': 2.103.0 + '@douyinfe/semi-foundation': 2.103.0 + '@douyinfe/semi-icons': 2.103.0(react@19.2.8) + '@douyinfe/semi-illustrations': 2.103.0(react@19.2.8) + '@douyinfe/semi-theme-default': 2.103.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-mention': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tiptap/starter-kit': 3.31.3 + async-validator: 3.5.2 + classnames: 2.5.1 + copy-text-to-clipboard: 2.2.0 + date-fns: 2.30.0 + date-fns-tz: 1.3.8(date-fns@2.30.0) + fast-copy: 3.1.0 + jsonc-parser: 3.3.1 + lodash: 4.18.1 + prop-types: 15.8.1 + prosemirror-state: 1.4.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-resizable: 3.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-window: 1.8.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + scroll-into-view-if-needed: 2.2.31 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@floating-ui/dom' + - '@tiptap/suggestion' + - '@types/react' + - '@types/react-dom' + - supports-color + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.18.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.1 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.18.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@oxc-project/types@0.148.0': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-mention@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + dependencies: + prosemirror-changeset: 2.4.2 + prosemirror-commands: 1.7.2 + prosemirror-dropcursor: 1.8.3 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@ungap/structured-clone@1.4.0': {} + + '@vitejs/plugin-react@5.2.0(vite@8.2.2(@types/node@22.20.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 8.2.2(@types/node@22.20.1) + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + astring@1.9.0: {} + + async-validator@3.5.2: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.11.21: {} + + bezier-easing@2.1.0: {} + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + caniuse-lite@1.0.30001810: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + classnames@2.5.1: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + comma-separated-tokens@2.0.3: {} + + compute-scroll-into-view@1.0.20: {} + + convert-source-map@2.0.0: {} + + copy-text-to-clipboard@2.2.0: {} + + csstype@3.2.3: {} + + date-fns-tz@1.3.8(date-fns@2.30.0): + dependencies: + date-fns: 2.30.0 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.7 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + electron-to-chromium@1.5.422: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.18.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + extend@3.0.2: {} + + fast-copy@3.1.0: {} + + fast-equals@5.4.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linkifyjs@4.3.3: {} + + lodash@4.18.1: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lottie-web@5.13.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + memoize-one@5.2.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.54: {} + + object-assign@4.1.1: {} + + orderedmap@2.1.1: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.6.2: {} + + prismjs@1.30.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@7.2.0: {} + + prosemirror-changeset@2.4.2: + dependencies: + prosemirror-transform: 1.12.1 + + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-dropcursor@1.8.3: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.3 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-transform@1.12.1: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.3: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-draggable@4.7.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + react-is@16.13.1: {} + + react-refresh@0.18.0: {} + + react-resizable@3.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + prop-types: 15.8.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-draggable: 4.7.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-window@1.8.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@babel/runtime': 7.29.7 + memoize-one: 5.2.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + rope-sequence@1.3.4: {} + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + utility-types@3.11.0: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.2.2(@types/node@22.20.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + w3c-keyname@2.2.8: {} + + yallist@3.1.1: {} + + zwitch@2.0.4: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5ca4705 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,328 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Avatar, + Badge, + Banner, + Button, + Input, + LocaleProvider, + Spin, + Toast, +} from "@douyinfe/semi-ui-19"; +import { + IconGridView, + IconUser, + IconBell, + IconExit, + IconArrowRight, + IconPulse, +} from "@douyinfe/semi-icons"; +import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN"; +import { api, post } from "./api"; +import type { Account, Job } from "./types"; +import { AccountPage } from "./pages/AccountPage"; +import { AlphaPage } from "./pages/AlphaPage"; +import { JobPanel } from "./components/JobPanel"; + +export default function App() { + const [authenticated, setAuthenticated] = useState(null); + const [account, setAccount] = useState(null); + const [jobs, setJobs] = useState([]); + const [page, setPage] = useState( + location.hash === "#account" ? "account" : "alphas", + ); + const [showJobs, setShowJobs] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [pollError, setPollError] = useState(""); + const refresh = useCallback(async () => { + try { + const [nextAccount, nextJobs] = await Promise.all([ + api("/account"), + api("/sync-jobs"), + ]); + setAccount(nextAccount); + setJobs(nextJobs); + setPollError(""); + } catch (error) { + setPollError((error as Error).message); + } + }, []); + + useEffect(() => { + api("/auth/me") + .then(() => setAuthenticated(true)) + .catch(() => setAuthenticated(false)); + const expired = () => { + setAuthenticated(false); + setAccount(null); + setJobs([]); + }; + window.addEventListener("session-expired", expired); + const hash = () => + setPage(location.hash === "#account" ? "account" : "alphas"); + window.addEventListener("hashchange", hash); + return () => { + window.removeEventListener("session-expired", expired); + window.removeEventListener("hashchange", hash); + }; + }, []); + useEffect(() => { + if (!authenticated) return; + void refresh(); + const timer = window.setInterval(() => void refresh(), 3000); + return () => clearInterval(timer); + }, [authenticated, refresh]); + useEffect(() => { + document.body.setAttribute("theme-mode", account?.theme ?? "light"); + }, [account?.theme]); + + const completedVersion = jobs + .filter( + (j) => j.status === "completed" || j.status === "completed_with_errors", + ) + .map((j) => `${j.id}:${j.updated_at}`) + .join("|"); + const pending = jobs.filter((j) => + ["queued", "running", "waiting_auth", "waiting_connection"].includes( + j.status, + ), + ).length; + const actionDone = () => { + void refresh(); + setRefreshKey((k) => k + 1); + }; + const taskCreated = () => { + actionDone(); + setShowJobs(true); + }; + const changePage = (next: string) => { + location.hash = next; + setPage(next); + }; + const logout = async () => { + try { + await post("/auth/logout"); + setAuthenticated(false); + } catch (e) { + Toast.error((e as Error).message); + } + }; + + return ( + + {authenticated === null ? ( +
+ +
+ ) : !authenticated ? ( + { + setAuthenticated(true); + void refresh(); + }} + /> + ) : ( +
+ +
+
+
+ 工作空间 /{" "} + {page === "alphas" ? "Alpha 管理" : "个人信息"} +
+
+ + + +
+ + {account?.display_name.slice(0, 1) || "研"} + + + {account?.display_name ?? "研究员"} + +
+
+
+ {pollError && ( + + )} + {page === "account" ? ( + + ) : ( + changePage("account")} + /> + )} +
+
+ setShowJobs(false)} + onChange={actionDone} + onAccount={() => { + setShowJobs(false); + changePage("account"); + }} + /> +
+ )} +
+ ); +} + +function Login({ onLogin }: { onLogin: () => void }) { + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await post("/auth/login", { username, password }); + setPassword(""); + onLogin(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + return ( +
+
+
+ α +
+ ALPHARESEARCH WORKSPACE +
+
+
+
WORLDQUANT RESEARCH
+

+ 让每一次研究, +
+ 都有迹可循。 +

+

+ 从想法到验证,从信号到洞察。 +
+ 在一个工作空间里,管理你的 Alpha 研究。 +

+
+ + 观察 · 记录 · 迭代 +
+
+
你的个人 Alpha 研究工作空间
+
+
+
+
欢迎回来
+

登录研究工作空间

+

使用部署时设置的系统账户登录。

+ {error && } + + + +
WorldQuant 账户在登录后单独连接。
+ +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..6bad5b6 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,111 @@ +export class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + } +} + +export async function api( + path: string, + options: RequestInit = {}, +): Promise { + const response = await fetch(`/api/v1${path}`, { + ...options, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-WQ-Request": "1", + ...options.headers, + }, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + if (response.status === 401 && path !== "/auth/login") + window.dispatchEvent(new Event("session-expired")); + throw new ApiError( + typeof body.detail === "string" + ? body.detail + : `请求失败(${response.status})`, + response.status, + ); + } + return response.json() as Promise; +} + +export const post = (path: string, body?: unknown) => + api(path, { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }); +export const patch = (path: string, body: unknown) => + api(path, { method: "PATCH", body: JSON.stringify(body) }); + +export function formatNumber(value: unknown, digits = 2): string { + return typeof value === "number" && Number.isFinite(value) + ? value.toFixed(digits) + : "—"; +} + +export function displayValue(value: unknown): string { + if (value === null || value === undefined || value === "") return "未提供"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function formatTime( + value: string | null | undefined, + timezone = "Asia/Shanghai", +): string { + if (!value) return "尚未同步"; + const date = new Date(value); + if (!Number.isFinite(date.valueOf())) return "时间未知"; + return new Intl.DateTimeFormat("zh-CN", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +export const stateLabels = { + inbox: "待研究", + candidate: "候选", + optimizing: "优化中", + archived: "归档", +}; +export const stateOptions = Object.entries(stateLabels).map( + ([value, label]) => ({ value, label }), +); +export const jobLabels: Record = { + full_sync: "全量同步 Alpha", + alpha_refresh: "导入 / 刷新 Alpha", + pnl_refresh: "获取 PnL", + connect: "连接 WorldQuant", + verify: "继续人工验证", + profile: "刷新个人资料", +}; +export const jobStateLabels: Record = { + queued: "排队中", + running: "执行中", + waiting_auth: "等待人工验证", + waiting_connection: "等待连接", + completed: "已完成", + completed_with_errors: "部分失败", + failed: "失败", + cancelled: "已取消", +}; + +export function queryString( + values: Record, +): string { + return new URLSearchParams( + Object.entries(values) + .filter(([, v]) => v !== undefined && v !== "") + .map(([k, v]) => [k, String(v)]), + ).toString(); +} diff --git a/frontend/src/components/AlphaDetail.tsx b/frontend/src/components/AlphaDetail.tsx new file mode 100644 index 0000000..3d312f4 --- /dev/null +++ b/frontend/src/components/AlphaDetail.tsx @@ -0,0 +1,369 @@ +import { useEffect, useRef, useState } from "react"; +import { + Banner, + Button, + Checkbox, + Descriptions, + Empty, + Input, + Select, + SideSheet, + Spin, + TabPane, + Tabs, + Tag, + TextArea, + Toast, +} from "@douyinfe/semi-ui-19"; +import { + api, + displayValue, + formatTime, + patch, + post, + stateOptions, +} from "../api"; +import type { + AlphaDetail as Detail, + Pnl, + Research, + ResearchState, +} from "../types"; +import { PnlChart } from "./PnlChart"; + +export function AlphaDetail({ + id, + version, + timezone, + taskPanelOpen, + onClose, + onSaved, + onTask, +}: { + id: string | null; + version: string; + timezone?: string; + taskPanelOpen: boolean; + onClose: () => void; + onSaved: () => void; + onTask: () => void; +}) { + const [detail, setDetail] = useState(null); + const [pnl, setPnl] = useState(null); + const [research, setResearch] = useState(null); + const [tagText, setTagText] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + const [tab, setTab] = useState("overview"); + const dirty = useRef(false); + useEffect(() => { + dirty.current = false; + setDetail(null); + setResearch(null); + setPnl(null); + setError(""); + setTab("overview"); + }, [id]); + useEffect(() => { + let active = true; + if (id) + api(`/alphas/${id}`) + .then((value) => { + if (active) { + setDetail(value); + setError(""); + if (!dirty.current) { + setResearch(value.research); + setTagText(value.research.tags.join(", ")); + } + } + }) + .catch((e) => { + if (active) setError(e.message); + }); + return () => { + active = false; + }; + }, [id, version]); + useEffect(() => { + let active = true; + if (id && tab === "pnl") + api(`/alphas/${id}/pnl`) + .then((p) => { + if (active) setPnl(p); + }) + .catch((e) => { + if (active) Toast.error(e.message); + }); + return () => { + active = false; + }; + }, [id, tab, version]); + async function fetchPnl() { + try { + await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] }); + onTask(); + } catch (e) { + Toast.error((e as Error).message); + } + } + async function save() { + if (!research) return; + setSaving(true); + try { + const { updated_at: _, ...editable } = research; + await patch(`/alphas/${id}/research`, { + ...editable, + tags: tagText + .split(/[,,]/) + .map((s) => s.trim()) + .filter(Boolean), + }); + dirty.current = false; + Toast.success("研究记录已保存"); + onSaved(); + } catch (e) { + Toast.error((e as Error).message); + } finally { + setSaving(false); + } + } + return ( + + Alpha 详情 {id} + + } + onCancel={onClose} + > + {error ? ( + + ) : !detail || !research ? ( + + ) : ( + <> +
+
+

{detail.name || detail.id}

+
+ {detail.alpha_type ?? "类型未提供"} + {detail.language ?? "语言未提供"} + {detail.status ?? "状态未提供"} +
+
+ + + +
+ + +
+ ({ + key, + value: displayValue(detail[key as keyof Detail]), + }))} + /> +

+ 平台创建:{formatTime(detail.date_created, timezone)} · + 最近同步:{formatTime(detail.synced_at, timezone)} +

+ {detail.expression !== null && ( + + )} + {detail.selection !== null && ( + + )} + {detail.combo !== null && ( + + )} +

回测设置

+ ({ + key, + value: displayValue(value), + }))} + /> +
+
+ + + +

平台已有检查结果

+

+ 展示已同步结果,查看此页不会触发新的平台检查。 +

+ {detail.checks.length ? ( + detail.checks.map((check, i) => ( +
+ {displayValue(check.name)} + + {displayValue(check.value)} / {displayValue(check.limit)} + + + {displayValue(check.result)} + +
+ )) + ) : ( + + )} +
+ +
+
+

累计 PnL

+

+ {pnl?.fetched_at + ? `缓存更新于 ${formatTime(pnl.fetched_at, timezone)}` + : "尚未获取 PnL"} +

+
+ +
+ {pnl?.cached ? ( + <> + +

+ {pnl.points.length} 条记录 · 缺失数据保留为断点 +

+ + ) : ( + + )} +
+ +
+

+ 这些记录只保存在你的工作空间,平台同步不会覆盖。 +

+ +