feat: 完成工作流调度与上传下载 API

This commit is contained in:
cat-shark
2026-08-08 21:04:21 +08:00
commit a8d11eafc6
32 changed files with 3675 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
.coverage
data/
storage/
*.log
+38
View File
@@ -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 冒烟测试单独覆盖。
+88
View File
@@ -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% 行覆盖率。
+1
View File
@@ -0,0 +1 @@
"""WOV platform API package."""
+18
View File
@@ -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"))
+382
View File
@@ -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,))
+60
View File
@@ -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")
+298
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""WOV API routers."""
+126
View File
@@ -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,
)
+41
View File
@@ -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}
+95
View File
@@ -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
]
+121
View File
@@ -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)
+215
View File
@@ -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
+53
View File
@@ -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]
+72
View File
@@ -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())
+22
View File
@@ -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"
+21
View File
@@ -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)
+17
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
import time
print("WOV_NODE_READY", flush=True)
time.sleep(5)
+17
View File
@@ -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()
+34
View File
@@ -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()
+20
View File
@@ -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()
+97
View File
@@ -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
+141
View File
@@ -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
+144
View File
@@ -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") == []
+394
View File
@@ -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()
+390
View File
@@ -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"
+39
View File
@@ -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())
+27
View File
@@ -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)
+107
View File
@@ -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
Generated
+584
View File
@@ -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" },
]