From a8d11eafc697f063cd7524e3b820a2a21a27629e Mon Sep 17 00:00:00 2001 From: cat-shark Date: Sat, 8 Aug 2026 21:04:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=B0=83=E5=BA=A6=E4=B8=8E=E4=B8=8A=E4=BC=A0=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + AGENTS.md | 38 ++ README.md | 88 +++++ app/__init__.py | 1 + app/config.py | 18 + app/db.py | 382 +++++++++++++++++++ app/main.py | 60 +++ app/node_manager.py | 298 +++++++++++++++ app/routers/__init__.py | 1 + app/routers/apps.py | 126 +++++++ app/routers/instances.py | 41 +++ app/routers/nodes.py | 95 +++++ app/routers/workflows.py | 121 +++++++ app/scheduler.py | 215 +++++++++++ app/schemas.py | 53 +++ app/seed.py | 72 ++++ pyproject.toml | 22 ++ tests/conftest.py | 21 ++ tests/fixtures/bad_node.py | 17 + tests/fixtures/bad_ready_node.py | 4 + tests/fixtures/bad_status_node.py | 17 + tests/fixtures/failing_node.py | 34 ++ tests/fixtures/stderr_node.py | 20 + tests/test_api.py | 97 +++++ tests/test_apps_api.py | 141 ++++++++ tests/test_db.py | 144 ++++++++ tests/test_node_manager.py | 394 ++++++++++++++++++++ tests/test_scheduler.py | 390 ++++++++++++++++++++ tests/test_seed.py | 39 ++ tests/test_uvicorn_smoke.py | 27 ++ tests/test_workflow_api.py | 107 ++++++ uv.lock | 584 ++++++++++++++++++++++++++++++ 32 files changed, 3675 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/node_manager.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/apps.py create mode 100644 app/routers/instances.py create mode 100644 app/routers/nodes.py create mode 100644 app/routers/workflows.py create mode 100644 app/scheduler.py create mode 100644 app/schemas.py create mode 100644 app/seed.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/bad_node.py create mode 100644 tests/fixtures/bad_ready_node.py create mode 100644 tests/fixtures/bad_status_node.py create mode 100644 tests/fixtures/failing_node.py create mode 100644 tests/fixtures/stderr_node.py create mode 100644 tests/test_api.py create mode 100644 tests/test_apps_api.py create mode 100644 tests/test_db.py create mode 100644 tests/test_node_manager.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_seed.py create mode 100644 tests/test_uvicorn_smoke.py create mode 100644 tests/test_workflow_api.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04e7162 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +.coverage +data/ +storage/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..08182b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# WOV API + +本仓库是 WOV 平台后端,负责节点注册、工作流编排、任务调度和用户 API。 + +## 职责 + +- 维护 SQLite 节点注册表和节点实例状态。 +- 通过 `NodeManager` 启动、复用和回收独立节点进程。 +- 提供管理端 API:注册节点、查看实例、停止实例。 +- 后续增加工作流 DAG、WorkflowRun、用户上传和产物下载。 + +## 运行 + +```powershell +uv sync +uv run uvicorn app.main:app --reload +``` + +`uv sync` 默认会安装 `[dependency-groups] dev`,包括 pytest、pytest-cov 和 httpx。 + +如果 8000 端口被占用或 Windows 提示 `WinError 10013`,先检查监听端口,然后改用其他端口: + +```powershell +Get-NetTCPConnection -LocalPort 8000 -State Listen +uv run uvicorn app.main:app --reload --port 8001 +``` + +## 不变量 + +- 节点生命周期只能由 `NodeManager` 管理,API 不直接启动或杀死节点。 +- 节点进程不共享 API 内存,中间产物通过 URI 落盘。 +- 后续切换 PostgreSQL、对象存储和分布式队列时,不能破坏这些边界。 + +## 环境 + +- Python 环境统一使用 uv 管理,不直接使用 pip 修改依赖。 +- 测试必须达到 100% 行覆盖率,且只能通过调用真实代码路径覆盖。 +- 行覆盖率不包含外部端口占用/权限问题;真实网络启动由 uvicorn 冒烟测试单独覆盖。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e673fd --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# WOV API + +## 启动 + +```powershell +cd wov-api +uv sync +uv run uvicorn app.main:app --reload +``` + +启动后: + +```text +http://127.0.0.1:8000/ 用户端 +http://127.0.0.1:8000/admin.html 管理后台 +http://127.0.0.1:8000/docs API 文档 +``` + +首次启动会自动注册 `wov-node-*` 下的节点,并创建并发布 `demo` 视频字幕工作流。 + +## Linux 运行 + +```bash +cd wov-api +uv sync +uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +Linux 上 ffmpeg 可用系统包安装,节点也会自动使用节点仓库自己的 `.venv/bin/python`: + +```bash +sudo apt install ffmpeg +``` + +如果不想安装系统 ffmpeg,`wov-node-ffmpeg` 的 `imageio-ffmpeg` 依赖会提供内置二进制。 + +## M5 视频字幕依赖 + +### FFmpeg + +确保 `ffmpeg` 在 PATH 中,或设置: + +```powershell +$env:FFMPEG_BIN="C:\path\to\ffmpeg.exe" +``` + +### Faster-Whisper + +节点会优先使用节点仓库自己的 `.venv`,所以在 `wov-node-whisper` 中安装: + +```powershell +cd wov-node-whisper +uv add faster-whisper +``` + +设置模型路径: + +```powershell +$env:WHISPER_MODEL_PATH="C:\models\faster-whisper-large-v3" +``` + +未设置时默认使用 `large-v3`,需要能联网下载 Hugging Face 模型。 + +### LLM 翻译 + +在启动 `wov-api` 的终端设置 OpenAI 兼容接口: + +```powershell +$env:LLM_API_BASE="http://192.168.123.70:8080/v1/chat/completions" +$env:LLM_MODEL="default" +``` + +该接口无需 key;如不使用当前默认接口,再按需设置 `LLM_API_BASE` 和 `LLM_API_KEY`。 + +## 使用 + +1. 打开 `http://127.0.0.1:8000/`。 +2. 在“视频字幕生成”中上传视频。 +3. 页面会轮询任务进度。 +4. 完成后下载 `cn_srt` 和 `ass` 两个产物。 + +## 测试 + +```powershell +uv run pytest +``` + +测试必须保持 100% 行覆盖率。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f113f79 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""WOV platform API package.""" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..67b0d88 --- /dev/null +++ b/app/config.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent +SDK_SRC = WORKSPACE_ROOT / "wov-sdk" / "src" + +if str(SDK_SRC) not in sys.path: + sys.path.insert(0, str(SDK_SRC)) + +DATA_DIR = Path(os.getenv("WOV_DATA_DIR", str(WORKSPACE_ROOT / "wov-api" / "data"))) +DB_PATH = Path(os.getenv("WOV_DB_PATH", str(DATA_DIR / "wov.db"))) +STORAGE_DIR = Path(os.getenv("WOV_STORAGE_DIR", str(DATA_DIR / "storage"))) + +NODE_REAP_INTERVAL_SECONDS = float(os.getenv("WOV_REAP_INTERVAL_SECONDS", "3")) +NODE_READY_TIMEOUT_SECONDS = float(os.getenv("WOV_READY_TIMEOUT_SECONDS", "12")) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..220d37e --- /dev/null +++ b/app/db.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from wov_sdk.models import NodeManifest + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + @contextmanager + def _connect(self) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + finally: + conn.close() + + def _init_schema(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + capability TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS node_instances ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + status TEXT NOT NULL, + pid INTEGER, + address TEXT, + started_at TEXT, + last_used_at TEXT, + busy_since TEXT, + error TEXT, + FOREIGN KEY(node_id) REFERENCES nodes(id) + ); + + CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + published INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS workflow_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id TEXT NOT NULL, + version INTEGER NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(workflow_id, version), + FOREIGN KEY(workflow_id) REFERENCES workflows(id) + ); + + CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + workflow_version INTEGER NOT NULL, + status TEXT NOT NULL, + current_node_id TEXT, + progress REAL NOT NULL DEFAULT 0, + error TEXT, + input_uri TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(workflow_id) REFERENCES workflows(id) + ); + + CREATE TABLE IF NOT EXISTS artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + name TEXT NOT NULL, + uri TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(run_id, name), + FOREIGN KEY(run_id) REFERENCES workflow_runs(id) + ); + """ + ) + + def upsert_node(self, manifest: NodeManifest) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO nodes (id, name, version, capability, manifest_json) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + version = excluded.version, + capability = excluded.capability, + manifest_json = excluded.manifest_json + """, + ( + manifest.id, + manifest.name, + manifest.version, + manifest.capability, + json.dumps(manifest.to_dict(), ensure_ascii=False), + ), + ) + + def get_node(self, node_id: str) -> NodeManifest | None: + with self._connect() as conn: + row = conn.execute("SELECT manifest_json FROM nodes WHERE id = ?", (node_id,)).fetchone() + if row is None: + return None + return NodeManifest.from_dict(json.loads(row["manifest_json"])) + + def list_nodes(self) -> list[NodeManifest]: + with self._connect() as conn: + rows = conn.execute("SELECT manifest_json FROM nodes ORDER BY id").fetchall() + return [NodeManifest.from_dict(json.loads(row["manifest_json"])) for row in rows] + + def delete_node(self, node_id: str) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM node_instances WHERE node_id = ?", (node_id,)) + conn.execute("DELETE FROM nodes WHERE id = ?", (node_id,)) + + def upsert_instance(self, instance: dict[str, Any]) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO node_instances ( + id, node_id, status, pid, address, started_at, last_used_at, + busy_since, error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + pid = excluded.pid, + address = excluded.address, + started_at = excluded.started_at, + last_used_at = excluded.last_used_at, + busy_since = excluded.busy_since, + error = excluded.error + """, + ( + instance["id"], + instance["node_id"], + instance["status"], + instance.get("pid"), + instance.get("address"), + instance.get("started_at"), + instance.get("last_used_at"), + instance.get("busy_since"), + instance.get("error"), + ), + ) + + def list_instances(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM node_instances ORDER BY started_at DESC" + ).fetchall() + return [dict(row) for row in rows] + + def delete_instance(self, instance_id: str) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM node_instances WHERE id = ?", (instance_id,)) + + def upsert_workflow(self, workflow: dict[str, Any]) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflows (id, name, description, published, latest_version) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + published = excluded.published, + latest_version = excluded.latest_version + """, + ( + workflow["id"], + workflow["name"], + workflow.get("description", ""), + int(workflow.get("published", 0)), + int(workflow.get("latest_version", 0)), + ), + ) + + def get_workflow(self, workflow_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id,)).fetchone() + return dict(row) if row else None + + def list_workflows(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM workflows ORDER BY created_at DESC").fetchall() + return [dict(row) for row in rows] + + def delete_workflow(self, workflow_id: str) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM artifacts WHERE run_id IN (SELECT id FROM workflow_runs WHERE workflow_id = ?)", (workflow_id,)) + conn.execute("DELETE FROM workflow_runs WHERE workflow_id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflow_versions WHERE workflow_id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + + def create_workflow_version(self, workflow_id: str, version: int, definition: dict[str, Any]) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflow_versions (workflow_id, version, definition_json) + VALUES (?, ?, ?) + """, + (workflow_id, version, json.dumps(definition, ensure_ascii=False)), + ) + + def get_latest_workflow_version(self, workflow_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? + ORDER BY version DESC + LIMIT 1 + """, + (workflow_id,), + ).fetchone() + if row is None: + return None + result = dict(row) + result["definition"] = json.loads(result.pop("definition_json")) + return result + + def get_workflow_version(self, workflow_id: str, version: int) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? AND version = ? + """, + (workflow_id, version), + ).fetchone() + if row is None: + return None + result = dict(row) + result["definition"] = json.loads(result.pop("definition_json")) + return result + + def list_workflow_versions(self, workflow_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? + ORDER BY version DESC + """, + (workflow_id,), + ).fetchall() + versions = [] + for row in rows: + item = dict(row) + item["definition"] = json.loads(item.pop("definition_json")) + versions.append(item) + return versions + + def create_run(self, run: dict[str, Any]) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflow_runs ( + id, workflow_id, workflow_version, status, current_node_id, + progress, error, input_uri, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run["id"], + run["workflow_id"], + run["workflow_version"], + run["status"], + run.get("current_node_id"), + float(run.get("progress", 0)), + run.get("error"), + run.get("input_uri"), + run["created_at"], + run["updated_at"], + ), + ) + + def get_run(self, run_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)).fetchone() + return dict(row) if row else None + + def list_runs(self, limit: int = 20) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def update_run(self, run_id: str, **fields: Any) -> None: + allowed = { + "status", + "current_node_id", + "progress", + "error", + } + updates = {key: value for key, value in fields.items() if key in allowed} + if not updates: + return + updates["updated_at"] = fields.get("updated_at") + assignments = ", ".join(f"{key} = ?" for key in updates) + values = list(updates.values()) + [run_id] + with self._connect() as conn: + conn.execute(f"UPDATE workflow_runs SET {assignments} WHERE id = ?", values) + + def next_queued_run(self) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_runs + WHERE status = 'QUEUED' + ORDER BY created_at ASC + LIMIT 1 + """ + ).fetchone() + return dict(row) if row else None + + def create_artifact(self, artifact: dict[str, Any]) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO artifacts ( + run_id, node_id, name, uri, mime_type, size + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + artifact["run_id"], + artifact["node_id"], + artifact["name"], + artifact["uri"], + artifact.get("mime_type", ""), + int(artifact.get("size", 0)), + ), + ) + + def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at", + (run_id,), + ).fetchall() + return [dict(row) for row in rows] + + def get_artifact(self, run_id: str, name: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM artifacts WHERE run_id = ? AND name = ?", + (run_id, name), + ).fetchone() + return dict(row) if row else None + + def delete_run_artifacts(self, run_id: str) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,)) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..34f61d5 --- /dev/null +++ b/app/main.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.config import DATA_DIR, DB_PATH, STORAGE_DIR, WORKSPACE_ROOT +from app.db import Database +from app.node_manager import NodeManager +from app.routers import apps, instances, nodes, workflows +from app.scheduler import WorkflowScheduler +from app.seed import seed_demo_workflow, seed_nodes + + +@asynccontextmanager +async def lifespan(app: FastAPI): + DATA_DIR.mkdir(parents=True, exist_ok=True) + STORAGE_DIR.mkdir(parents=True, exist_ok=True) + db = Database(DB_PATH) + manager = NodeManager(db) + manager.start_reaper() + if os.getenv("WOV_AUTO_SEED", "1") == "1": + seed_nodes(db, WORKSPACE_ROOT) + seed_demo_workflow(db) + scheduler = WorkflowScheduler(db, manager, STORAGE_DIR) + if os.getenv("WOV_SCHEDULER_ENABLED", "1") == "1": + scheduler.start() + app.state.db = db + app.state.node_manager = manager + app.state.scheduler = scheduler + yield + scheduler.stop() + manager.shutdown() + + +app = FastAPI(title="WOV API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(nodes.router) +app.include_router(instances.router) +app.include_router(workflows.router) +app.include_router(apps.router) + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "service": "wov-api"} + + +FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "wov-web" +app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") diff --git a/app/node_manager.py b/app/node_manager.py new file mode 100644 index 0000000..661de4b --- /dev/null +++ b/app/node_manager.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest + +from app.config import NODE_READY_TIMEOUT_SECONDS, NODE_REAP_INTERVAL_SECONDS, SDK_SRC, WORKSPACE_ROOT +from app.db import Database + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _idle_seconds(last_used_at: str) -> float: + if not last_used_at: + return 0.0 + try: + last = datetime.fromisoformat(last_used_at) + return max(0.0, (datetime.now(timezone.utc) - last).total_seconds()) + except ValueError: + return 0.0 + + +@dataclass +class NodeRuntime: + instance_id: str + node_id: str + manifest: NodeManifest + status: str = "starting" + process: subprocess.Popen | None = None + pid: int | None = None + address: str | None = None + started_at: str = field(default_factory=_now_iso) + last_used_at: str = field(default_factory=_now_iso) + busy_count: int = 0 + error: str | None = None + stderr_tail: list[str] = field(default_factory=list) + + +class NodeManager: + def __init__(self, db: Database) -> None: + self.db = db + self._runtimes: dict[str, NodeRuntime] = {} + self._lock = threading.RLock() + self._reaper_thread: threading.Thread | None = None + self._stopping = False + + def start_reaper(self) -> None: + self._stopping = False + self._reaper_thread = threading.Thread( + target=self._reaper_loop, + name="wov-node-reaper", + daemon=True, + ) + self._reaper_thread.start() + + def shutdown(self) -> None: + self._stopping = True + with self._lock: + for runtime in list(self._runtimes.values()): + self._stop_locked(runtime) + + def _reaper_loop(self) -> None: + while not self._stopping: + time.sleep(NODE_REAP_INTERVAL_SECONDS) + with self._lock: + for runtime in list(self._runtimes.values()): + if runtime.status != "ready" or runtime.busy_count > 0: + continue + if runtime.manifest.keep_warm: + continue + ttl = runtime.manifest.idle_ttl_seconds + if ttl >= 0 and _idle_seconds(runtime.last_used_at) >= ttl: + runtime.status = "stopping" + self._stop_locked(runtime) + + def _resolve_command(self, manifest: NodeManifest) -> list[str]: + command = list(manifest.command) + if command and command[0].lower() in {"python", "python3"}: + repo_dir = WORKSPACE_ROOT / manifest.repo_dir + windows_python = repo_dir / ".venv" / "Scripts" / "python.exe" + unix_python = repo_dir / ".venv" / "bin" / "python" + if windows_python.is_file(): + command[0] = str(windows_python) + elif unix_python.is_file(): + command[0] = str(unix_python) + else: + command[0] = sys.executable + return command + + def _node_env(self, manifest: NodeManifest) -> dict[str, str]: + env = os.environ.copy() + current_pythonpath = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = os.pathsep.join( + item for item in [str(SDK_SRC), current_pythonpath] if item + ) + env.update(manifest.env) + return env + + def _start_locked(self, manifest: NodeManifest) -> NodeRuntime: + runtime = NodeRuntime( + instance_id=f"ni_{uuid.uuid4().hex[:12]}", + node_id=manifest.id, + manifest=manifest, + ) + repo_dir = WORKSPACE_ROOT / manifest.repo_dir + if not repo_dir.is_dir(): + runtime.status = "error" + runtime.error = f"repo_dir not found: {repo_dir}" + self.db.upsert_instance(self._instance_row(runtime)) + return runtime + + ready_event = threading.Event() + ready_port: dict[str, int] = {} + + try: + process = subprocess.Popen( + self._resolve_command(manifest), + cwd=str(repo_dir), + env=self._node_env(manifest), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + except Exception as exc: # noqa: BLE001 + runtime.status = "error" + runtime.error = str(exc) + self.db.upsert_instance(self._instance_row(runtime)) + return runtime + + runtime.process = process + runtime.pid = process.pid + + def read_stdout() -> None: + assert process.stdout is not None + for line in iter(process.stdout.readline, ""): + line = line.strip() + if line.startswith("WOV_NODE_READY"): + try: + ready_port["port"] = int(line.split("port=", 1)[1]) + except (IndexError, ValueError): + ready_port["port"] = 0 + ready_event.set() + + def read_stderr() -> None: + assert process.stderr is not None + for line in iter(process.stderr.readline, ""): + line = line.strip() + if line: + runtime.stderr_tail.append(line) + runtime.stderr_tail = runtime.stderr_tail[-200:] + + threading.Thread(target=read_stdout, daemon=True).start() + threading.Thread(target=read_stderr, daemon=True).start() + + ready = ready_event.wait(timeout=NODE_READY_TIMEOUT_SECONDS) + if not ready or "port" not in ready_port or not ready_port["port"]: + self._stop_process(process) + runtime.status = "error" + runtime.error = "node did not report readiness" + self.db.upsert_instance(self._instance_row(runtime)) + return runtime + + address = f"http://127.0.0.1:{ready_port['port']}" + try: + with urllib.request.urlopen(f"{address}/health", timeout=2) as response: + if response.status != 200: + raise RuntimeError(f"health check returned {response.status}") + except Exception as exc: # noqa: BLE001 + self._stop_process(process) + runtime.status = "error" + runtime.error = f"health check failed: {exc}" + self.db.upsert_instance(self._instance_row(runtime)) + return runtime + + runtime.address = address + runtime.status = "ready" + runtime.started_at = _now_iso() + runtime.last_used_at = _now_iso() + self._runtimes[runtime.instance_id] = runtime + self.db.upsert_instance(self._instance_row(runtime)) + return runtime + + def _stop_process(self, process: subprocess.Popen) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + def _stop_locked(self, runtime: NodeRuntime) -> None: + if runtime.process is not None: + self._stop_process(runtime.process) + runtime.status = "stopped" + runtime.pid = None + runtime.address = None + self._runtimes.pop(runtime.instance_id, None) + self.db.upsert_instance(self._instance_row(runtime)) + + def _instance_row(self, runtime: NodeRuntime) -> dict: + return { + "id": runtime.instance_id, + "node_id": runtime.node_id, + "status": runtime.status, + "pid": runtime.pid, + "address": runtime.address, + "started_at": runtime.started_at, + "last_used_at": runtime.last_used_at, + "busy_since": None, + "error": runtime.error, + } + + def acquire(self, node_id: str) -> tuple[NodeRuntime, str]: + manifest = self.db.get_node(node_id) + if manifest is None: + raise ValueError(f"node not registered: {node_id}") + + with self._lock: + for runtime in self._runtimes.values(): + if ( + runtime.node_id == node_id + and runtime.status == "ready" + and runtime.busy_count < manifest.max_concurrency + ): + runtime.busy_count += 1 + runtime.last_used_at = _now_iso() + return runtime, runtime.address or "" + + runtime = self._start_locked(manifest) + if runtime.status != "ready": + raise RuntimeError(runtime.error or "node failed to start") + runtime.busy_count += 1 + runtime.last_used_at = _now_iso() + return runtime, runtime.address or "" + + def release(self, instance_id: str) -> None: + with self._lock: + runtime = self._runtimes.get(instance_id) + if runtime is None: + return + runtime.busy_count = max(0, runtime.busy_count - 1) + runtime.last_used_at = _now_iso() + self.db.upsert_instance(self._instance_row(runtime)) + + def invoke(self, node_id: str, request: InvokeRequest) -> InvokeResponse: + runtime, address = self.acquire(node_id) + request.node_instance_id = runtime.instance_id + body = json.dumps(request.to_dict(), ensure_ascii=False).encode("utf-8") + http_request = urllib.request.Request( + f"{address}/invoke", + data=body, + headers={"Content-Type": "application/json; charset=utf-8"}, + method="POST", + ) + try: + with urllib.request.urlopen(http_request, timeout=300) as response: + payload = json.loads(response.read().decode("utf-8")) + return InvokeResponse.from_dict(payload) + except urllib.error.HTTPError as exc: + try: + payload = json.loads(exc.read().decode("utf-8")) + return InvokeResponse.from_dict(payload) + except Exception: # noqa: BLE001 + return InvokeResponse(status="failed", error=f"node returned {exc.code}") + except Exception as exc: # noqa: BLE001 + return InvokeResponse(status="failed", error=str(exc)) + finally: + self.release(request.node_instance_id) + + def stop_instance(self, instance_id: str) -> None: + with self._lock: + runtime = self._runtimes.get(instance_id) + if runtime is None: + return + self._stop_locked(runtime) + + def stop_all_for_node(self, node_id: str) -> None: + with self._lock: + for runtime in list(self._runtimes.values()): + if runtime.node_id == node_id: + self._stop_locked(runtime) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..72d3248 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""WOV API routers.""" diff --git a/app/routers/apps.py b/app/routers/apps.py new file mode 100644 index 0000000..52713a9 --- /dev/null +++ b/app/routers/apps.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app.db import Database + +router = APIRouter(tags=["apps"]) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _get_db() -> Database: + from app.main import app + + return app.state.db + + +@router.get("/api/apps") +def list_apps(db: Database = Depends(_get_db)) -> list[dict]: + apps = [] + for workflow in db.list_workflows(): + if not workflow["published"]: + continue + latest = db.get_latest_workflow_version(workflow["id"]) + apps.append( + { + "id": workflow["id"], + "name": workflow["name"], + "description": workflow["description"], + "version": workflow["latest_version"], + "definition": latest["definition"] if latest else None, + } + ) + return apps + + +@router.post("/api/apps/{workflow_id}/runs") +async def create_run( + workflow_id: str, + file: UploadFile = File(...), + db: Database = Depends(_get_db), +) -> dict: + workflow = db.get_workflow(workflow_id) + if workflow is None or not workflow["published"]: + raise HTTPException(status_code=404, detail="published workflow not found") + + latest = db.get_latest_workflow_version(workflow_id) + if latest is None: + raise HTTPException(status_code=422, detail="workflow has no version") + + run_id = f"run_{uuid.uuid4().hex[:12]}" + filename = Path(file.filename or "upload.bin").name + from app.config import STORAGE_DIR + + input_dir = STORAGE_DIR / "uploads" / run_id + input_dir.mkdir(parents=True, exist_ok=True) + input_uri = input_dir / filename + content = await file.read() + input_uri.write_bytes(content) + + now = _now_iso() + db.create_run( + { + "id": run_id, + "workflow_id": workflow_id, + "workflow_version": latest["version"], + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_uri), + "created_at": now, + "updated_at": now, + } + ) + return { + "id": run_id, + "status": "QUEUED", + "progress": 0, + "artifacts": [], + } + + +@router.get("/api/runs") +def list_runs(db: Database = Depends(_get_db)) -> list[dict]: + return db.list_runs() + + +@router.get("/api/runs/{run_id}") +def get_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + run["artifacts"] = db.list_artifacts(run_id) + return run + + +@router.get("/api/runs/{run_id}/artifacts") +def list_run_artifacts(run_id: str, db: Database = Depends(_get_db)) -> list[dict]: + if db.get_run(run_id) is None: + raise HTTPException(status_code=404, detail="run not found") + return db.list_artifacts(run_id) + + +@router.get("/api/runs/{run_id}/artifacts/{artifact_name}") +def download_artifact( + run_id: str, + artifact_name: str, + db: Database = Depends(_get_db), +) -> FileResponse: + artifact = db.get_artifact(run_id, artifact_name) + if artifact is None: + raise HTTPException(status_code=404, detail="artifact not found") + path = Path(artifact["uri"]) + if not path.is_file(): + raise HTTPException(status_code=404, detail="artifact file missing") + return FileResponse( + path, + media_type=artifact["mime_type"], + filename=path.name, + ) diff --git a/app/routers/instances.py b/app/routers/instances.py new file mode 100644 index 0000000..36b20cd --- /dev/null +++ b/app/routers/instances.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.db import Database +from app.node_manager import NodeManager + +router = APIRouter(prefix="/api/admin/node-instances", tags=["node-instances"]) + + +def _get_db() -> Database: + from app.main import app + + return app.state.db + + +def _get_manager() -> NodeManager: + from app.main import app + + return app.state.node_manager + + +@router.get("") +def list_instances(db: Database = Depends(_get_db)) -> list[dict]: + return db.list_instances() + + +@router.post("/{instance_id}/stop") +def stop_instance( + instance_id: str, + db: Database = Depends(_get_db), + manager: NodeManager = Depends(_get_manager), +) -> dict: + manager.stop_instance(instance_id) + instance = next( + (item for item in db.list_instances() if item["id"] == instance_id), + None, + ) + if instance is None: + raise HTTPException(status_code=404, detail="instance not found") + return {"stopped": instance_id} diff --git a/app/routers/nodes.py b/app/routers/nodes.py new file mode 100644 index 0000000..5ed7f5c --- /dev/null +++ b/app/routers/nodes.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.db import Database +from app.node_manager import NodeManager +from app.schemas import InvokePayload, NodeCreate +from wov_sdk.models import InvokeRequest + +router = APIRouter(prefix="/api/admin/nodes", tags=["nodes"]) + + +def _get_db() -> Database: + from app.main import app + + return app.state.db + + +def _get_manager() -> NodeManager: + from app.main import app + + return app.state.node_manager + + +@router.post("") +def register_node(payload: NodeCreate, db: Database = Depends(_get_db)) -> dict: + manifest = payload.to_manifest() + try: + manifest.validate() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + db.upsert_node(manifest) + return manifest.to_dict() + + +@router.get("") +def list_nodes(db: Database = Depends(_get_db)) -> list[dict]: + return [manifest.to_dict() for manifest in db.list_nodes()] + + +@router.get("/{node_id}") +def get_node(node_id: str, db: Database = Depends(_get_db)) -> dict: + manifest = db.get_node(node_id) + if manifest is None: + raise HTTPException(status_code=404, detail="node not found") + return manifest.to_dict() + + +@router.delete("/{node_id}") +def delete_node( + node_id: str, + db: Database = Depends(_get_db), + manager: NodeManager = Depends(_get_manager), +) -> dict: + if db.get_node(node_id) is None: + raise HTTPException(status_code=404, detail="node not found") + manager.stop_all_for_node(node_id) + db.delete_node(node_id) + return {"deleted": node_id} + + +@router.post("/{node_id}/invoke") +def invoke_node( + node_id: str, + payload: InvokePayload, + manager: NodeManager = Depends(_get_manager), +) -> dict: + from app.config import STORAGE_DIR + + output_dir = ( + STORAGE_DIR / "runs" / payload.run_id / "steps" / node_id + ) + request = InvokeRequest( + run_id=payload.run_id, + node_instance_id="", + inputs=payload.inputs, + params=payload.params, + output_dir=str(output_dir), + ) + response = manager.invoke(node_id, request) + return response.to_dict() + + +@router.get("/{node_id}/instances") +def list_node_instances( + node_id: str, + db: Database = Depends(_get_db), +) -> list[dict]: + if db.get_node(node_id) is None: + raise HTTPException(status_code=404, detail="node not found") + return [ + instance + for instance in db.list_instances() + if instance["node_id"] == node_id + ] diff --git a/app/routers/workflows.py b/app/routers/workflows.py new file mode 100644 index 0000000..4b30c38 --- /dev/null +++ b/app/routers/workflows.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import re +import uuid + +from fastapi import APIRouter, Depends, HTTPException + +from app.db import Database +from app.schemas import WorkflowCreate +from wov_sdk.models import WorkflowDefinition + +router = APIRouter(prefix="/api/admin/workflows", tags=["workflows"]) + + +def _get_db() -> Database: + from app.main import app + + return app.state.db + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or uuid.uuid4().hex[:8] + + +def _validate_definition(raw: dict) -> WorkflowDefinition: + try: + definition = WorkflowDefinition.from_dict(raw) + definition.validate() + return definition + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("") +def list_workflows(db: Database = Depends(_get_db)) -> list[dict]: + return db.list_workflows() + + +@router.post("") +def create_workflow( + payload: WorkflowCreate, + db: Database = Depends(_get_db), +) -> dict: + definition = _validate_definition(payload.definition) + workflow_id = payload.id or _slugify(payload.name) + existing = db.get_workflow(workflow_id) + version = (existing or {}).get("latest_version", 0) + 1 + db.upsert_workflow( + { + "id": workflow_id, + "name": payload.name, + "description": payload.description, + "published": 0, + "latest_version": version, + } + ) + db.create_workflow_version(workflow_id, version, definition.to_dict()) + return { + "id": workflow_id, + "name": payload.name, + "description": payload.description, + "published": False, + "latest_version": version, + } + + +@router.get("/{workflow_id}") +def get_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + workflow = db.get_workflow(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail="workflow not found") + latest = db.get_latest_workflow_version(workflow_id) + workflow["latest_version_data"] = latest + return workflow + + +@router.delete("/{workflow_id}") +def delete_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + db.delete_workflow(workflow_id) + return {"deleted": workflow_id} + + +@router.post("/{workflow_id}/validate") +def validate_workflow( + workflow_id: str, + definition: dict, + db: Database = Depends(_get_db), +) -> dict: + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + parsed = _validate_definition(definition) + return {"valid": True, "node_ids": [node.id for node in parsed.nodes]} + + +@router.post("/{workflow_id}/publish") +def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + workflow = db.get_workflow(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail="workflow not found") + if workflow["latest_version"] == 0: + raise HTTPException(status_code=422, detail="workflow has no version") + db.upsert_workflow( + { + "id": workflow_id, + "name": workflow["name"], + "description": workflow["description"], + "published": 1, + "latest_version": workflow["latest_version"], + } + ) + return {"published": workflow_id} + + +@router.get("/{workflow_id}/versions") +def list_versions(workflow_id: str, db: Database = Depends(_get_db)) -> list[dict]: + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + return db.list_workflow_versions(workflow_id) diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..1b15033 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from wov_sdk.models import InvokeRequest, WorkflowDefinition + +from app.db import Database +from app.node_manager import NodeManager + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def topological_sort(definition: WorkflowDefinition) -> list[str]: + nodes = {node.id: node for node in definition.nodes} + indegree = {node_id: 0 for node_id in nodes} + dependents: dict[str, list[str]] = {node_id: [] for node_id in nodes} + + for edge in definition.edges: + if edge.from_node not in nodes or edge.to_node not in nodes: + raise ValueError(f"unknown edge: {edge.from_node} -> {edge.to_node}") + indegree[edge.to_node] += 1 + dependents[edge.from_node].append(edge.to_node) + + queue = [node_id for node_id, degree in indegree.items() if degree == 0] + ordered: list[str] = [] + while queue: + current = queue.pop(0) + ordered.append(current) + for dependent in dependents[current]: + indegree[dependent] -= 1 + if indegree[dependent] == 0: + queue.append(dependent) + + if len(ordered) != len(nodes): + raise ValueError("workflow contains a cycle") + return ordered + + +class WorkflowScheduler: + def __init__( + self, + db: Database, + node_manager: NodeManager, + storage_dir: Path, + interval_seconds: float = 1.0, + ) -> None: + self.db = db + self.node_manager = node_manager + self.storage_dir = storage_dir + self.interval_seconds = interval_seconds + self._thread: threading.Thread | None = None + self._stopping = False + + def start(self) -> None: + if self._thread is not None: + return + self._stopping = False + self._thread = threading.Thread( + target=self._loop, + name="wov-workflow-scheduler", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + self._stopping = True + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + def _loop(self) -> None: + while not self._stopping: + run = self.db.next_queued_run() + if run is not None: + self.execute_run(run["id"]) + else: + time.sleep(self.interval_seconds) + + def _resolve_ref( + self, + ref: str, + run_input_uri: str | None, + outputs_by_node: dict[str, dict[str, str]], + ) -> str | None: + if ref.startswith("input."): + return run_input_uri + node_id, separator, key = ref.partition(".") + if not separator: + return None + return outputs_by_node.get(node_id, {}).get(key) + + def execute_run(self, run_id: str) -> None: + run = self.db.get_run(run_id) + if run is None or run["status"] != "QUEUED": + return + + workflow = self.db.get_workflow(run["workflow_id"]) + if workflow is None: + self.db.update_run(run_id, status="FAILED", error="workflow not found", updated_at=_now_iso()) + return + + version = self.db.get_workflow_version(run["workflow_id"], run["workflow_version"]) + if version is None: + self.db.update_run(run_id, status="FAILED", error="workflow version not found", updated_at=_now_iso()) + return + + definition = WorkflowDefinition.from_dict(version["definition"]) + definition.validate() + ordered = topological_sort(definition) + outputs_by_node: dict[str, dict[str, str]] = {} + self.db.update_run(run_id, status="RUNNING", progress=0, updated_at=_now_iso()) + + try: + for index, node_id in enumerate(ordered): + node_spec = next(item for item in definition.nodes if item.id == node_id) + self.db.update_run( + run_id, + current_node_id=node_id, + progress=index / len(ordered), + updated_at=_now_iso(), + ) + invoke_inputs: dict[str, str] = {} + for input_name, ref in node_spec.inputs.items(): + value = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node) + if value is None: + raise ValueError(f"missing input {input_name} for node {node_id}") + invoke_inputs[input_name] = value + + output_dir = ( + self.storage_dir + / "runs" + / run_id + / "steps" + / node_id + ) + response = self.node_manager.invoke( + node_spec.node_type, + InvokeRequest( + run_id=run_id, + node_instance_id="", + inputs=invoke_inputs, + params=node_spec.params, + output_dir=str(output_dir), + ), + ) + if response.status != "completed": + raise RuntimeError(response.error or f"node {node_id} failed") + + outputs_by_node[node_id] = { + str(key): str(value) for key, value in response.outputs.items() + } + for key, uri in outputs_by_node[node_id].items(): + artifact = { + "run_id": run_id, + "node_id": node_id, + "name": f"{node_id}.{key}", + "uri": uri, + "mime_type": self._mime_type(uri), + "size": self._file_size(uri), + } + self.db.create_artifact(artifact) + + for alias, ref in definition.final_outputs.items(): + resolved = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node) + if resolved is not None: + self.db.create_artifact( + { + "run_id": run_id, + "node_id": ref.partition(".")[0], + "name": alias, + "uri": resolved, + "mime_type": self._mime_type(resolved), + "size": self._file_size(resolved), + } + ) + + self.db.update_run( + run_id, + status="COMPLETED", + current_node_id=None, + progress=1.0, + updated_at=_now_iso(), + ) + except Exception as exc: # noqa: BLE001 + self.db.update_run( + run_id, + status="FAILED", + error=str(exc), + updated_at=_now_iso(), + ) + + @staticmethod + def _mime_type(uri: str) -> str: + path = Path(uri) + suffix = path.suffix.lower() + return { + ".srt": "application/x-subrip", + ".ass": "text/plain", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".txt": "text/plain", + }.get(suffix, "application/octet-stream") + + @staticmethod + def _file_size(uri: str) -> int: + try: + return Path(uri).stat().st_size + except OSError: + return 0 diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..606da03 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +from wov_sdk.models import NodeManifest + + +class NodeCreate(BaseModel): + id: str = Field(min_length=1) + name: str = Field(min_length=1) + version: str = Field(min_length=1) + capability: str = Field(min_length=1) + command: list[str] = Field(min_length=1) + repo_dir: str = "." + env: dict[str, str] = Field(default_factory=dict) + input_schema: dict[str, Any] = Field(default_factory=dict) + output_schema: dict[str, Any] = Field(default_factory=dict) + max_concurrency: int = Field(default=1, ge=1) + idle_ttl_seconds: int = Field(default=300, ge=0) + health_timeout_seconds: int = Field(default=10, ge=1) + keep_warm: bool = False + + def to_manifest(self) -> NodeManifest: + return NodeManifest( + id=self.id, + name=self.name, + version=self.version, + capability=self.capability, + command=list(self.command), + repo_dir=self.repo_dir, + env=dict(self.env), + input_schema=dict(self.input_schema), + output_schema=dict(self.output_schema), + max_concurrency=self.max_concurrency, + idle_ttl_seconds=self.idle_ttl_seconds, + health_timeout_seconds=self.health_timeout_seconds, + keep_warm=self.keep_warm, + ) + + +class InvokePayload(BaseModel): + run_id: str = Field(default_factory=lambda: "run_admin") + inputs: dict[str, Any] = Field(default_factory=dict) + params: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowCreate(BaseModel): + id: str | None = None + name: str = Field(min_length=1) + description: str = "" + definition: dict[str, Any] diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..885ab62 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path + +from wov_sdk.models import NodeManifest, WorkflowDefinition, WorkflowEdge, WorkflowNode + +from app.db import Database + + +def seed_nodes(db: Database, workspace_root: Path) -> None: + for node_dir in sorted(workspace_root.glob("wov-node-*")): + manifest_path = node_dir / "node.manifest.json" + if not manifest_path.is_file(): + continue + manifest = NodeManifest.load(str(manifest_path)) + manifest.repo_dir = node_dir.name + db.upsert_node(manifest) + + +def seed_demo_workflow(db: Database) -> None: + if db.get_workflow("demo") is not None: + return + definition = WorkflowDefinition( + name="视频字幕生成", + version=1, + nodes=[ + WorkflowNode( + id="extract", + node_type="ffmpeg-extract", + params={"sample_rate": 16000, "channels": 1}, + inputs={"video_uri": "input.video_uri"}, + ), + WorkflowNode( + id="asr", + node_type="faster-whisper", + params={"language": "ja"}, + inputs={"audio_uri": "extract.audio_uri"}, + ), + WorkflowNode( + id="translate", + node_type="llm-translate", + params={"target_language": "zh-CN"}, + inputs={"srt_uri": "asr.srt_uri"}, + ), + WorkflowNode( + id="ass", + node_type="srt-to-dual-eye-ass", + params={"resolution": "3840x1920"}, + inputs={"cn_srt_uri": "translate.cn_srt_uri"}, + ), + ], + edges=[ + WorkflowEdge(from_node="extract", to_node="asr"), + WorkflowEdge(from_node="asr", to_node="translate"), + WorkflowEdge(from_node="translate", to_node="ass"), + ], + entry_inputs={"video_uri": "file"}, + final_outputs={ + "cn_srt": "translate.cn_srt_uri", + "ass": "ass.ass_uri", + }, + ) + db.upsert_workflow( + { + "id": "demo", + "name": definition.name, + "description": "上传视频,自动生成中文字幕和 VR 双眼 ASS。", + "published": 1, + "latest_version": 1, + } + ) + db.create_workflow_version("demo", 1, definition.to_dict()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..324a1d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "wov-api" +version = "0.1.0" +description = "WOV platform API" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "uvicorn", + "python-multipart", + "wov-sdk", +] + +[tool.uv.sources] +wov-sdk = { path = "../wov-sdk" } + +[dependency-groups] +dev = ["pytest", "pytest-cov", "httpx"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "--cov=app --cov-report=term-missing --cov-fail-under=100" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..08a569a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import atexit +import os +import shutil +import tempfile +from pathlib import Path + +TEST_ROOT = Path(tempfile.mkdtemp(prefix="wov-api-test-")) +os.environ["WOV_DATA_DIR"] = str(TEST_ROOT / "data") +os.environ["WOV_DB_PATH"] = str(TEST_ROOT / "data" / "wov.db") +os.environ["WOV_STORAGE_DIR"] = str(TEST_ROOT / "storage") +os.environ["WOV_REAP_INTERVAL_SECONDS"] = "0.1" +os.environ["WOV_READY_TIMEOUT_SECONDS"] = "2" +os.environ["WOV_AUTO_SEED"] = "0" +os.environ["WOV_SCHEDULER_ENABLED"] = "0" + + +def _cleanup() -> None: + shutil.rmtree(TEST_ROOT, ignore_errors=True) + + +atexit.register(_cleanup) diff --git a/tests/fixtures/bad_node.py b/tests/fixtures/bad_node.py new file mode 100644 index 0000000..2b766eb --- /dev/null +++ b/tests/fixtures/bad_node.py @@ -0,0 +1,17 @@ +import http.server +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(500) + self.end_headers() + + def log_message(self, format, *args) -> None: + return + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True) +threading.Thread(target=server.serve_forever, daemon=True).start() +threading.Event().wait() diff --git a/tests/fixtures/bad_ready_node.py b/tests/fixtures/bad_ready_node.py new file mode 100644 index 0000000..c30490b --- /dev/null +++ b/tests/fixtures/bad_ready_node.py @@ -0,0 +1,4 @@ +import time + +print("WOV_NODE_READY", flush=True) +time.sleep(5) diff --git a/tests/fixtures/bad_status_node.py b/tests/fixtures/bad_status_node.py new file mode 100644 index 0000000..f12887e --- /dev/null +++ b/tests/fixtures/bad_status_node.py @@ -0,0 +1,17 @@ +import http.server +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(201) + self.end_headers() + + def log_message(self, format, *args) -> None: + return + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True) +threading.Thread(target=server.serve_forever, daemon=True).start() +threading.Event().wait() diff --git a/tests/fixtures/failing_node.py b/tests/fixtures/failing_node.py new file mode 100644 index 0000000..54b2cef --- /dev/null +++ b/tests/fixtures/failing_node.py @@ -0,0 +1,34 @@ +import http.server +import json +import os +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/health": + self.send_response(200) + self.end_headers() + return + self.send_response(404) + self.end_headers() + + def do_POST(self) -> None: + self.send_response(500) + self.send_header("Content-Type", "application/json") + if os.environ.get("WOV_FAIL_INVALID_JSON") == "1": + body = b"not-json" + else: + body = json.dumps({"status": "failed", "error": "boom"}).encode("utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args) -> None: + return + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True) +threading.Thread(target=server.serve_forever, daemon=True).start() +threading.Event().wait() diff --git a/tests/fixtures/stderr_node.py b/tests/fixtures/stderr_node.py new file mode 100644 index 0000000..74bb5cd --- /dev/null +++ b/tests/fixtures/stderr_node.py @@ -0,0 +1,20 @@ +import http.server +import sys +import threading + +print("stderr warning", file=sys.stderr, flush=True) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + + def log_message(self, format, *args) -> None: + return + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True) +threading.Thread(target=server.serve_forever, daemon=True).start() +threading.Event().wait() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e93cc48 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,97 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import app + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +ECHO_MANIFEST = json.loads( + (WORKSPACE / "wov-node-echo" / "node.manifest.json").read_text(encoding="utf-8") +) + + +def test_health() -> None: + with TestClient(app) as client: + root = client.get("/", follow_redirects=False) + assert root.status_code == 200 + assert "WOV 应用中心" in root.text + + docs = client.get("/docs") + assert docs.status_code == 200 + + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["service"] == "wov-api" + + +def test_register_validation_errors() -> None: + with TestClient(app) as client: + assert client.post("/api/admin/nodes", json={}).status_code == 422 + invalid = dict(ECHO_MANIFEST, id="") + assert client.post("/api/admin/nodes", json=invalid).status_code == 422 + invalid = dict(ECHO_MANIFEST, repo_dir="") + assert client.post("/api/admin/nodes", json=invalid).status_code == 422 + invalid = dict(ECHO_MANIFEST, max_concurrency=0) + assert client.post("/api/admin/nodes", json=invalid).status_code == 422 + + +def test_node_lifecycle_and_invoke() -> None: + with TestClient(app) as client: + registered = client.post("/api/admin/nodes", json=ECHO_MANIFEST) + assert registered.status_code == 200 + assert registered.json()["id"] == "echo" + + nodes = client.get("/api/admin/nodes") + assert nodes.status_code == 200 + assert [node["id"] for node in nodes.json()] == ["echo"] + + node = client.get("/api/admin/nodes/echo") + assert node.status_code == 200 + assert node.json()["capability"] == "echo" + + assert client.get("/api/admin/nodes/missing").status_code == 404 + + invoked = client.post( + "/api/admin/nodes/echo/invoke", + json={ + "run_id": "run_api", + "inputs": {"text": "api test"}, + "params": {}, + }, + ) + assert invoked.status_code == 200 + assert invoked.json()["status"] == "completed" + assert invoked.json()["outputs"]["text"] == "api test" + + invoked_default = client.post( + "/api/admin/nodes/echo/invoke", + json={"inputs": {}}, + ) + assert invoked_default.status_code == 200 + assert invoked_default.json()["status"] == "completed" + assert invoked_default.json()["outputs"]["text"] == "echo" + + instances = client.get("/api/admin/node-instances") + assert instances.status_code == 200 + assert len(instances.json()) >= 1 + instance_id = instances.json()[0]["id"] + + node_instances = client.get("/api/admin/nodes/echo/instances") + assert node_instances.status_code == 200 + assert node_instances.json()[0]["id"] == instance_id + + assert client.get("/api/admin/nodes/missing/instances").status_code == 404 + + stopped = client.post(f"/api/admin/node-instances/{instance_id}/stop") + assert stopped.status_code == 200 + assert stopped.json()["stopped"] == instance_id + + assert client.post("/api/admin/node-instances/missing/stop").status_code == 404 + + deleted = client.delete("/api/admin/nodes/echo") + assert deleted.status_code == 200 + assert deleted.json()["deleted"] == "echo" + + assert client.get("/api/admin/nodes/echo").status_code == 404 + assert client.delete("/api/admin/nodes/echo").status_code == 404 diff --git a/tests/test_apps_api.py b/tests/test_apps_api.py new file mode 100644 index 0000000..08441d4 --- /dev/null +++ b/tests/test_apps_api.py @@ -0,0 +1,141 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import app + +WORKSPACE = Path(__file__).resolve().parent.parent.parent + + +def _create_published_echo_workflow(client) -> str: + definition = { + "name": "echo-flow", + "version": 1, + "nodes": [ + { + "id": "step", + "node_type": "echo", + "inputs": {"file_uri": "input.video_uri"}, + } + ], + "edges": [], + "entry_inputs": {"video_uri": "file"}, + "final_outputs": {"result": "step.file_uri"}, + } + manifest = json.loads( + (WORKSPACE / "wov-node-echo" / "node.manifest.json").read_text(encoding="utf-8") + ) + assert client.post("/api/admin/nodes", json=manifest).status_code == 200 + + client.post( + "/api/admin/workflows", + json={ + "id": "echo-app", + "name": "Echo App", + "description": "upload a file", + "definition": definition, + }, + ) + client.post("/api/admin/workflows/echo-app/publish") + return "echo-app" + + +def test_upload_run_progress_and_download() -> None: + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + apps = client.get("/api/apps") + assert apps.status_code == 200 + assert any(item["id"] == workflow_id for item in apps.json()) + + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello from upload", "text/plain")}, + ) + assert uploaded.status_code == 200 + run_id = uploaded.json()["id"] + assert uploaded.json()["status"] == "QUEUED" + + run = client.get(f"/api/runs/{run_id}") + assert run.status_code == 200 + assert run.json()["input_uri"].endswith("sample.txt") + assert run.json()["artifacts"] == [] + + scheduler = app.state.scheduler + scheduler.execute_run(run_id) + + completed = client.get(f"/api/runs/{run_id}") + assert completed.status_code == 200 + assert completed.json()["status"] == "COMPLETED" + artifact_names = [item["name"] for item in completed.json()["artifacts"]] + assert "result" in artifact_names + + artifacts = client.get(f"/api/runs/{run_id}/artifacts") + assert artifacts.status_code == 200 + assert len(artifacts.json()) >= 1 + + downloaded = client.get(f"/api/runs/{run_id}/artifacts/result") + assert downloaded.status_code == 200 + assert b"hello from upload" in downloaded.content + + assert client.get(f"/api/runs/{run_id}/artifacts/missing").status_code == 404 + assert client.get("/api/runs/missing").status_code == 404 + assert client.get("/api/runs/missing/artifacts").status_code == 404 + + db = app.state.db + db.create_artifact( + { + "run_id": run_id, + "node_id": "step", + "name": "missing-file", + "uri": str(Path(__file__).resolve().parent / "not-exists.bin"), + "mime_type": "text/plain", + "size": 0, + } + ) + assert client.get(f"/api/runs/{run_id}/artifacts/missing-file").status_code == 404 + + runs = client.get("/api/runs") + assert runs.status_code == 200 + assert any(item["id"] == run_id for item in runs.json()) + + +def test_upload_rejects_unpublished_workflow() -> None: + with TestClient(app) as client: + client.post( + "/api/admin/workflows", + json={ + "id": "draft", + "name": "Draft", + "definition": { + "name": "Draft", + "version": 1, + "nodes": [], + "edges": [], + }, + }, + ) + response = client.post( + "/api/apps/draft/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 404 + + response = client.post( + "/api/apps/missing/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 404 + + +def test_upload_rejects_workflow_without_version() -> None: + with TestClient(app) as client: + db = app.state.db + db.upsert_workflow( + {"id": "empty", "name": "Empty", "published": 1, "latest_version": 0} + ) + response = client.post( + "/api/apps/empty/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 422 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..27fec59 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,144 @@ +from pathlib import Path + +from app.db import Database +from wov_sdk.models import NodeManifest + + +def manifest() -> NodeManifest: + return NodeManifest( + id="echo", + name="Echo", + version="1.0.0", + capability="echo", + command=["python", "-m", "echo"], + repo_dir="wov-node-echo", + ) + + +def test_node_crud(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + assert db.get_node("echo") is None + assert db.list_nodes() == [] + + db.upsert_node(manifest()) + assert db.get_node("echo") == manifest() + assert db.list_nodes() == [manifest()] + + updated = manifest() + updated.version = "1.1.0" + db.upsert_node(updated) + assert db.get_node("echo").version == "1.1.0" + + db.delete_node("echo") + assert db.get_node("echo") is None + + +def test_instance_crud(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + db.upsert_node(manifest()) + instance = { + "id": "ni_1", + "node_id": "echo", + "status": "ready", + "pid": 123, + "address": "http://127.0.0.1:1", + "started_at": "2026-01-01T00:00:00+00:00", + "last_used_at": "2026-01-01T00:00:00+00:00", + "busy_since": None, + "error": None, + } + db.upsert_instance(instance) + assert db.list_instances() == [instance] + + instance["status"] = "stopped" + db.upsert_instance(instance) + assert db.list_instances()[0]["status"] == "stopped" + + db.delete_instance("ni_1") + assert db.list_instances() == [] + + +def test_workflow_crud(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + workflow = { + "id": "demo", + "name": "Demo", + "description": "desc", + "published": 0, + "latest_version": 0, + } + db.upsert_workflow(workflow) + assert db.get_workflow("demo")["name"] == "Demo" + assert [item["id"] for item in db.list_workflows()] == ["demo"] + + db.upsert_workflow({**workflow, "published": 1, "latest_version": 1}) + assert db.get_workflow("demo")["published"] == 1 + + db.delete_workflow("demo") + assert db.get_workflow("demo") is None + + +def test_workflow_versions(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + db.upsert_workflow( + {"id": "demo", "name": "Demo", "published": 1, "latest_version": 2} + ) + definition = {"name": "Demo", "version": 1, "nodes": [], "edges": []} + db.create_workflow_version("demo", 1, definition) + db.create_workflow_version("demo", 2, {**definition, "version": 2}) + + latest = db.get_latest_workflow_version("demo") + assert latest["version"] == 2 + assert latest["definition"]["version"] == 2 + + version = db.get_workflow_version("demo", 1) + assert version["version"] == 1 + assert db.get_workflow_version("demo", 99) is None + assert len(db.list_workflow_versions("demo")) == 2 + + empty_db = Database(tmp_path / "empty.db") + assert empty_db.get_latest_workflow_version("missing") is None + + +def test_run_and_artifact_crud(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "demo", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": "in.txt", + "created_at": now, + "updated_at": now, + } + ) + assert db.get_run("run_1")["status"] == "QUEUED" + assert db.next_queued_run()["id"] == "run_1" + + db.update_run("run_1", status="RUNNING", progress=0.5, updated_at=now) + db.update_run("run_1") + assert db.get_run("run_1")["status"] == "RUNNING" + assert db.get_run("run_1")["progress"] == 0.5 + assert db.next_queued_run() is None + assert len(db.list_runs()) == 1 + + db.create_artifact( + { + "run_id": "run_1", + "node_id": "echo", + "name": "result", + "uri": "out.txt", + "mime_type": "text/plain", + "size": 3, + } + ) + assert db.get_artifact("run_1", "result")["uri"] == "out.txt" + assert db.get_artifact("run_1", "missing") is None + assert len(db.list_artifacts("run_1")) == 1 + + db.delete_run_artifacts("run_1") + assert db.list_artifacts("run_1") == [] diff --git a/tests/test_node_manager.py b/tests/test_node_manager.py new file mode 100644 index 0000000..9b156b1 --- /dev/null +++ b/tests/test_node_manager.py @@ -0,0 +1,394 @@ +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from app.db import Database +from app.node_manager import NodeManager, NodeRuntime, _idle_seconds, _now_iso +from wov_sdk.models import InvokeRequest, NodeManifest + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +_BASE_ECHO_MANIFEST = NodeManifest.load(WORKSPACE / "wov-node-echo" / "node.manifest.json") +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +@pytest.fixture +def db(tmp_path) -> Database: + return Database(tmp_path / "wov.db") + + +def _register(db: Database, manifest: NodeManifest) -> None: + db.upsert_node(manifest) + + +def echo_manifest() -> NodeManifest: + return NodeManifest.from_dict(_BASE_ECHO_MANIFEST.to_dict()) + + +def test_time_helpers() -> None: + now = _now_iso() + assert datetime.fromisoformat(now) + assert _idle_seconds(now) < 0.1 + old = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() + assert _idle_seconds(old) >= 9 + assert _idle_seconds("") == 0 + assert _idle_seconds("invalid") == 0 + + +def test_resolve_command_and_env(db: Database) -> None: + manager = NodeManager(db) + python_command = NodeManifest( + id="x", + name="x", + version="1", + capability="x", + command=["python", "-m", "x"], + ) + assert manager._resolve_command(python_command)[0] == sys.executable + + fixed_command = NodeManifest( + id="x", + name="x", + version="1", + capability="x", + command=["node", "index.js"], + ) + assert manager._resolve_command(fixed_command) == ["node", "index.js"] + + env = manager._node_env(python_command) + assert "wov-sdk" in env["PYTHONPATH"] + + +def test_resolve_command_prefers_node_venv(db: Database, tmp_path, monkeypatch) -> None: + monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path) + repo = tmp_path / "wov-node-demo" + python_exe = repo / ".venv" / "Scripts" / "python.exe" + python_exe.parent.mkdir(parents=True) + python_exe.write_bytes(b"") + manifest = NodeManifest( + id="x", + name="x", + version="1", + capability="x", + command=["python", "-m", "x"], + repo_dir="wov-node-demo", + ) + manager = NodeManager(db) + assert manager._resolve_command(manifest)[0] == str(python_exe) + + python_exe.unlink() + unix_python = repo / ".venv" / "bin" / "python" + unix_python.parent.mkdir(parents=True) + unix_python.write_bytes(b"") + assert manager._resolve_command(manifest)[0] == str(unix_python) + + +def test_acquire_unregistered(db: Database) -> None: + manager = NodeManager(db) + with pytest.raises(ValueError): + manager.acquire("missing") + + +def test_start_missing_repo(db: Database) -> None: + manifest = echo_manifest() + manifest.repo_dir = "missing-repo" + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError, match="repo_dir not found"): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_start_command_not_found(db: Database) -> None: + manifest = echo_manifest() + manifest.command = ["definitely-not-a-real-wov-command"] + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_start_no_ready_timeout(db: Database, monkeypatch) -> None: + monkeypatch.setattr("app.node_manager.NODE_READY_TIMEOUT_SECONDS", 0.2) + manifest = echo_manifest() + manifest.command = ["python", "-c", "import time; time.sleep(0.5)"] + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError, match="did not report readiness"): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_start_bad_ready_line(db: Database) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "bad_ready_node.py")] + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError, match="did not report readiness"): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_start_health_check_failure(db: Database) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "bad_node.py")] + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError, match="health check failed"): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_start_health_check_bad_status(db: Database) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "bad_status_node.py")] + _register(db, manifest) + manager = NodeManager(db) + with pytest.raises(RuntimeError, match="health check returned 201"): + manager.acquire("echo") + assert db.list_instances()[0]["status"] == "error" + + +def test_stderr_is_captured(db: Database) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "stderr_node.py")] + _register(db, manifest) + manager = NodeManager(db) + try: + runtime, _ = manager.acquire("echo") + time.sleep(0.2) + runtime = next(iter(manager._runtimes.values())) + assert "stderr warning" in runtime.stderr_tail + manager.release(runtime.instance_id) + finally: + manager.shutdown() + + +def test_acquire_reuses_ready_instance(db: Database) -> None: + _register(db, echo_manifest()) + manager = NodeManager(db) + try: + first, first_address = manager.acquire("echo") + manager.release(first.instance_id) + second, second_address = manager.acquire("echo") + assert first.instance_id == second.instance_id + assert first_address == second_address + manager.release(second.instance_id) + finally: + manager.shutdown() + + +def test_invoke_success(db: Database, tmp_path) -> None: + _register(db, echo_manifest()) + manager = NodeManager(db) + try: + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_ok", + node_instance_id="", + inputs={"text": "real path"}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "completed" + assert response.outputs["text"] == "real path" + finally: + manager.shutdown() + + +def test_invoke_http_error(db: Database, tmp_path) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")] + _register(db, manifest) + manager = NodeManager(db) + try: + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_fail", + node_instance_id="", + inputs={}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "failed" + assert response.error == "boom" + finally: + manager.shutdown() + + +def test_invoke_http_error_invalid_json(db: Database, tmp_path) -> None: + manifest = echo_manifest() + manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")] + manifest.env["WOV_FAIL_INVALID_JSON"] = "1" + _register(db, manifest) + manager = NodeManager(db) + try: + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_bad_json", + node_instance_id="", + inputs={}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "failed" + assert "node returned 500" in response.error + finally: + manager.shutdown() + + +def test_invoke_network_error(db: Database, tmp_path) -> None: + _register(db, echo_manifest()) + manager = NodeManager(db) + try: + runtime, _ = manager.acquire("echo") + manager.release(runtime.instance_id) + runtime.process.terminate() + runtime.process.wait(timeout=5) + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_net", + node_instance_id="", + inputs={}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "failed" + finally: + manager.shutdown() + + +def test_release_unknown(db: Database) -> None: + manager = NodeManager(db) + manager.release("missing") + + +def test_stop_unknown_instance(db: Database) -> None: + manager = NodeManager(db) + manager.stop_instance("missing") + + +def test_stop_all_for_node(db: Database) -> None: + _register(db, echo_manifest()) + manager = NodeManager(db) + runtime, _ = manager.acquire("echo") + manager.stop_all_for_node("echo") + assert runtime.status == "stopped" + assert runtime.instance_id not in manager._runtimes + + +def test_stop_locked_without_process(db: Database) -> None: + _register(db, echo_manifest()) + manager = NodeManager(db) + runtime = NodeRuntime( + instance_id="ni_static", + node_id="echo", + manifest=echo_manifest(), + status="ready", + ) + manager._runtimes[runtime.instance_id] = runtime + manager.stop_instance(runtime.instance_id) + assert runtime.status == "stopped" + assert runtime.instance_id not in manager._runtimes + + +def test_stop_process_already_exited() -> None: + class FakeProcess: + def poll(self): + return 0 + + manager = object.__new__(NodeManager) + manager._stop_process(FakeProcess()) + + +def test_stop_process_timeout() -> None: + class FakeProcess: + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def wait(self, timeout): + if not self.killed: + raise subprocess.TimeoutExpired("fake", timeout) + + def kill(self): + self.killed = True + + fake = FakeProcess() + fake.killed = False + manager = object.__new__(NodeManager) + manager._stop_process(fake) + assert fake.terminated + assert fake.killed + + +def test_reaper_recycles_idle(db: Database, tmp_path) -> None: + manifest = echo_manifest() + manifest.idle_ttl_seconds = 0 + _register(db, manifest) + manager = NodeManager(db) + manager.start_reaper() + try: + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_reap", + node_instance_id="", + inputs={"text": "reap"}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "completed" + time.sleep(0.4) + assert db.list_instances()[0]["status"] == "stopped" + finally: + manager.shutdown() + + +def test_reaper_keeps_warm(db: Database, tmp_path) -> None: + manifest = echo_manifest() + manifest.idle_ttl_seconds = 0 + manifest.keep_warm = True + _register(db, manifest) + manager = NodeManager(db) + manager.start_reaper() + try: + response = manager.invoke( + "echo", + InvokeRequest( + run_id="run_warm", + node_instance_id="", + inputs={"text": "warm"}, + output_dir=str(tmp_path), + ), + ) + assert response.status == "completed" + time.sleep(0.4) + assert db.list_instances()[0]["status"] == "ready" + finally: + manager.shutdown() + + +def test_reaper_keeps_busy(db: Database) -> None: + manifest = echo_manifest() + manifest.idle_ttl_seconds = 0 + _register(db, manifest) + manager = NodeManager(db) + manager.start_reaper() + try: + runtime, _ = manager.acquire("echo") + time.sleep(0.4) + assert runtime.status == "ready" + manager.release(runtime.instance_id) + finally: + manager.shutdown() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..34ed02f --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,390 @@ +import time +from pathlib import Path + +import pytest + +from app.db import Database +from app.node_manager import NodeManager +from app.scheduler import WorkflowScheduler, topological_sort +from wov_sdk.models import ( + NodeManifest, + WorkflowDefinition, + WorkflowEdge, + WorkflowNode, +) + + +def _db(tmp_path) -> Database: + return Database(tmp_path / "wov.db") + + +def _echo_definition() -> WorkflowDefinition: + return WorkflowDefinition( + name="echo-flow", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="echo", + inputs={"file_uri": "input.video_uri"}, + ) + ], + edges=[], + entry_inputs={"video_uri": "file"}, + final_outputs={"result": "step.file_uri"}, + ) + + +def test_topological_sort() -> None: + definition = WorkflowDefinition( + name="dag", + version=1, + nodes=[ + WorkflowNode(id="a", node_type="x"), + WorkflowNode(id="b", node_type="x"), + WorkflowNode(id="c", node_type="x"), + ], + edges=[ + WorkflowEdge(from_node="a", to_node="b"), + WorkflowEdge(from_node="a", to_node="c"), + ], + ) + order = topological_sort(definition) + assert order.index("a") < order.index("b") + assert order.index("a") < order.index("c") + + cycle = WorkflowDefinition( + name="cycle", + version=1, + nodes=[ + WorkflowNode(id="a", node_type="x"), + WorkflowNode(id="b", node_type="x"), + ], + edges=[ + WorkflowEdge(from_node="a", to_node="b"), + WorkflowEdge(from_node="b", to_node="a"), + ], + ) + with pytest.raises(ValueError, match="cycle"): + topological_sort(cycle) + + with pytest.raises(ValueError, match="unknown edge"): + topological_sort( + WorkflowDefinition( + name="bad", + version=1, + nodes=[WorkflowNode(id="a", node_type="x")], + edges=[WorkflowEdge(from_node="a", to_node="missing")], + ) + ) + + +def test_execute_echo_workflow(tmp_path) -> None: + db = _db(tmp_path) + input_file = tmp_path / "input.txt" + input_file.write_text("hello scheduler", encoding="utf-8") + manifest = NodeManifest.load( + Path(__file__).resolve().parent.parent.parent / "wov-node-echo" / "node.manifest.json" + ) + db.upsert_node(manifest) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _echo_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_1") + finally: + manager.shutdown() + + run = db.get_run("run_1") + assert run["status"] == "COMPLETED" + artifacts = db.list_artifacts("run_1") + assert {item["name"] for item in artifacts} == {"step.text", "step.file_uri", "result"} + + +def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None: + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_missing", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + monkeypatch.setattr(db, "get_workflow", lambda workflow_id: None) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_missing") + finally: + manager.shutdown() + assert db.get_run("run_missing")["status"] == "FAILED" + + +def test_execute_run_missing_version(tmp_path) -> None: + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_version", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_version") + finally: + manager.shutdown() + assert db.get_run("run_version")["status"] == "FAILED" + + +def test_execute_run_missing_node(tmp_path) -> None: + db = _db(tmp_path) + definition = WorkflowDefinition( + name="bad", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="missing-node", + inputs={"text": "input.video_uri"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_node", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_node") + finally: + manager.shutdown() + assert db.get_run("run_node")["status"] == "FAILED" + + +def test_resolve_ref_and_mime(tmp_path) -> None: + db = _db(tmp_path) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + assert scheduler._resolve_ref("input.video", "in.mp4", {}) == "in.mp4" + assert ( + scheduler._resolve_ref( + "a.out", None, {"a": {"out": "result.txt"}} + ) + == "result.txt" + ) + assert scheduler._resolve_ref("a.out", None, {}) is None + assert scheduler._resolve_ref("nodot", "in.mp4", {}) is None + assert scheduler._mime_type("x.srt") == "application/x-subrip" + assert scheduler._mime_type("x.ass") == "text/plain" + assert scheduler._mime_type("x.wav") == "audio/wav" + assert scheduler._mime_type("x.mp4") == "video/mp4" + assert scheduler._mime_type("x.txt") == "text/plain" + assert scheduler._mime_type("x.bin") == "application/octet-stream" + existing = tmp_path / "existing.txt" + existing.write_text("x", encoding="utf-8") + assert scheduler._file_size(str(existing)) == 1 + missing = tmp_path / "missing.bin" + assert scheduler._file_size(str(missing)) == 0 + + +def test_execute_unknown_or_non_queued_run(tmp_path) -> None: + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_done", + "workflow_id": "flow", + "workflow_version": 1, + "status": "COMPLETED", + "progress": 1, + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("missing") + scheduler.execute_run("run_done") + finally: + manager.shutdown() + assert db.get_run("run_done")["status"] == "COMPLETED" + + +def test_execute_missing_input(tmp_path) -> None: + db = _db(tmp_path) + definition = WorkflowDefinition( + name="missing-input", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="echo", + inputs={"text": "missing.output"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_input", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_input") + finally: + manager.shutdown() + assert db.get_run("run_input")["status"] == "FAILED" + + +def test_execute_node_failed_response(tmp_path) -> None: + db = _db(tmp_path) + manifest = NodeManifest( + id="fail-node", + name="Fail", + version="1", + capability="echo", + repo_dir="wov-node-echo", + command=["python", "-u", str(Path(__file__).parent / "fixtures" / "failing_node.py")], + ) + db.upsert_node(manifest) + definition = WorkflowDefinition( + name="fail-flow", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="fail-node", + inputs={"text": "input.video_uri"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_fail_node", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage") + try: + scheduler.execute_run("run_fail_node") + finally: + manager.shutdown() + assert db.get_run("run_fail_node")["status"] == "FAILED" + + +def test_scheduler_start_stop_loop(tmp_path) -> None: + db = _db(tmp_path) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage", interval_seconds=0.05) + scheduler.start() + try: + scheduler.start() + time.sleep(0.15) + finally: + scheduler.stop() + assert scheduler._thread is None + + +def test_scheduler_background_executes_queued_run(tmp_path) -> None: + db = _db(tmp_path) + input_file = tmp_path / "input.txt" + input_file.write_text("background", encoding="utf-8") + manifest = NodeManifest.load( + Path(__file__).resolve().parent.parent.parent / "wov-node-echo" / "node.manifest.json" + ) + db.upsert_node(manifest) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _echo_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_bg", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + manager = NodeManager(db) + scheduler = WorkflowScheduler(db, manager, tmp_path / "storage", interval_seconds=0.05) + scheduler.start() + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if db.get_run("run_bg")["status"] in {"COMPLETED", "FAILED"}: + break + time.sleep(0.1) + finally: + scheduler.stop() + manager.shutdown() + assert db.get_run("run_bg")["status"] == "COMPLETED" diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 0000000..6307132 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.db import Database +from app.main import app +from app.seed import seed_demo_workflow, seed_nodes + +WORKSPACE = Path(__file__).resolve().parent.parent.parent + + +def test_seed_nodes_and_demo_workflow(tmp_path) -> None: + db = Database(tmp_path / "wov.db") + seed_nodes(db, WORKSPACE) + node_ids = [node.id for node in db.list_nodes()] + assert "echo" in node_ids + + seed_demo_workflow(db) + workflow = db.get_workflow("demo") + assert workflow is not None + assert workflow["published"] == 1 + latest = db.get_latest_workflow_version("demo") + assert latest["definition"]["name"] == "视频字幕生成" + + seed_demo_workflow(db) + assert len(db.list_workflow_versions("demo")) == 1 + + empty_workspace = tmp_path / "empty" + (empty_workspace / "wov-node-empty").mkdir(parents=True) + seed_nodes(db, empty_workspace) + + +def test_lifespan_with_seed_and_scheduler(monkeypatch) -> None: + monkeypatch.setenv("WOV_AUTO_SEED", "1") + monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1") + with TestClient(app) as client: + apps = client.get("/api/apps") + assert apps.status_code == 200 + assert any(item["id"] == "demo" for item in apps.json()) diff --git a/tests/test_uvicorn_smoke.py b/tests/test_uvicorn_smoke.py new file mode 100644 index 0000000..2038a6f --- /dev/null +++ b/tests/test_uvicorn_smoke.py @@ -0,0 +1,27 @@ +import threading +import time +import urllib.request + +from uvicorn import Config, Server + +from app.main import app + + +def test_uvicorn_serves_app_over_real_socket() -> None: + config = Config(app=app, host="127.0.0.1", port=0, log_level="error") + server = Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + try: + deadline = time.monotonic() + 10 + while not server.started and time.monotonic() < deadline: + time.sleep(0.05) + assert server.started + + port = server.servers[0].sockets[0].getsockname()[1] + with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as response: + assert response.status == 200 + assert b'"wov-api"' in response.read() + finally: + server.should_exit = True + thread.join(timeout=10) diff --git a/tests/test_workflow_api.py b/tests/test_workflow_api.py new file mode 100644 index 0000000..47f6a52 --- /dev/null +++ b/tests/test_workflow_api.py @@ -0,0 +1,107 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +def definition() -> dict: + return { + "name": "echo-flow", + "version": 1, + "nodes": [ + { + "id": "step", + "node_type": "echo", + "inputs": {"file_uri": "input.video_uri"}, + } + ], + "edges": [], + "entry_inputs": {"video_uri": "file"}, + "final_outputs": {"result": "step.file_uri"}, + } + + +def test_workflow_crud_and_publish() -> None: + with TestClient(app) as client: + created = client.post( + "/api/admin/workflows", + json={ + "id": "echo-flow", + "name": "Echo Flow", + "description": "demo", + "definition": definition(), + }, + ) + assert created.status_code == 200 + assert created.json()["id"] == "echo-flow" + + assert client.get("/api/admin/workflows").status_code == 200 + assert client.get("/api/admin/workflows/echo-flow").status_code == 200 + assert client.get("/api/admin/workflows/missing").status_code == 404 + + validated = client.post( + "/api/admin/workflows/echo-flow/validate", + json=definition(), + ) + assert validated.status_code == 200 + assert validated.json()["valid"] is True + assert client.post( + "/api/admin/workflows/missing/validate", + json=definition(), + ).status_code == 404 + + published = client.post("/api/admin/workflows/echo-flow/publish") + assert published.status_code == 200 + assert published.json()["published"] == "echo-flow" + assert client.post("/api/admin/workflows/missing/publish").status_code == 404 + + versions = client.get("/api/admin/workflows/echo-flow/versions") + assert versions.status_code == 200 + assert len(versions.json()) == 1 + assert client.get("/api/admin/workflows/missing/versions").status_code == 404 + + assert client.delete("/api/admin/workflows/echo-flow").status_code == 200 + assert client.delete("/api/admin/workflows/echo-flow").status_code == 404 + + +def test_workflow_slug_without_id() -> None: + with TestClient(app) as client: + created = client.post( + "/api/admin/workflows", + json={ + "name": "Echo Flow", + "definition": definition(), + }, + ) + assert created.status_code == 200 + assert created.json()["id"] == "echo-flow" + + +def test_workflow_validation_error() -> None: + with TestClient(app) as client: + response = client.post( + "/api/admin/workflows", + json={ + "id": "bad", + "name": "Bad", + "definition": { + "name": "Bad", + "version": 1, + "nodes": [ + {"id": "a", "node_type": "x"}, + {"id": "a", "node_type": "y"}, + ], + "edges": [], + }, + }, + ) + assert response.status_code == 422 + + +def test_publish_workflow_without_version() -> None: + with TestClient(app) as client: + db = app.state.db + db.upsert_workflow( + {"id": "empty", "name": "Empty", "published": 0, "latest_version": 0} + ) + response = client.post("/api/admin/workflows/empty/publish") + assert response.status_code == 422 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..07d1d48 --- /dev/null +++ b/uv.lock @@ -0,0 +1,584 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[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.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[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 = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[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 = "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 = "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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "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 = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "starlette" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/aa/bf8ead9ed9d965a9290d291c4ac2d30ac1ae1f9b05a1a0ffdf1a05ac8ac6/starlette-1.5.0.tar.gz", hash = "sha256:7d5f63a7e4981a9587fc2a0fbc44cfb6cc4c9181d8c26d7e948871ed5da8c3df", size = 2711789, upload-time = "2026-08-08T07:23:16.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/6a/a9ff0b4b7603f9498fa419b63ff09e182536ff2f739c85df7f38c0b46781/starlette-1.5.0-py3-none-any.whl", hash = "sha256:9ca76b47375e56f279d7c66651e801ef11167e58557c429be7ec55702d81d4bb", size = 74328, upload-time = "2026-08-08T07:23:14.675Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[[package]] +name = "wov-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn" }, + { name = "wov-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn" }, + { name = "wov-sdk", directory = "../wov-sdk" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[[package]] +name = "wov-sdk" +version = "0.1.0" +source = { directory = "../wov-sdk" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +]