From 3cd280d06869bfd9f68c8caac4f72eeddafa491e Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Mon, 7 Sep 2026 14:54:20 +0800 Subject: [PATCH] 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. --- .dockerignore | 19 + .env.example | 11 + .gitignore | 22 + Caddyfile | 18 + Dockerfile.backend | 15 + Dockerfile.frontend | 12 + README.md | 179 + backend/alembic.ini | 3 + backend/app/__init__.py | 0 backend/app/alphas.py | 199 ++ backend/app/cli.py | 33 + backend/app/config.py | 28 + backend/app/db.py | 8 + backend/app/jobs.py | 390 +++ backend/app/main.py | 498 +++ backend/app/models.py | 123 + backend/app/schemas.py | 281 ++ backend/app/security.py | 65 + backend/app/worldquant.py | 211 ++ backend/migrations/env.py | 28 + backend/migrations/script.py.mako | 14 + ..._initial_account_alpha_and_durable_job_.py | 153 + backend/pyproject.toml | 27 + backend/tests/__init__.py | 0 backend/tests/browser_server.py | 135 + backend/tests/conftest.py | 71 + backend/tests/docker_acceptance.py | 198 ++ backend/tests/test_api.py | 232 ++ backend/tests/test_jobs.py | 251 ++ backend/tests/test_worldquant.py | 186 + backend/uv.lock | 699 ++++ compose.public.yaml | 59 + compose.yaml | 57 + docs/project-plan.md | 67 + docs/verification.md | 56 + frontend/index.html | 10 + frontend/package.json | 30 + frontend/playwright.config.ts | 30 + frontend/pnpm-lock.yaml | 3068 +++++++++++++++++ frontend/src/App.tsx | 328 ++ frontend/src/api.ts | 111 + frontend/src/components/AlphaDetail.tsx | 369 ++ frontend/src/components/JobPanel.tsx | 153 + frontend/src/components/PnlChart.tsx | 60 + frontend/src/main.tsx | 11 + frontend/src/pages/AccountPage.tsx | 330 ++ frontend/src/pages/AlphaPage.tsx | 781 +++++ frontend/src/style.css | 905 +++++ frontend/src/types.ts | 77 + frontend/tests/workspace.spec.ts | 223 ++ frontend/tsconfig.json | 19 + frontend/vite.config.ts | 10 + scripts/init_env.py | 25 + 53 files changed, 10888 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 README.md create mode 100644 backend/alembic.ini create mode 100644 backend/app/__init__.py create mode 100644 backend/app/alphas.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/jobs.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/app/worldquant.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/0001_initial_account_alpha_and_durable_job_.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/browser_server.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/docker_acceptance.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_jobs.py create mode 100644 backend/tests/test_worldquant.py create mode 100644 backend/uv.lock create mode 100644 compose.public.yaml create mode 100644 compose.yaml create mode 100644 docs/project-plan.md create mode 100644 docs/verification.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AlphaDetail.tsx create mode 100644 frontend/src/components/JobPanel.tsx create mode 100644 frontend/src/components/PnlChart.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AccountPage.tsx create mode 100644 frontend/src/pages/AlphaPage.tsx create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/tests/workspace.spec.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/init_env.py 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} 条记录 · 缺失数据保留为断点 +

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

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

+ +