feat: add initial frontend setup with styles, types, and testing framework

- Created a global CSS file for styling the frontend with responsive design.
- Introduced TypeScript types for various entities including Research, Alpha, and Account.
- Implemented Playwright tests for account management and data synchronization workflows.
- Configured TypeScript with strict settings and included necessary libraries.
- Set up Vite as the build tool with React plugin and API proxy configuration.
- Added a Python script to initialize environment variables securely.
This commit is contained in:
yuxuanhui
2026-09-07 14:54:20 +08:00
commit 3cd280d068
53 changed files with 10888 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
[alembic]
script_location = migrations
prepend_sys_path = .
View File
+199
View File
@@ -0,0 +1,199 @@
"""Normalize upstream business data, without making platform or research decisions."""
import math
import re
from datetime import datetime
from sqlalchemy import or_, select
from .models import Alpha, Research, ResearchTag, now
SENSITIVE_KEYS = {
"password",
"token",
"cookies",
"cookie",
"authorization",
"credentials",
"secret",
"accesstoken",
"refreshtoken",
"sessiontoken",
"clientsecret",
"authorizationheader",
"setcookie",
"apikey",
"csrftoken",
"xsrftoken",
"authentication",
}
def sanitize(value):
if isinstance(value, dict):
return {
k: sanitize(v) for k, v in value.items() if re.sub(r"[^a-z]", "", k.lower()) not in SENSITIVE_KEYS
}
if isinstance(value, list):
return [sanitize(v) for v in value]
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def number(value):
if value is None or isinstance(value, bool):
return None
try:
result = float(value)
return result if math.isfinite(result) else None
except (ValueError, TypeError):
return None
def date(value):
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None
except (ValueError, TypeError, AttributeError):
return None
def code(value):
return value.get("code") if isinstance(value, dict) else value if isinstance(value, str) else None
async def upsert_alpha(db, raw: dict):
alpha_id = raw.get("id")
if not isinstance(alpha_id, str) or not alpha_id:
raise ValueError("Alpha 数据缺少 ID")
item = await db.get(Alpha, alpha_id)
if item is None:
item = Alpha(id=alpha_id)
db.add(item)
settings = raw.get("settings") or {}
metrics = raw.get("is") if isinstance(raw.get("is"), dict) else {}
item.name = raw.get("name")
item.expression = code(raw.get("regular"))
item.selection, item.combo = code(raw.get("selection")), code(raw.get("combo"))
item.alpha_type, item.language = raw.get("type"), settings.get("language")
item.stage, item.status, item.hidden = raw.get("stage"), raw.get("status"), raw.get("hidden") is True
item.region, item.universe = settings.get("region"), settings.get("universe")
item.settings, item.is_metrics = sanitize(settings), sanitize(metrics)
item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
item.checks = sanitize(metrics.get("checks") or raw.get("checks") or [])
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
setattr(item, key, number(metrics.get(key)))
item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted"))
item.synced_at, item.raw = now(), sanitize(raw)
await db.flush()
if await db.get(Research, alpha_id) is None:
db.add(Research(alpha_id=alpha_id))
return item
def list_statement(filters):
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
q = filters.q
if q:
pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
query = query.where(
or_(
*(
getattr(Alpha, f).ilike(pattern, escape="\\")
for f in ("id", "name", "expression", "selection", "combo")
)
)
)
for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden"):
value = getattr(filters, name)
if value is not None:
query = query.where(getattr(Alpha, name) == value)
if filters.research_state:
query = query.where(Research.state == filters.research_state)
if filters.favorite is not None:
query = query.where(Research.favorite == filters.favorite)
if filters.tag:
query = query.where(Alpha.id.in_(select(ResearchTag.alpha_id).where(ResearchTag.tag == filters.tag)))
if filters.created_from:
query = query.where(Alpha.date_created >= filters.created_from)
if filters.created_to:
query = query.where(Alpha.date_created <= filters.created_to)
for name in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
for suffix, compare in (("min", "ge"), ("max", "le")):
value = getattr(filters, f"{name}_{suffix}")
if value is not None:
column = getattr(Alpha, name)
query = query.where(column >= value if compare == "ge" else column <= value)
return query
def sorted_statement(query, sort, direction):
column = getattr(Alpha, sort)
order = column.desc() if direction == "desc" else column.asc()
return query.order_by(order.nullslast(), Alpha.id.asc())
def summary(item: Alpha, research: Research):
keys = (
"id",
"name",
"alpha_type",
"language",
"stage",
"status",
"hidden",
"region",
"universe",
"sharpe",
"fitness",
"returns",
"turnover",
"margin",
"drawdown",
"date_created",
"date_submitted",
"synced_at",
)
result = {k: getattr(item, k) for k in keys}
result["expression_preview"] = (item.expression or item.selection or "")[:240]
result["research"] = {
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at")
}
return result
def pnl_points(raw):
"""Use schema column names, preserving missing values rather than creating zero PnL."""
records = raw.get("records")
schema = raw.get("schema") or {}
properties = schema.get("properties", []) if isinstance(schema, dict) else schema
if isinstance(properties, dict):
names = list(properties)
else:
names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties]
normalized = [name.lower() for name in names]
if not isinstance(records, list):
raise ValueError("PnL 缺少 records")
points = []
for row in records:
if isinstance(row, dict):
row = {str(k).lower(): v for k, v in row.items()}
timestamp = next((row[k] for k in ("date", "datetime", "timestamp") if k in row), None)
value = next((row[k] for k in ("pnl", "value") if k in row), None)
else:
date_i = next(
(i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None
)
pnl_i = next((i for i, n in enumerate(normalized) if n in ("pnl", "value")), None)
if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= max(date_i, pnl_i):
raise ValueError("PnL schema 无法识别日期或数值列")
timestamp, value = row[date_i], row[pnl_i]
if timestamp is not None:
if isinstance(timestamp, (int, float)):
from datetime import timezone
timestamp = datetime.fromtimestamp(
timestamp / 1000 if timestamp > 1e11 else timestamp, tz=timezone.utc
).isoformat()
points.append({"date": str(timestamp), "value": number(value)})
return sorted(points, key=lambda p: p["date"])
+33
View File
@@ -0,0 +1,33 @@
"""Administrative commands run inside the backend container."""
import argparse
import asyncio
import getpass
from sqlalchemy import delete
from .config import Settings
from .db import create_database
from .models import Admin, LoginSession
from .security import password_hasher
async def reset_password():
password = getpass.getpass("New admin password (12+ characters): ")
if len(password) < 12 or password != getpass.getpass("Confirm password: "):
raise SystemExit("Password too short or confirmation does not match")
engine, sessions = create_database(Settings().database_url)
async with sessions() as db:
admin = await db.get(Admin, 1)
admin.password_hash = password_hasher.hash(password)
await db.execute(delete(LoginSession))
await db.commit()
await engine.dispose()
print("Admin password updated; all system sessions revoked.")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["reset-password"])
parser.parse_args()
asyncio.run(reset_password())
+28
View File
@@ -0,0 +1,28 @@
"""Deployment configuration; secrets are required and never included in API responses."""
from cryptography.fernet import Fernet
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file="../.env", extra="ignore")
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
admin_username: str = "admin"
admin_password: SecretStr = Field(min_length=12)
encryption_key: SecretStr
public_origin: str = "http://localhost:8080"
cookie_secure: bool = False
session_hours: int = Field(default=24, ge=1, le=168)
wq_base_url: str = "https://api.worldquantbrain.com"
request_timeout: float = 30
retry_attempts: int = Field(default=4, ge=1, le=8)
enable_runner: bool = True
@model_validator(mode="after")
def validate_secrets(self):
Fernet(self.encryption_key.get_secret_value().encode())
if self.cookie_secure and not self.public_origin.startswith("https://"):
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
return self
+8
View File
@@ -0,0 +1,8 @@
"""Async database sessions; each request and background operation owns its transaction."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
def create_database(url: str):
engine = create_async_engine(url, pool_pre_ping=True, hide_parameters=True)
return engine, async_sessionmaker(engine, expire_on_commit=False)
+390
View File
@@ -0,0 +1,390 @@
"""Durable, single-process task runner.
Each page and its checkpoint commit in one transaction. A single Uvicorn process
owns the runner and shared upstream session; do not scale replicas before adding
a distributed lease and session coordinator.
"""
import asyncio
import logging
from contextlib import suppress
from datetime import timedelta
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from .alphas import pnl_points, sanitize, upsert_alpha
from .models import Account, Alpha, Job, JobItem, Pnl, now
from .security import cipher
from .worldquant import VerificationRequired, WqClient, WqError
logger = logging.getLogger(__name__)
ACTIVE = ("queued", "running", "waiting_auth", "waiting_connection")
AUTH_KINDS = ("connect", "verify", "profile")
async def create_job(db, kind, payload=None):
job = Job(id=str(uuid4()), kind=kind, payload=payload or {}, checkpoint={})
db.add(job)
await db.commit()
return job
class Runner:
def __init__(self, sessions, settings, client=None):
self.sessions, self.settings = sessions, settings
self.client = client or WqClient(settings)
self.loop_task = None
self.active_task = None
self.active_id = None
self.stopping = False
self.disconnecting = False
self.control_lock = asyncio.Lock()
self.recover_database = False
self.wake = asyncio.Event()
async def start(self):
async with self.sessions() as db:
await db.execute(update(Job).where(Job.status == "running").values(status="queued"))
account = await db.get(Account, 1)
if account.connection_status in ("connected", "connecting", "verification_required"):
account.connection_status = "expired"
account.verification_url = None
await db.commit()
self.loop_task = asyncio.create_task(self.run_loop())
async def stop(self):
self.stopping = True
self.wake.set()
if self.active_task:
self.active_task.cancel()
if self.loop_task:
await self.loop_task
await self.client.close()
async def cancel(self, job_id):
if self.active_id == job_id and self.active_task:
self.active_task.cancel()
with suppress(asyncio.CancelledError):
await self.active_task
async def disconnect(self):
# Prevent the scheduler from starting another request while credentials are cleared.
async with self.control_lock:
self.disconnecting = True
try:
if self.active_task:
await self.cancel(self.active_id)
self.client.disconnect()
async with self.sessions() as db:
account = await db.get(Account, 1)
account.connection_status = "disconnected"
account.verification_url = None
account.connection_error = None
await db.execute(
update(Job).where(Job.status.in_(ACTIVE)).values(status="waiting_connection")
)
await db.commit()
finally:
self.disconnecting = False
async def run_loop(self):
while not self.stopping:
try:
await self.run_next()
except (SQLAlchemyError, OSError):
# A temporary DB outage must not silently kill the scheduler.
self.recover_database = True
logger.warning("Task runner is waiting for database recovery")
await self.wait_for_work()
async def wait_for_work(self):
self.wake.clear()
if not self.stopping:
try:
await asyncio.wait_for(self.wake.wait(), timeout=1)
except TimeoutError:
pass
async def run_next(self):
async with self.control_lock:
async with self.sessions() as db:
if self.recover_database:
await db.execute(update(Job).where(Job.status == "running").values(status="queued"))
await db.commit()
self.recover_database = False
job = (
await db.scalars(
select(Job)
.where(Job.status == "queued")
.order_by(Job.kind.in_(AUTH_KINDS).desc(), Job.created_at)
.limit(1)
)
).first()
job_id = job.id if job else None
if job_id and not self.stopping:
self.active_id = job_id
self.active_task = asyncio.create_task(self.execute(job_id))
if job_id and self.active_task:
try:
with suppress(asyncio.CancelledError):
await self.active_task
finally:
self.active_id, self.active_task = None, None
else:
await self.wait_for_work()
async def set_account(self, status, error=None, url=None):
async with self.sessions() as db:
account = await db.get(Account, 1)
account.connection_status, account.connection_error, account.verification_url = status, error, url
await db.commit()
async def ensure_connected(self, force=False):
async with self.sessions() as db:
account = await db.get(Account, 1)
if (
not account.email
or not account.password_encrypted
or (account.connection_status == "disconnected" and not force)
):
raise WqError("请先配置并连接 WorldQuant", "disconnected")
password = cipher(self.settings).decrypt(account.password_encrypted.encode()).decode()
email = account.email
if self.client.verification_url and not force:
raise VerificationRequired(self.client.verification_url)
await self.client.authenticate(email, password, force=force)
await self.set_account("connected")
async def refresh_profile(self):
raw = await self.client.profile()
async with self.sessions() as db:
account = await db.get(Account, 1)
user_id = raw.get("id")
if not user_id or (account.wq_user_id and account.wq_user_id != str(user_id)):
self.client.disconnect()
raise WqError("平台账户身份不匹配,请核对凭据", "identity_mismatch")
account.wq_user_id = str(user_id)
# An allowlist avoids persisting unknown personal/security fields.
account.profile = sanitize(
{
k: raw[k]
for k in (
"id",
"username",
"name",
"email",
"firstName",
"lastName",
"role",
"roles",
"permissions",
"type",
)
if k in raw
}
)
account.connection_status, account.connection_error = "connected", None
account.verification_url, account.last_synced_at = None, now()
await db.execute(
update(Job)
.where(Job.status.in_(("waiting_auth", "waiting_connection")), ~Job.kind.in_(AUTH_KINDS))
.values(status="queued", error=None)
)
# Superseded connect/verify attempts are complete after identity confirmation.
await db.execute(
update(Job)
.where(Job.status.in_(("waiting_auth", "waiting_connection")), Job.kind.in_(AUTH_KINDS))
.values(status="completed", error=None)
)
await db.commit()
async def checkpoint(self, job_id, values):
async with self.sessions() as db:
job = await db.get(Job, job_id)
if job.cancel_requested:
raise asyncio.CancelledError()
for key, value in values.items():
setattr(job, key, value)
job.updated_at = now()
await db.commit()
async def execute(self, job_id):
async def retrying(delay):
await self.checkpoint(job_id, {"next_retry_at": now() + timedelta(seconds=delay)})
self.client.on_retry = retrying
try:
await self.checkpoint(job_id, {"status": "running", "error": None, "next_retry_at": None})
async with self.sessions() as db:
job = await db.get(Job, job_id)
kind, payload = job.kind, job.payload
if kind == "verify":
if not self.client.verification_url:
await self.ensure_connected(force=True)
else:
await self.client.verify()
await self.refresh_profile()
else:
await self.ensure_connected(force=kind == "connect")
if kind in ("connect", "profile"):
await self.refresh_profile()
elif kind == "full_sync":
await self.sync_all(job_id)
else:
await self.sync_ids(job_id, kind, payload["alpha_ids"])
async with self.sessions() as db:
job = await db.get(Job, job_id)
job.status = "completed_with_errors" if job.failed else "completed"
job.next_retry_at, job.updated_at = None, now()
await db.commit()
except asyncio.CancelledError:
async with self.sessions() as db:
job = await db.get(Job, job_id)
job.status = (
"cancelled"
if job.cancel_requested
else "waiting_connection"
if self.disconnecting
else "queued"
if self.stopping
else "cancelled"
)
job.next_retry_at, job.updated_at = None, now()
await db.commit()
except VerificationRequired as exc:
await self.set_account("verification_required", str(exc), exc.url)
await self.checkpoint(
job_id, {"status": "waiting_auth", "error": str(exc), "next_retry_at": None}
)
except WqError as exc:
waiting = exc.code in ("disconnected", "authentication_failed", "identity_mismatch")
if waiting:
await self.set_account("disconnected" if exc.code == "disconnected" else "error", str(exc))
await self.checkpoint(
job_id,
{
"status": "waiting_connection" if waiting else "failed",
"error": str(exc),
"next_retry_at": None,
},
)
except Exception:
# Do not expose upstream bodies, decrypted credentials, or SQL bind parameters.
logger.error("Job %s failed with an internal error", job_id)
await self.checkpoint(
job_id,
{"status": "failed", "error": "任务内部错误,请检查服务日志后重试", "next_retry_at": None},
)
finally:
self.client.on_retry = None
async def sync_all(self, job_id):
partitions = [
("UNSUBMITTED", False),
("UNSUBMITTED", True),
("SUBMITTED", False),
("SUBMITTED", True),
]
async with self.sessions() as db:
job = await db.get(Job, job_id)
checkpoint = job.checkpoint
before = job.created_at.isoformat()
start_partition, offset = checkpoint.get("partition", 0), checkpoint.get("offset", 0)
for partition in range(start_partition, len(partitions)):
submission, hidden = partitions[partition]
while True:
await self.checkpoint(job_id, {"next_retry_at": None})
raw = await self.client.alphas(submission, hidden, offset, before)
rows = raw.get("results")
if not isinstance(rows, list):
raise WqError("Alpha 列表缺少 results,已保留当前进度", "invalid_response")
async with self.sessions() as db:
job = await db.get(Job, job_id)
if job.cancel_requested:
raise asyncio.CancelledError()
for raw_alpha in rows:
await upsert_alpha(db, raw_alpha)
if not await db.get(JobItem, (job_id, raw_alpha["id"])):
db.add(JobItem(job_id=job_id, alpha_id=raw_alpha["id"]))
job.processed += 1
# Trust explicit next/count when available; otherwise an empty page ends iteration.
count = raw.get("count")
more = bool(rows)
if "next" in raw:
more = bool(raw["next"])
elif isinstance(count, int):
more = offset + len(rows) < count and bool(rows)
if more and not rows:
raise WqError("平台分页未前进", "invalid_response")
offset += len(rows)
job.checkpoint = {
"partition": partition if more else partition + 1,
"offset": offset if more else 0,
}
job.updated_at = now()
await db.commit()
if not more:
break
offset = 0
async with self.sessions() as db:
job = await db.get(Job, job_id)
job.total = job.processed
account = await db.get(Account, 1)
account.last_synced_at = now()
await db.commit()
async def sync_ids(self, job_id, kind, alpha_ids):
await self.checkpoint(job_id, {"total": len(alpha_ids)})
for alpha_id in alpha_ids:
async with self.sessions() as db:
previous = await db.get(JobItem, (job_id, alpha_id))
if previous and not previous.error:
continue
await self.checkpoint(job_id, {"next_retry_at": None})
error = None
try:
raw = await (
self.client.pnl(alpha_id) if kind == "pnl_refresh" else self.client.alpha(alpha_id)
)
if kind != "pnl_refresh" and raw.get("id") != alpha_id:
raise ValueError("平台返回的 Alpha ID 与请求不一致")
points = pnl_points(raw) if kind == "pnl_refresh" else None
except VerificationRequired:
raise
except WqError as exc:
if exc.code not in ("not_found", "access_denied"):
raise
error = str(exc)
except ValueError as exc:
error = str(exc)
async with self.sessions() as db:
job = await db.get(Job, job_id)
if job.cancel_requested:
raise asyncio.CancelledError()
previous = await db.get(JobItem, (job_id, alpha_id))
if previous and previous.error:
job.failed -= 1
if not previous:
previous = JobItem(job_id=job_id, alpha_id=alpha_id)
db.add(previous)
if not error:
if kind == "pnl_refresh":
if not await db.get(Alpha, alpha_id):
error = "请先导入此 Alpha"
else:
pnl = await db.get(Pnl, alpha_id)
if pnl is None:
pnl = Pnl(alpha_id=alpha_id)
db.add(pnl)
pnl.raw, pnl.points, pnl.fetched_at = sanitize(raw), points, now()
else:
await upsert_alpha(db, raw)
previous.error = error
if error:
job.failed += 1
else:
job.processed += 1
job.updated_at = now()
await db.commit()
+498
View File
@@ -0,0 +1,498 @@
"""FastAPI application and authenticated public API."""
import asyncio
import csv
import io
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import delete, func, select, text
from .alphas import list_statement, sorted_statement, summary
from .config import Settings
from .db import create_database
from .jobs import ACTIVE, AUTH_KINDS, Runner, create_job
from .models import Account, Admin, Alpha, Job, JobItem, LoginSession, Pnl, Research, ResearchTag, now
from .schemas import (
AccountOutput,
AlphaDetail,
AlphaFilters,
AlphaPage,
BulkInput,
BulkOutput,
CredentialsInput,
ErrorOutput,
FacetsOutput,
HealthOutput,
JobErrorOutput,
JobInput,
JobOutput,
LoginInput,
OkOutput,
PnlOutput,
PreferencesInput,
ResearchInput,
SessionOutput,
normalize_tags,
)
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
def account_output(account):
keys = (
"email",
"wq_user_id",
"profile",
"connection_status",
"connection_error",
"verification_url",
"last_synced_at",
"display_name",
"theme",
"timezone",
"page_size",
)
return {**{k: getattr(account, k) for k in keys}, "configured": bool(account.password_encrypted)}
async def set_tags(db, record, tags):
record.tags = normalize_tags(tags)
await db.execute(delete(ResearchTag).where(ResearchTag.alpha_id == record.alpha_id))
db.add_all(ResearchTag(alpha_id=record.alpha_id, tag=t) for t in record.tags)
def csv_cell(value):
"""Neutralize spreadsheet formulas in untrusted names, expressions, notes and tags."""
if value is None:
return ""
if isinstance(value, str) and (
value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n"))
):
return "'" + value
return value
def create_app(settings=None, wq_client=None):
settings = settings or Settings()
engine, sessions = create_database(settings.database_url)
runner = Runner(sessions, settings, client=wq_client)
@asynccontextmanager
async def lifespan(app):
async with sessions() as db:
await bootstrap(db, settings)
if settings.enable_runner:
await runner.start()
yield
if settings.enable_runner:
await runner.stop()
else:
await runner.client.close()
await engine.dispose()
app = FastAPI(
title="WorldQuant Alpha Research API",
version="0.1.0",
lifespan=lifespan,
responses={code: {"model": ErrorOutput} for code in (401, 403, 404, 409, 422, 429)},
)
app.state.engine, app.state.sessions, app.state.runner = engine, sessions, runner
app.state.settings = settings
login_failures = defaultdict(list)
@app.exception_handler(RequestValidationError)
async def validation_error(request, exc):
# Pydantic's default error includes the submitted value, possibly a password.
return JSONResponse(
status_code=422,
content={
"detail": "; ".join(f"{'.'.join(str(v) for v in e['loc'])}: {e['msg']}" for e in exc.errors())
},
)
@app.middleware("http")
async def browser_security(request, call_next):
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("X-WQ-Request") != "1":
return JSONResponse({"detail": "缺少请求校验头"}, status_code=403)
origin = request.headers.get("Origin")
if origin and origin.rstrip("/") != settings.public_origin.rstrip("/"):
return JSONResponse({"detail": "请求来源不被允许"}, status_code=403)
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/api/v1/health", response_model=HealthOutput, tags=["health"])
async def health():
async with sessions() as db:
await db.execute(text("SELECT 1"))
return {"status": "ok"}
@app.post("/api/v1/auth/login", response_model=SessionOutput, tags=["auth"])
async def login(body: LoginInput, request: Request, response: Response):
key = request.client.host if request.client else "unknown"
current = time.monotonic()
# Bound the rate-limit map and remove expired entries.
for peer in list(login_failures):
login_failures[peer] = [t for t in login_failures[peer] if current - t < 60]
if not login_failures[peer]:
del login_failures[peer]
if len(login_failures[key]) >= 5:
raise HTTPException(429, "登录尝试过多,请一分钟后重试")
async with sessions() as db:
admin = await db.get(Admin, 1)
matches = await asyncio.to_thread(valid_password, admin.password_hash, body.password)
if body.username != admin.username or not matches:
login_failures[key].append(current)
raise HTTPException(401, "用户名或密码不正确")
login_failures.pop(key, None)
token = await issue_session(db, settings.session_hours)
response.set_cookie(
"wq_session",
token,
httponly=True,
secure=settings.cookie_secure,
samesite="strict",
max_age=settings.session_hours * 3600,
path="/",
)
return {"username": admin.username}
api = APIRouter(prefix="/api/v1", dependencies=[Depends(require_auth)])
@api.get("/auth/me", response_model=SessionOutput, tags=["auth"])
async def me():
async with sessions() as db:
return {"username": (await db.get(Admin, 1)).username}
@api.post("/auth/logout", response_model=OkOutput, tags=["auth"])
async def logout(request: Request, response: Response):
async with sessions() as db:
await db.execute(
delete(LoginSession).where(
LoginSession.token_hash == token_hash(request.cookies["wq_session"])
)
)
await db.commit()
response.delete_cookie(
"wq_session", path="/", httponly=True, secure=settings.cookie_secure, samesite="strict"
)
return {"ok": True}
@api.get("/account", response_model=AccountOutput, tags=["account"])
async def get_account():
async with sessions() as db:
return account_output(await db.get(Account, 1))
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
async def credentials(body: CredentialsInput):
async with sessions() as db:
account = await db.get(Account, 1)
if account.wq_user_id and account.email.casefold() != body.email.casefold():
raise HTTPException(409, "本系统已绑定一个账户;不允许混入其他账户的 Alpha")
await runner.disconnect()
async with sessions() as db:
account = await db.get(Account, 1)
account.email = body.email
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
await db.commit()
return account_output(account)
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
async def preferences(body: PreferencesInput):
async with sessions() as db:
account = await db.get(Account, 1)
for key, value in body.model_dump().items():
setattr(account, key, value)
await db.commit()
return account_output(account)
async def account_job(kind):
async with sessions() as db:
account = await db.scalar(select(Account).where(Account.id == 1).with_for_update())
if not account.password_encrypted:
raise HTTPException(409, "请先保存 WorldQuant 账户配置")
existing = (
await db.scalars(
select(Job).where(Job.kind.in_(AUTH_KINDS), Job.status.in_(("running", "queued")))
)
).first()
if existing:
return existing
if kind == "connect":
account.connection_status = "connecting"
account.connection_error = None
job = await create_job(db, kind)
runner.wake.set()
return job
@api.post("/account/connect", status_code=202, response_model=JobOutput, tags=["account"])
async def connect():
return await account_job("connect")
@api.post("/account/verify", status_code=202, response_model=JobOutput, tags=["account"])
async def verify():
return await account_job("verify")
@api.post("/account/refresh", status_code=202, response_model=JobOutput, tags=["account"])
async def refresh():
return await account_job("profile")
@api.post("/account/disconnect", response_model=OkOutput, tags=["account"])
async def disconnect():
await runner.disconnect()
return {"ok": True}
@api.get("/alphas", response_model=AlphaPage, tags=["alphas"])
async def get_alphas(filters: Annotated[AlphaFilters, Query()]):
query = list_statement(filters)
async with sessions() as db:
total = await db.scalar(select(func.count()).select_from(query.subquery()))
rows = (
await db.execute(
sorted_statement(query, filters.sort, filters.direction)
.limit(filters.limit)
.offset(filters.offset)
)
).all()
return {
"items": [summary(a, r) for a, r in rows],
"total": total,
"limit": filters.limit,
"offset": filters.offset,
}
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"])
async def facets():
async with sessions() as db:
result = {}
for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
column = getattr(Alpha, key)
result[key] = list(
(
await db.scalars(
select(column).where(column.is_not(None)).distinct().order_by(column)
)
).all()
)
result["tags"] = list(
(await db.scalars(select(ResearchTag.tag).distinct().order_by(ResearchTag.tag))).all()
)
result["total"] = await db.scalar(select(func.count()).select_from(Alpha))
result["favorites"] = await db.scalar(
select(func.count()).select_from(Research).where(Research.favorite.is_(True))
)
result["last_sync"] = await db.scalar(select(func.max(Alpha.synced_at)))
return result
@api.get(
"/alphas/export",
response_class=StreamingResponse,
responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}},
tags=["alphas"],
)
async def export(filters: Annotated[AlphaFilters, Query()]):
query = sorted_statement(list_statement(filters), filters.sort, filters.direction)
async def content():
yield "\ufeff"
buffer = io.StringIO()
writer = csv.writer(buffer)
columns = [
"id",
"name",
"expression",
"selection",
"combo",
"alpha_type",
"language",
"stage",
"status",
"hidden",
"region",
"universe",
"sharpe",
"fitness",
"returns",
"turnover",
"margin",
"drawdown",
"date_created",
"date_submitted",
"synced_at",
]
writer.writerow(columns + ["research_state", "favorite", "tags", "note"])
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
async with sessions() as db:
rows = await db.stream(query.execution_options(yield_per=200))
async for a, r in rows:
writer.writerow(
[csv_cell(getattr(a, key)) for key in columns]
+ [r.state, r.favorite, csv_cell(";".join(r.tags)), csv_cell(r.note)]
)
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
return StreamingResponse(
content(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="alphas.csv"'},
)
@api.patch("/alphas/research/bulk", response_model=BulkOutput, tags=["alphas"])
async def bulk(body: BulkInput):
async with sessions() as db:
rows = (
await db.scalars(
select(Research).where(Research.alpha_id.in_(body.alpha_ids)).with_for_update()
)
).all()
if len(rows) != len(body.alpha_ids):
raise HTTPException(404, "部分 Alpha 尚未同步,本次未修改任何记录")
for row in rows:
tags = (set(row.tags) | set(body.add_tags)) - set(body.remove_tags)
try:
await set_tags(db, row, list(tags))
except ValueError as exc:
raise HTTPException(422, str(exc)) from None
if body.state:
row.state = body.state
row.updated_at = now()
await db.commit()
return {"updated": len(rows)}
@api.get("/alphas/{alpha_id}", response_model=AlphaDetail, tags=["alphas"])
async def detail(alpha_id: str):
async with sessions() as db:
a, r = await db.get(Alpha, alpha_id), await db.get(Research, alpha_id)
if a is None:
raise HTTPException(404, "Alpha 尚未同步")
return {
**summary(a, r),
**{
key: getattr(a, key)
for key in (
"expression",
"selection",
"combo",
"settings",
"is_metrics",
"os_metrics",
"checks",
)
},
}
@api.patch("/alphas/{alpha_id}/research", response_model=OkOutput, tags=["alphas"])
async def research(alpha_id: str, body: ResearchInput):
async with sessions() as db:
row = await db.scalar(select(Research).where(Research.alpha_id == alpha_id).with_for_update())
if row is None:
raise HTTPException(404, "Alpha 尚未同步")
for key, value in body.model_dump(exclude_unset=True).items():
if key == "tags":
await set_tags(db, row, value)
else:
setattr(row, key, value)
row.updated_at = now()
await db.commit()
return {"ok": True}
@api.get("/alphas/{alpha_id}/pnl", response_model=PnlOutput, tags=["alphas"])
async def pnl(alpha_id: str):
async with sessions() as db:
if not await db.get(Alpha, alpha_id):
raise HTTPException(404, "Alpha 尚未同步")
record = await db.get(Pnl, alpha_id)
return {
"cached": record is not None,
"points": record.points if record else [],
"fetched_at": record.fetched_at if record else None,
}
@api.post("/sync-jobs", status_code=202, response_model=JobOutput, tags=["sync-jobs"])
async def new_job(body: JobInput):
async with sessions() as db:
# Serialize creation against the singleton account, avoiding duplicate full scans.
account = await db.scalar(select(Account).where(Account.id == 1).with_for_update())
if not account.password_encrypted or account.connection_status in ("disconnected", "error"):
raise HTTPException(409, "请先连接 WorldQuant")
payload = {"alpha_ids": body.alpha_ids}
existing = (
await db.scalars(select(Job).where(Job.kind == body.kind, Job.status.in_(ACTIVE)))
).all()
for job in existing:
if job.payload == payload:
return job
job = await create_job(db, body.kind, payload)
runner.wake.set()
return job
@api.get("/sync-jobs", response_model=list[JobOutput], tags=["sync-jobs"])
async def get_jobs():
async with sessions() as db:
return (await db.scalars(select(Job).order_by(Job.created_at.desc()).limit(100))).all()
@api.get("/sync-jobs/{job_id}", response_model=JobOutput, tags=["sync-jobs"])
async def get_job(job_id: str):
async with sessions() as db:
job = await db.get(Job, job_id)
if job is None:
raise HTTPException(404, "任务不存在")
return job
@api.get("/sync-jobs/{job_id}/errors", response_model=list[JobErrorOutput], tags=["sync-jobs"])
async def job_errors(job_id: str):
async with sessions() as db:
rows = (
await db.scalars(select(JobItem).where(JobItem.job_id == job_id, JobItem.error.is_not(None)))
).all()
return [{"alpha_id": row.alpha_id, "error": row.error} for row in rows]
@api.post("/sync-jobs/{job_id}/cancel", response_model=OkOutput, tags=["sync-jobs"])
async def cancel_job(job_id: str):
async with sessions() as db:
job = await db.get(Job, job_id)
if job is None:
raise HTTPException(404, "任务不存在")
if job.status not in ACTIVE:
return {"ok": True}
job.cancel_requested = True
if job.status != "running":
job.status = "cancelled"
await db.commit()
await runner.cancel(job_id)
return {"ok": True}
@api.post("/sync-jobs/{job_id}/retry", response_model=JobOutput, tags=["sync-jobs"])
async def retry_job(job_id: str):
async with sessions() as db:
job = await db.get(Job, job_id)
if job is None:
raise HTTPException(404, "任务不存在")
if job.status not in (
"failed",
"cancelled",
"completed_with_errors",
"waiting_connection",
"waiting_auth",
):
raise HTTPException(409, "该任务当前不需要重试")
job.status, job.error, job.cancel_requested, job.next_retry_at = "queued", None, False, None
job.updated_at = now()
await db.commit()
runner.wake.set()
return job
app.include_router(api)
return app
+123
View File
@@ -0,0 +1,123 @@
"""Platform snapshots and local research are intentionally stored separately."""
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def now() -> datetime:
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
pass
class Admin(Base):
__tablename__ = "admins"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(Text)
class LoginSession(Base):
__tablename__ = "login_sessions"
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class Account(Base):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
email: Mapped[str | None] = mapped_column(String(254))
password_encrypted: Mapped[str | None] = mapped_column(Text)
wq_user_id: Mapped[str | None] = mapped_column(String(100))
profile: Mapped[dict] = mapped_column(JSON, default=dict)
connection_status: Mapped[str] = mapped_column(String(30), default="disconnected")
connection_error: Mapped[str | None] = mapped_column(Text)
verification_url: Mapped[str | None] = mapped_column(Text)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str] = mapped_column(String(100), default="研究员")
theme: Mapped[str] = mapped_column(String(10), default="light")
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
page_size: Mapped[int] = mapped_column(Integer, default=25)
class Alpha(Base):
__tablename__ = "alphas"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
name: Mapped[str | None] = mapped_column(Text)
expression: Mapped[str | None] = mapped_column(Text)
selection: Mapped[str | None] = mapped_column(Text)
combo: Mapped[str | None] = mapped_column(Text)
alpha_type: Mapped[str | None] = mapped_column(String(50), index=True)
language: Mapped[str | None] = mapped_column(String(50))
stage: Mapped[str | None] = mapped_column(String(50), index=True)
status: Mapped[str | None] = mapped_column(String(80), index=True)
hidden: Mapped[bool] = mapped_column(Boolean, default=False)
region: Mapped[str | None] = mapped_column(String(50), index=True)
universe: Mapped[str | None] = mapped_column(String(100))
settings: Mapped[dict] = mapped_column(JSON, default=dict)
is_metrics: Mapped[dict] = mapped_column(JSON, default=dict)
os_metrics: Mapped[dict] = mapped_column(JSON, default=dict)
checks: Mapped[list] = mapped_column(JSON, default=list)
sharpe: Mapped[float | None] = mapped_column(Float, index=True)
fitness: Mapped[float | None] = mapped_column(Float, index=True)
returns: Mapped[float | None] = mapped_column(Float)
turnover: Mapped[float | None] = mapped_column(Float)
margin: Mapped[float | None] = mapped_column(Float)
drawdown: Mapped[float | None] = mapped_column(Float)
date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
raw: Mapped[dict] = mapped_column(JSON, default=dict)
__table_args__ = (Index("ix_alphas_region_status", "region", "status"),)
class Research(Base):
__tablename__ = "research"
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
note: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list] = mapped_column(JSON, default=list)
favorite: Mapped[bool] = mapped_column(Boolean, default=False)
state: Mapped[str] = mapped_column(String(30), default="inbox", index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class Pnl(Base):
__tablename__ = "pnl_cache"
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
raw: Mapped[dict] = mapped_column(JSON)
points: Mapped[list] = mapped_column(JSON)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class ResearchTag(Base):
__tablename__ = "research_tags"
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
tag: Mapped[str] = mapped_column(String(60), primary_key=True, index=True)
class Job(Base):
__tablename__ = "sync_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
kind: Mapped[str] = mapped_column(String(30))
status: Mapped[str] = mapped_column(String(30), default="queued", index=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
checkpoint: Mapped[dict] = mapped_column(JSON, default=dict)
processed: Mapped[int] = mapped_column(Integer, default=0)
failed: Mapped[int] = mapped_column(Integer, default=0)
total: Mapped[int | None] = mapped_column(Integer)
error: Mapped[str | None] = mapped_column(Text)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class JobItem(Base):
__tablename__ = "job_items"
job_id: Mapped[str] = mapped_column(ForeignKey("sync_jobs.id"), primary_key=True)
alpha_id: Mapped[str] = mapped_column(String(100), primary_key=True)
error: Mapped[str | None] = mapped_column(Text)
+281
View File
@@ -0,0 +1,281 @@
"""Validated public API contracts. Platform state is intentionally not a closed enum."""
import re
from datetime import datetime, timezone
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
SortField = Literal[
"id",
"name",
"sharpe",
"fitness",
"returns",
"turnover",
"margin",
"drawdown",
"date_created",
"date_submitted",
"synced_at",
]
class Contract(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
class LoginInput(Contract):
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=1024)
class CredentialsInput(Contract):
email: str = Field(max_length=254)
password: str = Field(min_length=1, max_length=1024)
@field_validator("email")
@classmethod
def valid_email(cls, value):
value = value.strip()
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
raise ValueError("邮箱格式不正确")
return value
class PreferencesInput(Contract):
display_name: str = Field(min_length=1, max_length=100)
theme: Literal["light", "dark"]
timezone: str
page_size: Literal[25, 50, 100]
@field_validator("timezone")
@classmethod
def valid_timezone(cls, value):
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("未知时区") from None
return value
class AlphaFilters(Contract):
q: str | None = Field(default=None, max_length=300)
region: str | None = None
universe: str | None = None
alpha_type: str | None = None
language: str | None = None
status: str | None = None
stage: str | None = None
hidden: bool | None = None
research_state: ResearchState | None = None
favorite: bool | None = None
tag: str | None = Field(default=None, max_length=60)
created_from: datetime | None = None
created_to: datetime | None = None
sharpe_min: float | None = None
sharpe_max: float | None = None
fitness_min: float | None = None
fitness_max: float | None = None
returns_min: float | None = None
returns_max: float | None = None
turnover_min: float | None = None
turnover_max: float | None = None
margin_min: float | None = None
margin_max: float | None = None
drawdown_min: float | None = None
drawdown_max: float | None = None
sort: SortField = "date_created"
direction: Literal["asc", "desc"] = "desc"
limit: int = Field(default=25, ge=1, le=100)
offset: int = Field(default=0, ge=0)
@field_validator("created_from", "created_to")
@classmethod
def utc_dates(cls, value):
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
@model_validator(mode="after")
def range_order(self):
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max")
if lo is not None and hi is not None and lo > hi:
raise ValueError(f"{key} 最小值不能大于最大值")
if self.created_from and self.created_to and self.created_from > self.created_to:
raise ValueError("开始时间不能晚于结束时间")
return self
def normalize_tags(values):
result = sorted(set(v.strip() for v in values if v.strip()))
if len(result) > 30 or any(len(v) > 60 for v in result):
raise ValueError("最多 30 个标签,每个标签最多 60 字符")
return result
class ResearchInput(Contract):
note: str = Field(default="", max_length=20000)
tags: list[str] = Field(default_factory=list)
favorite: bool = False
state: ResearchState = "inbox"
_tags = field_validator("tags")(normalize_tags)
def valid_ids(values):
result = list(dict.fromkeys(values))
if not result or len(result) > 100 or any(not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", v) for v in result):
raise ValueError("请输入 1–100 个合法 Alpha ID(字母、数字、下划线或短横线)")
return result
class BulkInput(Contract):
alpha_ids: list[str]
add_tags: list[str] = Field(default_factory=list)
remove_tags: list[str] = Field(default_factory=list)
state: ResearchState | None = None
_ids = field_validator("alpha_ids")(valid_ids)
_tags = field_validator("add_tags", "remove_tags")(normalize_tags)
@model_validator(mode="after")
def disjoint(self):
if set(self.add_tags) & set(self.remove_tags):
raise ValueError("同一标签不能同时添加和移除")
return self
class JobInput(Contract):
kind: Literal["full_sync", "alpha_refresh", "pnl_refresh"]
alpha_ids: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def validate_ids(self):
if self.kind == "full_sync":
if self.alpha_ids:
raise ValueError("全量同步不接受 Alpha ID")
else:
self.alpha_ids = valid_ids(self.alpha_ids)
return self
class ResearchOutput(ResearchInput):
updated_at: datetime
class AlphaSummary(BaseModel):
id: str
name: str | None
expression_preview: str
alpha_type: str | None
language: str | None
stage: str | None
status: str | None
hidden: bool
region: str | None
universe: str | None
sharpe: float | None
fitness: float | None
returns: float | None
turnover: float | None
margin: float | None
drawdown: float | None
date_created: datetime | None
date_submitted: datetime | None
synced_at: datetime
research: ResearchOutput
class AlphaDetail(AlphaSummary):
expression: str | None
selection: str | None
combo: str | None
settings: dict
is_metrics: dict
os_metrics: dict
checks: list
class AlphaPage(BaseModel):
items: list[AlphaSummary]
total: int
limit: int
offset: int
class JobOutput(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
status: str
processed: int
failed: int
total: int | None
error: str | None
next_retry_at: datetime | None
created_at: datetime
updated_at: datetime
class AccountOutput(BaseModel):
email: str | None
configured: bool
wq_user_id: str | None
profile: dict
connection_status: str
connection_error: str | None
verification_url: str | None
last_synced_at: datetime | None
display_name: str
theme: Literal["light", "dark"]
timezone: str
page_size: int
class SessionOutput(BaseModel):
username: str
class OkOutput(BaseModel):
ok: bool
class ErrorOutput(BaseModel):
detail: str
class HealthOutput(BaseModel):
status: Literal["ok"]
class BulkOutput(BaseModel):
updated: int
class PnlPoint(BaseModel):
date: str
value: float | None
class PnlOutput(BaseModel):
cached: bool
points: list[PnlPoint]
fetched_at: datetime | None
class FacetsOutput(BaseModel):
region: list[str]
universe: list[str]
alpha_type: list[str]
language: list[str]
status: list[str]
stage: list[str]
tags: list[str]
total: int
favorites: int
last_sync: datetime | None
class JobErrorOutput(BaseModel):
alpha_id: str
error: str
+65
View File
@@ -0,0 +1,65 @@
"""Server-side authentication and encryption utilities."""
import hashlib
import secrets
from datetime import timedelta
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError
from cryptography.fernet import Fernet
from fastapi import Depends, HTTPException, Request
from fastapi.security import APIKeyCookie
from sqlalchemy import delete
from .models import Account, Admin, LoginSession, now
password_hasher = PasswordHasher()
session_cookie = APIKeyCookie(name="wq_session", auto_error=False)
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def cipher(settings) -> Fernet:
return Fernet(settings.encryption_key.get_secret_value().encode())
async def bootstrap(db, settings):
"""Only initialize missing singleton records; deployments never reset existing passwords."""
if not await db.get(Admin, 1):
db.add(
Admin(
id=1,
username=settings.admin_username,
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
)
)
if not await db.get(Account, 1):
db.add(Account(id=1))
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
await db.commit()
def valid_password(encoded: str, value: str) -> bool:
try:
return password_hasher.verify(encoded, value)
except VerificationError:
return False
async def issue_session(db, hours: int) -> str:
token = secrets.token_urlsafe(32)
db.add(LoginSession(token_hash=token_hash(token), expires_at=now() + timedelta(hours=hours)))
await db.commit()
return token
async def require_auth(request: Request, token: str | None = Depends(session_cookie)):
if not token:
raise HTTPException(401, "请先登录系统")
async with request.app.state.sessions() as db:
row = await db.get(LoginSession, token_hash(token))
# SQLite fixtures lose tzinfo; production PostgreSQL preserves it.
if row is None or row.expires_at.replace(tzinfo=now().tzinfo) <= now():
raise HTTPException(401, "系统登录已过期,请重新登录")
+211
View File
@@ -0,0 +1,211 @@
"""Read-only WorldQuant adapter. Authentication is the only allowed upstream POST.
No upstream response body or request headers are included in exceptions: they may
contain credentials, cookies, or temporary authentication links.
"""
import asyncio
import math
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Awaitable, Callable
from urllib.parse import urljoin, urlparse
import httpx
class WqError(Exception):
def __init__(self, message: str, code: str = "upstream_error"):
super().__init__(message)
self.code = code
class VerificationRequired(WqError):
def __init__(self, url: str):
super().__init__("请在 WorldQuant 完成人工验证,再点击继续验证", "verification_required")
self.url = url
class WqClient:
def __init__(self, settings, transport=None, sleep=asyncio.sleep):
self.settings = settings
self.client = httpx.AsyncClient(
base_url=settings.wq_base_url,
timeout=settings.request_timeout,
transport=transport,
follow_redirects=False,
)
self.lock = asyncio.Lock()
self.authenticated = False
self.auth_generation = 0
self.credentials: tuple[str, str] | None = None
self.verification_url: str | None = None
self.sleep = sleep
self.on_retry: Callable[[float], Awaitable[None]] | None = None
async def close(self):
await self.client.aclose()
def disconnect(self):
self.authenticated = False
self.credentials = None
self.verification_url = None
self.client.cookies.clear()
def safe_verification_url(self, response: httpx.Response) -> str:
url = urljoin(str(response.url), response.headers.get("Location", ""))
base = urlparse(self.settings.wq_base_url)
parsed = urlparse(url)
if (
not response.headers.get("Location")
or parsed.scheme != base.scheme
or parsed.netloc != base.netloc
):
raise WqError("平台返回了无法确认的验证地址,请到 BRAIN 官网检查账户", "invalid_verification")
return url
@staticmethod
def retry_delay(value: str | None, attempt: int) -> float:
if value:
try:
seconds = float(value)
except ValueError:
try:
seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
except (TypeError, ValueError):
seconds = -1
if math.isfinite(seconds) and seconds >= 0:
return seconds
return min(2**attempt + random.uniform(0, 0.5), 30)
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
for attempt in range(self.settings.retry_attempts):
try:
response = await self.client.request(method, path, **kwargs)
except (httpx.TimeoutException, httpx.NetworkError):
if attempt + 1 == self.settings.retry_attempts:
raise WqError("WorldQuant 网络请求失败,请稍后重试", "network_error") from None
response = None
if response is not None and response.status_code not in (429, 500, 502, 503, 504):
return response
if attempt + 1 == self.settings.retry_attempts:
raise WqError("WorldQuant 暂时限流或不可用,重试预算已用完", "retry_exhausted")
delay = self.retry_delay(
response.headers.get("Retry-After") if response is not None else None, attempt
)
if self.on_retry:
await self.on_retry(delay)
await self.sleep(delay)
raise WqError("重试失败")
async def authenticate(self, email: str, password: str, force=False, stale_generation=None):
async with self.lock:
if (
self.authenticated
and not force
and (stale_generation is None or stale_generation != self.auth_generation)
):
return
if self.verification_url and not force:
raise VerificationRequired(self.verification_url)
self.credentials = (email, password)
self.authenticated = False
if force:
self.client.cookies.clear()
self.verification_url = None
response = await self._request("POST", "/authentication", auth=httpx.BasicAuth(email, password))
if (
response.status_code == 401
and response.headers.get("WWW-Authenticate", "").lower() == "persona"
):
self.verification_url = self.safe_verification_url(response)
raise VerificationRequired(self.verification_url)
if response.status_code in (401, 403):
raise WqError("平台认证未通过,请检查凭据或账户访问权限", "authentication_failed")
if response.status_code not in (200, 201):
raise WqError(f"平台认证返回异常状态 {response.status_code}", "authentication_failed")
self.authenticated = True
self.auth_generation += 1
self.verification_url = None
async def verify(self):
"""Continue the same server-side challenge session after the human visits the URL."""
if not self.verification_url:
if not self.credentials:
raise WqError("验证会话已结束,请重新连接", "authentication_failed")
await self.authenticate(*self.credentials, force=True)
return
response = await self._request("POST", self.verification_url)
if response.status_code in (200, 201):
self.authenticated = True
self.auth_generation += 1
self.verification_url = None
elif response.status_code in (401, 403, 202):
raise VerificationRequired(self.verification_url)
else:
raise WqError("验证会话已失效,请重新连接", "authentication_failed")
async def get(self, path: str, params=None):
if not self.credentials:
raise WqError("请先连接 WorldQuant", "disconnected")
if not self.authenticated:
await self.authenticate(*self.credentials)
refreshed = False
for attempt in range(self.settings.retry_attempts):
generation = self.auth_generation
response = await self._request("GET", path, params=params)
if response.status_code == 401 and not refreshed:
await self.authenticate(*self.credentials, stale_generation=generation)
refreshed = True
continue
if response.status_code in (401, 403):
raise WqError("无权访问该平台资源", "access_denied")
if response.status_code == 404:
raise WqError("平台资源不存在或不可访问", "not_found")
if response.status_code not in (200, 201, 202):
raise WqError(f"WorldQuant 请求返回状态 {response.status_code}")
# Recordsets may return 200/202 with Retry-After before results exist.
if (
response.headers.get("Retry-After")
and self.retry_delay(response.headers["Retry-After"], 0) > 0
):
if attempt + 1 == self.settings.retry_attempts:
raise WqError("平台数据仍在准备,请稍后重试", "pending")
delay = self.retry_delay(response.headers["Retry-After"], attempt)
if self.on_retry:
await self.on_retry(delay)
await self.sleep(delay)
continue
try:
result = response.json()
if not isinstance(result, dict):
raise ValueError()
return result
except ValueError:
raise WqError("平台返回的数据格式无法识别", "invalid_response") from None
raise WqError("平台数据尚未就绪", "pending")
async def profile(self):
return await self.get("/users/self")
async def alphas(self, submission, hidden, offset, before):
# Cover every platform stage; submitted records are not assumed to be OS only.
status_key = "status" if submission == "UNSUBMITTED" else "status!"
return await self.get(
"/users/self/alphas",
{
status_key: "UNSUBMITTED",
"hidden": str(hidden).lower(),
"limit": 100,
"offset": offset,
"order": "dateCreated",
"dateCreated<": before,
},
)
async def alpha(self, alpha_id):
return await self.get(f"/alphas/{alpha_id}")
async def pnl(self, alpha_id):
return await self.get(f"/alphas/{alpha_id}/recordsets/pnl")
+28
View File
@@ -0,0 +1,28 @@
import asyncio
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import Settings
from app.models import Base
def migrations(connection):
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async def run():
engine = create_async_engine(Settings().database_url)
async with engine.connect() as connection:
await connection.run_sync(migrations)
await engine.dispose()
if context.is_offline_mode():
context.configure(url=Settings().database_url, target_metadata=Base.metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
else:
asyncio.run(run())
+14
View File
@@ -0,0 +1,14 @@
"""${message}"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,153 @@
"""Initial account alpha and durable job schema"""
from alembic import op
import sqlalchemy as sa
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=254), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('wq_user_id', sa.String(length=100), nullable=True),
sa.Column('profile', sa.JSON(), nullable=False),
sa.Column('connection_status', sa.String(length=30), nullable=False),
sa.Column('connection_error', sa.Text(), nullable=True),
sa.Column('verification_url', sa.Text(), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('display_name', sa.String(length=100), nullable=False),
sa.Column('theme', sa.String(length=10), nullable=False),
sa.Column('timezone', sa.String(length=64), nullable=False),
sa.Column('page_size', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('admins',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('password_hash', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('alphas',
sa.Column('id', sa.String(length=100), nullable=False),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('expression', sa.Text(), nullable=True),
sa.Column('selection', sa.Text(), nullable=True),
sa.Column('combo', sa.Text(), nullable=True),
sa.Column('alpha_type', sa.String(length=50), nullable=True),
sa.Column('language', sa.String(length=50), nullable=True),
sa.Column('stage', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=80), nullable=True),
sa.Column('hidden', sa.Boolean(), nullable=False),
sa.Column('region', sa.String(length=50), nullable=True),
sa.Column('universe', sa.String(length=100), nullable=True),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('is_metrics', sa.JSON(), nullable=False),
sa.Column('os_metrics', sa.JSON(), nullable=False),
sa.Column('checks', sa.JSON(), nullable=False),
sa.Column('sharpe', sa.Float(), nullable=True),
sa.Column('fitness', sa.Float(), nullable=True),
sa.Column('returns', sa.Float(), nullable=True),
sa.Column('turnover', sa.Float(), nullable=True),
sa.Column('margin', sa.Float(), nullable=True),
sa.Column('drawdown', sa.Float(), nullable=True),
sa.Column('date_created', sa.DateTime(timezone=True), nullable=True),
sa.Column('date_submitted', sa.DateTime(timezone=True), nullable=True),
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('raw', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_alphas_alpha_type'), 'alphas', ['alpha_type'], unique=False)
op.create_index(op.f('ix_alphas_date_created'), 'alphas', ['date_created'], unique=False)
op.create_index(op.f('ix_alphas_fitness'), 'alphas', ['fitness'], unique=False)
op.create_index(op.f('ix_alphas_region'), 'alphas', ['region'], unique=False)
op.create_index('ix_alphas_region_status', 'alphas', ['region', 'status'], unique=False)
op.create_index(op.f('ix_alphas_sharpe'), 'alphas', ['sharpe'], unique=False)
op.create_index(op.f('ix_alphas_stage'), 'alphas', ['stage'], unique=False)
op.create_index(op.f('ix_alphas_status'), 'alphas', ['status'], unique=False)
op.create_table('login_sessions',
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('token_hash')
)
op.create_index(op.f('ix_login_sessions_expires_at'), 'login_sessions', ['expires_at'], unique=False)
op.create_table('sync_jobs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('payload', sa.JSON(), nullable=False),
sa.Column('checkpoint', sa.JSON(), nullable=False),
sa.Column('processed', sa.Integer(), nullable=False),
sa.Column('failed', sa.Integer(), nullable=False),
sa.Column('total', sa.Integer(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('cancel_requested', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sync_jobs_status'), 'sync_jobs', ['status'], unique=False)
op.create_table('job_items',
sa.Column('job_id', sa.String(length=36), nullable=False),
sa.Column('alpha_id', sa.String(length=100), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['job_id'], ['sync_jobs.id'], ),
sa.PrimaryKeyConstraint('job_id', 'alpha_id')
)
op.create_table('pnl_cache',
sa.Column('alpha_id', sa.String(length=100), nullable=False),
sa.Column('raw', sa.JSON(), nullable=False),
sa.Column('points', sa.JSON(), nullable=False),
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
sa.PrimaryKeyConstraint('alpha_id')
)
op.create_table('research',
sa.Column('alpha_id', sa.String(length=100), nullable=False),
sa.Column('note', sa.Text(), nullable=False),
sa.Column('tags', sa.JSON(), nullable=False),
sa.Column('favorite', sa.Boolean(), nullable=False),
sa.Column('state', sa.String(length=30), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
sa.PrimaryKeyConstraint('alpha_id')
)
op.create_index(op.f('ix_research_state'), 'research', ['state'], unique=False)
op.create_table('research_tags',
sa.Column('alpha_id', sa.String(length=100), nullable=False),
sa.Column('tag', sa.String(length=60), nullable=False),
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
sa.PrimaryKeyConstraint('alpha_id', 'tag')
)
op.create_index(op.f('ix_research_tags_tag'), 'research_tags', ['tag'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_research_tags_tag'), table_name='research_tags')
op.drop_table('research_tags')
op.drop_index(op.f('ix_research_state'), table_name='research')
op.drop_table('research')
op.drop_table('pnl_cache')
op.drop_table('job_items')
op.drop_index(op.f('ix_sync_jobs_status'), table_name='sync_jobs')
op.drop_table('sync_jobs')
op.drop_index(op.f('ix_login_sessions_expires_at'), table_name='login_sessions')
op.drop_table('login_sessions')
op.drop_index(op.f('ix_alphas_status'), table_name='alphas')
op.drop_index(op.f('ix_alphas_stage'), table_name='alphas')
op.drop_index(op.f('ix_alphas_sharpe'), table_name='alphas')
op.drop_index('ix_alphas_region_status', table_name='alphas')
op.drop_index(op.f('ix_alphas_region'), table_name='alphas')
op.drop_index(op.f('ix_alphas_fitness'), table_name='alphas')
op.drop_index(op.f('ix_alphas_date_created'), table_name='alphas')
op.drop_index(op.f('ix_alphas_alpha_type'), table_name='alphas')
op.drop_table('alphas')
op.drop_table('admins')
op.drop_table('accounts')
# ### end Alembic commands ###
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "wq-alpha-system"
version = "0.1.0"
description = "Personal WorldQuant Alpha research workspace"
requires-python = ">=3.12,<3.13"
dependencies = [
"fastapi>=0.115,<1", "uvicorn[standard]>=0.34,<1",
"httpx>=0.28,<1", "sqlalchemy[asyncio]>=2.0.38,<2.1",
"asyncpg>=0.30,<1", "alembic>=1.15,<2",
"pydantic-settings>=2.8,<3", "cryptography>=44,<50", "argon2-cffi>=23.1,<26"
]
[dependency-groups]
dev = ["pytest>=8,<10", "pytest-asyncio>=0.25,<2", "aiosqlite>=0.21,<1", "ruff>=0.11,<1"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
target-version = "py312"
line-length = 110
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
View File
+135
View File
@@ -0,0 +1,135 @@
"""Isolated browser acceptance server: temporary database, synthetic upstream, no real network."""
import tempfile
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
import httpx
from cryptography.fernet import Fernet
from app.config import Settings
from app.main import create_app
from app.models import Base
from app.worldquant import WqClient
TEST_PASSWORD = "browser-test-password"
def sample(index):
super_alpha = index % 7 == 0
language = "PYTHON" if index % 11 == 0 and not super_alpha else "FASTEXPR"
names = [
"短期价格反转",
"成交量与价格背离",
"分析师预期修正",
"行业中性动量",
"低波动质量信号",
"资金流向偏离",
"组合选择实验",
]
return {
"id": f"TEST{index:04}",
"name": names[index % len(names)] + f" · {index:03}",
"type": "SUPER" if super_alpha else "REGULAR",
"stage": "OS" if index % 3 == 0 else "IS",
"status": "ACTIVE" if index % 3 == 0 else "UNSUBMITTED",
"hidden": index % 5 == 0,
"regular": None
if super_alpha
else {
"code": "def alpha(data):\n return -data.close.rank()"
if language == "PYTHON"
else "group_neutralize(-rank(ts_delta(close, 5)), industry)"
},
"selection": {"code": "self_correlation < 0.5"} if super_alpha else None,
"combo": {"code": "alpha"} if super_alpha else None,
"settings": {
"region": ["USA", "CHN", "EUR"][index % 3],
"universe": "TOP3000",
"language": language,
"delay": 1,
"decay": 4,
"neutralization": "INDUSTRY",
"truncation": 0.08,
"pasteurization": "ON",
},
"is": {
"sharpe": None if index % 13 == 0 else round(0.8 + (index % 25) * 0.1, 2),
"fitness": round(0.3 + (index % 20) * 0.06, 2),
"turnover": round(0.08 + (index % 12) * 0.015, 3),
"returns": 0.09,
"margin": 0.0002,
"drawdown": 0.11,
"checks": [{"name": "LOW_SHARPE", "result": "PASS", "value": 2.1, "limit": 1.58}],
},
"os": {"sharpe": 1.1} if index % 3 == 0 else None,
"dateCreated": (datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(days=index)).isoformat(),
}
def create_test_app():
temporary = tempfile.TemporaryDirectory(prefix="wq-browser-test-")
settings = Settings(
_env_file=None,
database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db",
admin_password=TEST_PASSWORD,
encryption_key=Fernet.generate_key().decode(),
public_origin="http://127.0.0.1:5179",
)
records = [sample(i) for i in range(620)]
def upstream(request):
path = request.url.path
if path == "/authentication" and request.method == "POST":
return httpx.Response(
201, json={"user": {"id": "TEST_USER"}}, headers={"Set-Cookie": "mock=only; Path=/"}
)
if request.method != "GET":
raise AssertionError("Browser acceptance attempted an upstream mutation")
if path == "/users/self":
return httpx.Response(
200, json={"id": "TEST_USER", "name": "模拟研究员", "email": "test@example.com"}
)
if path == "/users/self/alphas":
unsubmitted = "status" in request.url.params
hidden = request.url.params.get("hidden") == "true"
matched = [
r for r in records if (r["status"] == "UNSUBMITTED") == unsubmitted and r["hidden"] == hidden
]
offset, limit = (
int(request.url.params.get("offset", 0)),
int(request.url.params.get("limit", 100)),
)
return httpx.Response(
200, json={"count": len(matched), "results": matched[offset : offset + limit]}
)
if path.endswith("/recordsets/pnl"):
return httpx.Response(
200,
json={
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
"records": [
[
(datetime(2025, 1, 1) + timedelta(days=i)).date().isoformat(),
i * 250 + (i % 13) * 85,
]
for i in range(180)
],
},
)
record = next((r for r in records if path == f"/alphas/{r['id']}"), None)
return httpx.Response(200, json=record) if record else httpx.Response(404)
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream)))
original_lifespan = application.router.lifespan_context
@asynccontextmanager
async def lifespan(app):
async with app.state.engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with original_lifespan(app):
yield
temporary.cleanup()
application.router.lifespan_context = lifespan
return application
+71
View File
@@ -0,0 +1,71 @@
import httpx
import pytest
from cryptography.fernet import Fernet
from app.config import Settings
from app.main import create_app
from app.models import Base
from app.worldquant import WqClient
ADMIN_PASSWORD = "test-admin-password-only"
@pytest.fixture
def settings(tmp_path):
return Settings(
_env_file=None,
database_url=f"sqlite+aiosqlite:///{tmp_path}/test.db",
admin_password=ADMIN_PASSWORD,
encryption_key=Fernet.generate_key().decode(),
enable_runner=False,
public_origin="http://testserver",
)
@pytest.fixture
async def app(settings):
def unexpected_request(request):
raise AssertionError("Unit tests must never access the real platform")
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(unexpected_request)))
async with application.state.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with application.router.lifespan_context(application):
yield application
@pytest.fixture
async def client(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}
) as client:
yield client
@pytest.fixture
async def logged_in(client):
response = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
assert response.status_code == 200
return client
def alpha(alpha_id="alpha1", **changes):
raw = {
"id": alpha_id,
"name": "Reversal",
"type": "REGULAR",
"stage": "IS",
"status": "UNSUBMITTED",
"hidden": False,
"regular": {"code": "-rank(ts_delta(close, 5))"},
"settings": {"region": "USA", "universe": "TOP3000", "language": "FASTEXPR", "delay": 1},
"is": {
"sharpe": 1.5,
"fitness": 0.8,
"turnover": 0.2,
"returns": 0.04,
"checks": [{"name": "LOW_SHARPE", "result": "FAIL", "value": 1.5, "limit": 1.58}],
},
"dateCreated": "2025-01-01T00:00:00Z",
}
return {**raw, **changes}
+198
View File
@@ -0,0 +1,198 @@
"""Run against an isolated acceptance Compose project; never use a personal data stack.
From the repository root:
python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env
Requires images already built and services started with -p wq-alpha-acceptance.
"""
import argparse
import http.cookiejar
import json
import os
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--env-file", required=True, type=Path)
parser.add_argument("--project", default="wq-alpha-acceptance")
args = parser.parse_args()
if not args.project.startswith("wq-alpha-acceptance"):
raise SystemExit("Only an isolated wq-alpha-acceptance* project is allowed")
values = dict(
line.split("=", 1)
for line in args.env_file.read_text().splitlines()
if "=" in line and not line.startswith("#")
)
compose = ["docker", "compose", "--env-file", str(args.env_file), "-p", args.project]
def run(arguments, data=None):
result = subprocess.run(compose + arguments, input=data, capture_output=True, check=True)
return result.stdout
# Assert that we are addressing the isolated project rather than an unrelated localhost app.
published = run(["port", "web", "80"]).decode().strip()
assert published == f"127.0.0.1:{values.get('LOCAL_PORT', '8080')}"
base = f"http://localhost:{values.get('LOCAL_PORT', '8080')}"
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
)
def request(path, method="GET", payload=None):
body = json.dumps(payload).encode() if payload is not None else None
return opener.open(
urllib.request.Request(
base + path,
data=body,
method=method,
headers={"Content-Type": "application/json", "X-WQ-Request": "1", "Origin": base},
),
timeout=10,
)
assert json.load(request("/api/v1/health")) == {"status": "ok"}
html = request("/")
assert "frame-ancestors 'none'" in html.headers["Content-Security-Policy"]
assert b'<div id="root">' in html.read()
try:
request("/api/v1/alphas")
raise AssertionError("Unauthenticated data was exposed")
except urllib.error.HTTPError as error:
assert error.code == 401
json.load(
request(
"/api/v1/auth/login",
"POST",
{"username": values.get("ADMIN_USERNAME", "admin"), "password": values["ADMIN_PASSWORD"]},
)
)
code = b"""import asyncio
from app.config import Settings
from app.db import create_database
from app.alphas import upsert_alpha
async def seed():
engine, sessions = create_database(Settings().database_url)
async with sessions() as db:
await upsert_alpha(db, {"id":"DOCKER_ACCEPTANCE", "name":"Synthetic acceptance record", "type":"REGULAR", "stage":"IS", "status":"UNSUBMITTED", "regular":{"code":"rank(close)"}, "settings":{"region":"USA", "language":"FASTEXPR"}, "is":{"sharpe":2.0}})
await db.commit()
await engine.dispose()
asyncio.run(seed())
"""
run(["exec", "-T", "backend", "python", "-"], code)
json.load(
request(
"/api/v1/alphas/DOCKER_ACCEPTANCE/research",
"PATCH",
{
"note": "persistent local note",
"tags": ["docker-verified"],
"state": "candidate",
"favorite": True,
},
)
)
print("PASS: PostgreSQL migration, login, API authorization, local research write")
run(["up", "-d", "--force-recreate", "--wait"])
# A persisted server session and all database rows survive container replacement.
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
assert detail["research"]["note"] == "persistent local note"
run(["exec", "-T", "backend", "python", "-"], code.replace(b'"sharpe":2.0', b'"sharpe":3.0'))
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
assert detail["sharpe"] == 3 and detail["research"]["state"] == "candidate"
assert detail["research"]["tags"] == ["docker-verified"]
print("PASS: container replacement preserves session and data; snapshot update preserves research")
dump = run(["exec", "-T", "db", "pg_dump", "-U", "wq", "-d", "wq", "-Fc", "--no-owner"])
backup = Path(".local/docker-acceptance.dump")
fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as output:
output.write(dump)
# Only this script's scratch restore database is replaced on repeated acceptance runs.
run(["exec", "-T", "db", "dropdb", "-U", "wq", "--if-exists", "wq_acceptance_restore"])
run(["exec", "-T", "db", "createdb", "-U", "wq", "wq_acceptance_restore"])
run(
[
"exec",
"-T",
"db",
"pg_restore",
"-U",
"wq",
"-d",
"wq_acceptance_restore",
"--no-owner",
"--exit-on-error",
],
dump,
)
rows = (
run(
[
"exec",
"-T",
"db",
"psql",
"-U",
"wq",
"-d",
"wq_acceptance_restore",
"-At",
"-c",
"SELECT note || '|' || state FROM research WHERE alpha_id = 'DOCKER_ACCEPTANCE'",
]
)
.decode()
.strip()
)
assert rows == "persistent local note|candidate"
print("PASS: PostgreSQL custom-format backup restores records into independent database")
config = json.loads(run(["-f", "compose.public.yaml", "config", "--format", "json"]))
assert config["services"]["backend"]["environment"]["COOKIE_SECURE"] == "true"
assert config["services"]["backend"]["environment"]["PUBLIC_ORIGIN"].startswith("https://")
assert "ports" not in config["services"]["db"] and "ports" not in config["services"]["backend"]
run(
[
"exec",
"-T",
"web",
"caddy",
"validate",
"--config",
"/etc/caddy/Caddyfile",
"--adapter",
"caddyfile",
]
)
print("PASS: Caddy configuration and public HTTPS/Secure-cookie configuration checks")
# Keep the backend process alive while its database disappears and returns.
run(["stop", "db"])
run(["up", "-d", "--wait", "db"])
probe = b"""import asyncio
from app.config import Settings
from app.db import create_database
from app.jobs import create_job
async def enqueue():
engine, sessions = create_database(Settings().database_url)
async with sessions() as db:
job = await create_job(db, "profile")
print(job.id)
await engine.dispose()
asyncio.run(enqueue())
"""
job_id = run(["exec", "-T", "backend", "python", "-"], probe).decode().strip()
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
status = json.load(request(f"/api/v1/sync-jobs/{job_id}"))["status"]
if status == "waiting_connection":
break
time.sleep(0.25)
else:
raise AssertionError("Scheduler did not resume after database restart")
print("PASS: live backend resumes task processing after database stop/start")
if __name__ == "__main__":
main()
+232
View File
@@ -0,0 +1,232 @@
import csv
import io
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.alphas import upsert_alpha
from app.models import Account, Admin, LoginSession, Research, now
from app.security import cipher
from tests.conftest import ADMIN_PASSWORD, alpha
PREFIX = "/api/v1"
async def seed(app, count=1):
async with app.state.sessions() as db:
for i in range(count):
await upsert_alpha(db, alpha(f"a{i:04}", name=f"Signal {i:04}", **{"is": {"sharpe": i / 100}}))
await db.commit()
async def test_login_logout_expiry_and_password_hash(app, client):
assert (await client.get(f"{PREFIX}/alphas")).status_code == 401
wrong = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
assert wrong.status_code == 401
login = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
assert login.status_code == 200
assert "HttpOnly" in login.headers["set-cookie"] and "SameSite=strict" in login.headers["set-cookie"]
token = client.cookies.get("wq_session")
async with app.state.sessions() as db:
admin = await db.get(Admin, 1)
session = (await db.scalars(select(LoginSession))).one()
assert admin.password_hash.startswith("$argon2id$") and ADMIN_PASSWORD not in admin.password_hash
assert session.token_hash != token
assert (await client.post(f"{PREFIX}/auth/logout")).status_code == 200
client.cookies.set("wq_session", token)
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
async with app.state.sessions() as db:
session = (await db.scalars(select(LoginSession))).one()
session.expires_at = now() - timedelta(seconds=1)
await db.commit()
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
async def test_login_rate_limit_and_csrf(client):
for _ in range(5):
assert (
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
).status_code == 401
assert (
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
).status_code == 429
assert (
await client.post(f"{PREFIX}/auth/login", headers={"X-WQ-Request": ""}, json={})
).status_code == 403
assert (
await client.post(f"{PREFIX}/auth/login", headers={"Origin": "https://evil.example"}, json={})
).status_code == 403
async def test_public_session_cookie_is_secure(app, client):
app.state.settings.cookie_secure = True
response = await client.post(
f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}
)
assert response.status_code == 200
assert "; Secure" in response.headers["set-cookie"] and "; HttpOnly" in response.headers["set-cookie"]
async def test_credentials_encrypted_and_never_returned(app, logged_in):
secret = "private-worldquant-password"
r = await logged_in.put(
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": secret}
)
assert r.status_code == 200 and r.json()["configured"] is True
assert "password" not in r.text and secret not in r.text
async with app.state.sessions() as db:
account = await db.get(Account, 1)
assert secret not in account.password_encrypted
assert cipher(app.state.settings).decrypt(account.password_encrypted.encode()).decode() == secret
account.wq_user_id = "person"
await db.commit()
assert (
await logged_in.put(
f"{PREFIX}/account/credentials", json={"email": "other@example.com", "password": secret}
)
).status_code == 409
invalid = await logged_in.put(
f"{PREFIX}/account/credentials", json={"email": "invalid", "password": secret}
)
assert invalid.status_code == 422 and secret not in invalid.text
response = await logged_in.get(f"{PREFIX}/account")
assert response.json()["profile"] == {} and "password" not in response.text
async def test_preferences_and_openapi(app, logged_in):
values = {"display_name": "My Lab", "theme": "dark", "timezone": "UTC", "page_size": 100}
r = await logged_in.patch(f"{PREFIX}/account/preferences", json=values)
assert r.status_code == 200 and all(r.json()[k] == v for k, v in values.items())
values["timezone"] = "invalid/timezone"
assert (await logged_in.patch(f"{PREFIX}/account/preferences", json=values)).status_code == 422
contract = app.openapi()
assert contract["paths"]["/api/v1/alphas"]["get"]["security"] == [{"APIKeyCookie": []}]
assert "text/csv" in contract["paths"]["/api/v1/alphas/export"]["get"]["responses"]["200"]["content"]
assert contract["paths"]["/api/v1/alphas"]["get"]["responses"]["422"]["content"]["application/json"][
"schema"
]["$ref"].endswith("/ErrorOutput")
async def test_local_research_survives_snapshot_and_bulk_is_atomic(app, logged_in):
await seed(app, 3)
r = await logged_in.patch(
f"{PREFIX}/alphas/a0000/research",
json={"note": "keep hypothesis", "tags": ["a", "a"], "state": "candidate", "favorite": True},
)
assert r.status_code == 200
async with app.state.sessions() as db:
await upsert_alpha(db, alpha("a0000", **{"is": {"sharpe": 9.25}}))
await db.commit()
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
assert detail["sharpe"] == 9.25 and detail["fitness"] is None
assert detail["research"]["note"] == "keep hypothesis" and detail["research"]["favorite"]
assert detail["research"]["tags"] == ["a"] and detail["research"]["state"] == "candidate"
invalid = await logged_in.patch(
f"{PREFIX}/alphas/research/bulk", json={"alpha_ids": ["a0000", "missing"], "add_tags": ["bad"]}
)
assert invalid.status_code == 404
assert (await logged_in.get(f"{PREFIX}/alphas?tag=bad")).json()["total"] == 0
r = await logged_in.patch(
f"{PREFIX}/alphas/research/bulk",
json={
"alpha_ids": ["a0000", "a0001"],
"add_tags": ["new"],
"remove_tags": ["a"],
"state": "optimizing",
},
)
assert r.json()["updated"] == 2
result = (await logged_in.get(f"{PREFIX}/alphas?tag=new&research_state=optimizing")).json()
assert result["total"] == 2
assert (await logged_in.get(f"{PREFIX}/alphas?tag=ne")).json()["total"] == 0
await logged_in.patch(f"{PREFIX}/alphas/a0000/research", json={"note": "updated"})
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
assert detail["research"]["favorite"] and detail["research"]["tags"] == ["new"]
async def test_filter_pagination_sort_and_export_over_500(app, logged_in):
await seed(app, 603)
query = "region=USA&sharpe_min=0.02&sharpe_max=6&sort=sharpe&direction=desc"
page = (await logged_in.get(f"{PREFIX}/alphas?{query}&offset=10&limit=25")).json()
assert page["total"] == 599 and len(page["items"]) == 25
assert page["items"][0]["id"] == "a0590"
exported = await logged_in.get(f"{PREFIX}/alphas/export?{query}&offset=10&limit=25")
rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff"))))
assert len(rows) == 599 and rows[0]["id"] == "a0600" and rows[-1]["id"] == "a0002"
assert (await logged_in.get(f"{PREFIX}/alphas?q=Signal%200001")).json()["total"] == 1
assert (await logged_in.get(f"{PREFIX}/alphas?q=%25")).json()["total"] == 0
for query in (
"sort=raw",
"sharpe_min=nan",
"sharpe_min=inf",
"sharpe_min=5&sharpe_max=2",
"limit=1000",
"created_from=2026-01-01&created_to=2025-01-01T00:00:00Z",
):
assert (await logged_in.get(f"{PREFIX}/alphas?{query}")).status_code == 422
async def test_export_formula_injection_and_detail_variants(app, logged_in):
async with app.state.sessions() as db:
await upsert_alpha(
db,
alpha(
"super1",
name="=DANGEROUS()",
type="SUPER",
regular=None,
selection={"code": "self_correlation < 0.5"},
combo={"code": "alpha"},
settings={"language": "FASTEXPR"},
**{"is": {"sharpe": None, "fitness": float("nan")}},
),
)
await upsert_alpha(
db,
alpha(
"python1", regular={"code": "def alpha():\n return close"}, settings={"language": "PYTHON"}
),
)
await db.commit()
row = await db.get(Research, "super1")
row.note = " @formula()"
await db.commit()
detail = (await logged_in.get(f"{PREFIX}/alphas/super1")).json()
assert detail["selection"] == "self_correlation < 0.5" and detail["combo"] == "alpha"
assert detail["expression"] is None and detail["sharpe"] is None and detail["fitness"] is None
assert (await logged_in.get(f"{PREFIX}/alphas/python1")).json()["language"] == "PYTHON"
csv_data = (await logged_in.get(f"{PREFIX}/alphas/export?q=super1")).text
row = list(csv.DictReader(io.StringIO(csv_data.lstrip("\ufeff"))))[0]
assert row["name"] == "'=DANGEROUS()" and row["note"] == "' @formula()"
assert (await logged_in.get(f"{PREFIX}/alphas/super1/pnl")).json() == {
"cached": False,
"points": [],
"fetched_at": None,
}
async def test_job_api_deduplicates_and_retries(app, logged_in):
assert (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).status_code == 409
await logged_in.put(
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": "only-testing"}
)
connect = await logged_in.post(f"{PREFIX}/account/connect")
assert connect.status_code == 202
first = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
again = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
assert first["id"] == again["id"]
await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/cancel")
assert (await logged_in.get(f"{PREFIX}/sync-jobs/{first['id']}")).json()["status"] == "cancelled"
assert (await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/retry")).json()["status"] == "queued"
assert (
await logged_in.post(
f"{PREFIX}/sync-jobs", json={"kind": "alpha_refresh", "alpha_ids": ["../authentication"]}
)
).status_code == 422
@pytest.mark.parametrize("endpoint", ["account", "alphas", "alphas/export", "alphas/a1/pnl", "sync-jobs"])
async def test_every_data_endpoint_requires_session(client, endpoint):
assert (await client.get(f"{PREFIX}/{endpoint}")).status_code == 401
+251
View File
@@ -0,0 +1,251 @@
import asyncio
from sqlalchemy import func, select
from sqlalchemy.exc import OperationalError
from app.jobs import Runner, create_job
from app.models import Account, Alpha, Job, JobItem, Pnl, Research
from app.security import cipher
from app.worldquant import VerificationRequired, WqError
from tests.conftest import alpha
class FakePlatform:
def __init__(self):
self.verification_url = None
self.on_retry = None
self.page_calls = []
self.fail_page = True
self.fail_id = True
self.require_verification = False
self.block = None
async def authenticate(self, *args, **kwargs):
if self.require_verification:
self.verification_url = "https://api.worldquantbrain.com/authentication/persona/test"
raise VerificationRequired(self.verification_url)
async def verify(self):
self.require_verification = False
self.verification_url = None
async def profile(self):
return {"id": "user1", "email": "test@example.com", "password": "never-save", "unknown": 1}
async def alphas(self, submission, hidden, offset, before):
self.page_calls.append((submission, hidden, offset, before))
if self.block:
self.block.set()
await asyncio.Future()
if submission == "UNSUBMITTED" and not hidden:
if offset == 0:
return {"results": [alpha("a1"), alpha("a2")], "count": 4}
if self.fail_page:
raise WqError("模拟第二页网络故障", "network_error")
return {"results": [alpha("a2", **{"is": {"sharpe": 7.0}}), alpha("a3")], "count": 4}
if submission == "SUBMITTED" and hidden:
return {"results": [alpha("hidden1", hidden=True, stage="OS", status="ACTIVE")], "next": None}
return {"results": [], "next": None}
async def alpha(self, alpha_id):
if alpha_id == "bad" and self.fail_id:
raise WqError("模拟无权访问", "access_denied")
return alpha(alpha_id, **{"is": {"sharpe": 8.0}})
async def pnl(self, alpha_id):
return {
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
"records": [["2025-01-01", 100]],
}
def disconnect(self):
self.verification_url = None
async def close(self):
pass
async def ready_runner(app):
async with app.state.sessions() as db:
account = await db.get(Account, 1)
account.email = "test@example.com"
account.password_encrypted = cipher(app.state.settings).encrypt(b"test-password").decode()
account.connection_status = "connected"
await db.commit()
return Runner(app.state.sessions, app.state.settings, FakePlatform())
async def job_for(runner, kind="full_sync", ids=None):
async with runner.sessions() as db:
return (await create_job(db, kind, {"alpha_ids": ids or []})).id
async def result(runner, job_id):
async with runner.sessions() as db:
return await db.get(Job, job_id)
async def test_pages_commit_resume_dedupe_and_preserve_research(app):
runner = await ready_runner(app)
job_id = await job_for(runner)
await runner.execute(job_id)
failed = await result(runner, job_id)
assert failed.status == "failed" and failed.processed == 2
assert failed.checkpoint == {"partition": 0, "offset": 2}
async with runner.sessions() as db:
research = await db.get(Research, "a2")
research.note, research.tags, research.state = "keep me", ["keep"], "candidate"
await db.commit()
runner.client.fail_page = False
runner.client.page_calls.clear()
await runner.execute(job_id)
finished = await result(runner, job_id)
assert finished.status == "completed" and finished.processed == 4 and finished.total == 4
assert runner.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
assert len({call[3] for call in runner.client.page_calls}) == 1
async with runner.sessions() as db:
assert await db.scalar(select(func.count()).select_from(Alpha)) == 4
assert (await db.get(Alpha, "a2")).sharpe == 7.0
assert (await db.get(Alpha, "hidden1")).hidden is True
research = await db.get(Research, "a2")
assert (research.note, research.tags, research.state) == ("keep me", ["keep"], "candidate")
assert await db.scalar(select(func.count()).select_from(JobItem)) == 4
# A later full scan updates existing IDs, without deleting unseen local records.
second = await job_for(runner)
await runner.execute(second)
assert (await result(runner, second)).processed == 4
async def test_partial_ids_retry_only_errors_and_pnl_cache(app):
runner = await ready_runner(app)
job_id = await job_for(runner, "alpha_refresh", ["good", "bad"])
await runner.execute(job_id)
partial = await result(runner, job_id)
assert (partial.status, partial.processed, partial.failed, partial.total) == (
"completed_with_errors",
1,
1,
2,
)
runner.client.fail_id = False
await runner.execute(job_id)
complete = await result(runner, job_id)
assert (complete.status, complete.processed, complete.failed) == ("completed", 2, 0)
pnl_job = await job_for(runner, "pnl_refresh", ["good", "missing"])
await runner.execute(pnl_job)
assert (await result(runner, pnl_job)).failed == 1
async with runner.sessions() as db:
pnl = await db.get(Pnl, "good")
assert pnl.points == [{"date": "2025-01-01", "value": 100.0}]
async def test_human_verification_waits_then_resumes_pending_jobs(app):
runner = await ready_runner(app)
runner.client.require_verification = True
job_id = await job_for(runner, "alpha_refresh", ["good"])
await runner.execute(job_id)
assert (await result(runner, job_id)).status == "waiting_auth"
async with runner.sessions() as db:
account = await db.get(Account, 1)
assert account.connection_status == "verification_required" and account.verification_url
verification = await job_for(runner, "verify")
await runner.execute(verification)
assert (await result(runner, verification)).status == "completed"
assert (await result(runner, job_id)).status == "queued"
async with runner.sessions() as db:
account = await db.get(Account, 1)
assert (
account.wq_user_id == "user1"
and "password" not in account.profile
and "unknown" not in account.profile
)
await runner.execute(job_id)
assert (await result(runner, job_id)).status == "completed"
async def wait_status(runner, job_id, expected):
async with asyncio.timeout(5):
while (await result(runner, job_id)).status not in expected:
await asyncio.sleep(0.02)
async def test_user_cancel_interrupts_request_and_disconnect_pauses(app):
runner = await ready_runner(app)
runner.client.block = asyncio.Event()
job_id = await job_for(runner)
await runner.start()
await asyncio.wait_for(runner.client.block.wait(), 3)
async with runner.sessions() as db:
job = await db.get(Job, job_id)
job.cancel_requested = True
await db.commit()
await runner.cancel(job_id)
await wait_status(runner, job_id, {"cancelled"})
runner.client.block.clear()
pending = await job_for(runner)
runner.wake.set()
await asyncio.wait_for(runner.client.block.wait(), 3)
await runner.disconnect()
assert (await result(runner, pending)).status == "waiting_connection"
async with runner.sessions() as db:
assert (await db.get(Account, 1)).connection_status == "disconnected"
await runner.stop()
async def test_restart_recovers_running_job_and_keeps_checkpoint(app):
runner = await ready_runner(app)
runner.client.block = asyncio.Event()
job_id = await job_for(runner)
await runner.start()
await asyncio.wait_for(runner.client.block.wait(), 3)
await runner.stop()
assert (await result(runner, job_id)).status == "queued"
# Simulate abrupt process loss after a committed page, before the running flag was cleared.
async with runner.sessions() as db:
job = await db.get(Job, job_id)
job.status, job.checkpoint = "running", {"partition": 0, "offset": 2}
await db.commit()
resumed = Runner(runner.sessions, runner.settings, FakePlatform())
resumed.client.fail_page = False
await resumed.start()
await wait_status(resumed, job_id, {"completed", "failed"})
await resumed.stop()
assert (await result(resumed, job_id)).status == "completed"
assert resumed.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
async def test_identity_change_fails_without_overwriting_profile(app):
runner = await ready_runner(app)
async with runner.sessions() as db:
account = await db.get(Account, 1)
account.wq_user_id = "original-user"
await db.commit()
job_id = await job_for(runner, "connect")
await runner.execute(job_id)
assert (await result(runner, job_id)).status == "waiting_connection"
async with runner.sessions() as db:
assert (await db.get(Account, 1)).wq_user_id == "original-user"
async def test_scheduler_recovers_after_database_outage(app):
runner = await ready_runner(app)
job_id = await job_for(runner, "alpha_refresh", ["good"])
original = runner.run_next
attempts = 0
async def failing_once():
nonlocal attempts
attempts += 1
if attempts == 1:
async with runner.sessions() as db:
job = await db.get(Job, job_id)
job.status = "running"
await db.commit()
raise OperationalError("", {}, Exception("simulated database restart"))
await original()
runner.run_next = failing_once
await runner.start()
await wait_status(runner, job_id, {"completed", "failed"})
await runner.stop()
assert attempts >= 2 and (await result(runner, job_id)).status == "completed"
+186
View File
@@ -0,0 +1,186 @@
import asyncio
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
import httpx
import pytest
from app.alphas import pnl_points, sanitize
from app.worldquant import VerificationRequired, WqClient, WqError
async def test_cookie_auth_expiry_and_read_only_boundary(settings):
calls, auth_count = [], 0
def handler(request):
nonlocal auth_count
calls.append((request.method, request.url.path))
if request.url.path == "/authentication":
auth_count += 1
return httpx.Response(201, headers={"Set-Cookie": f"t=session{auth_count}; Path=/"}, json={})
assert "Authorization" not in request.headers
if request.headers.get("Cookie") == "t=session1":
return httpx.Response(401)
return httpx.Response(200, json={"id": "user1"})
client = WqClient(settings, transport=httpx.MockTransport(handler))
await client.authenticate("person@example.com", "secret")
assert await client.profile() == {"id": "user1"}
assert auth_count == 2
await asyncio.gather(*(client.authenticate("person@example.com", "secret") for _ in range(5)))
assert auth_count == 2
assert all(method == "GET" or path == "/authentication" for method, path in calls)
client.disconnect()
assert client.credentials is None and not list(client.client.cookies.items())
await client.close()
async def test_persona_verification_uses_same_cookie_and_safe_location(settings):
complete = False
def handler(request):
if request.url.path == "/authentication":
return httpx.Response(
401,
headers={
"WWW-Authenticate": "persona",
"Location": "/authentication/persona/challenge",
"Set-Cookie": "challenge=abc; Path=/",
},
)
assert request.headers.get("Cookie") == "challenge=abc"
return httpx.Response(201 if complete else 202, json={})
client = WqClient(settings, transport=httpx.MockTransport(handler))
with pytest.raises(VerificationRequired) as error:
await client.authenticate("test@example.com", "secret")
assert error.value.url.endswith("/authentication/persona/challenge")
with pytest.raises(VerificationRequired):
await client.verify()
complete = True
await client.verify()
assert client.authenticated and client.verification_url is None
await client.close()
unsafe = WqClient(
settings,
transport=httpx.MockTransport(
lambda r: httpx.Response(
401, headers={"WWW-Authenticate": "persona", "Location": "https://evil.example/secret"}
)
),
)
with pytest.raises(WqError) as error:
await unsafe.authenticate("test@example.com", "secret")
assert error.value.code == "invalid_verification"
await unsafe.close()
async def test_retry_after_network_budget_pending_and_permissions(settings):
delays, requests = [], 0
async def sleep(delay):
delays.append(delay)
def handler(request):
nonlocal requests
requests += 1
if requests == 1:
return httpx.Response(429, headers={"Retry-After": "120"})
if requests == 2:
raise httpx.ConnectError("contains private body", request=request)
return httpx.Response(200, json={"id": "ready"})
client = WqClient(settings, transport=httpx.MockTransport(handler), sleep=sleep)
client.credentials, client.authenticated = ("test@example.com", "secret"), True
assert await client.profile() == {"id": "ready"}
assert delays[0] == 120 and len(delays) == 2
date = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=60), usegmt=True)
assert 58 < client.retry_delay(date, 0) <= 60
await client.close()
for status, code in [(403, "access_denied"), (404, "not_found"), (503, "retry_exhausted")]:
c = WqClient(
settings,
transport=httpx.MockTransport(lambda r: httpx.Response(status, text="private data")),
sleep=sleep,
)
c.credentials, c.authenticated = ("test@example.com", "secret"), True
with pytest.raises(WqError) as error:
await c.profile()
assert error.value.code == code and "private" not in str(error.value)
await c.close()
responses = iter(
[httpx.Response(202, headers={"Retry-After": "1"}), httpx.Response(200, json={"records": []})]
)
c = WqClient(settings, transport=httpx.MockTransport(lambda r: next(responses)), sleep=sleep)
c.credentials, c.authenticated = ("test@example.com", "secret"), True
assert await c.pnl("abc") == {"records": []}
await c.close()
@pytest.mark.parametrize(
"raw,expected",
[
(
{
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
"records": [["2025-01-01", 10], ["2025-01-02", None]],
},
[{"date": "2025-01-01", "value": 10.0}, {"date": "2025-01-02", "value": None}],
),
(
{"schema": {"properties": [{"name": "pnl"}, {"name": "date"}]}, "records": [[20, "2025-01-01"]]},
[{"date": "2025-01-01", "value": 20.0}],
),
(
{"schema": {"properties": {"date": {}, "pnl": {}}}, "records": [["2025-01-01", "NaN"]]},
[{"date": "2025-01-01", "value": None}],
),
(
{"records": [{"timestamp": 1735689600000, "value": "5"}]},
[{"date": "2025-01-01T00:00:00+00:00", "value": 5.0}],
),
],
)
def test_pnl_schemas_preserve_null(raw, expected):
assert pnl_points(raw) == expected
def test_unknown_pnl_schema_fails_instead_of_fabricating():
with pytest.raises(ValueError):
pnl_points({"records": [["2025-01-01", 100]]})
def test_sensitive_response_keys_are_removed_recursively():
assert sanitize(
{
"id": "a1",
"accessToken": "secret",
"nested": [{"refresh_token": "secret", "client-secret": "secret", "value": 1}],
"Set-Cookie": "secret",
}
) == {"id": "a1", "nested": [{"value": 1}]}
async def test_concurrent_expiry_authenticates_once(settings):
auth_count = 0
first_requests = 0
both_expired = asyncio.Event()
async def handler(request):
nonlocal auth_count, first_requests
if request.method == "POST":
auth_count += 1
return httpx.Response(201, json={})
if first_requests < 2:
first_requests += 1
if first_requests == 2:
both_expired.set()
await both_expired.wait()
return httpx.Response(401)
return httpx.Response(200, json={"id": "user"})
client = WqClient(settings, transport=httpx.MockTransport(handler))
await client.authenticate("test@example.com", "secret")
assert await asyncio.gather(client.profile(), client.profile()) == [{"id": "user"}, {"id": "user"}]
assert auth_count == 2 # Initial connection plus one shared reauthentication.
await client.close()
+699
View File
@@ -0,0 +1,699 @@
version = 1
revision = 2
requires-python = "==3.12.*"
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "alembic"
version = "1.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/10/181eecdd552217d0342492bd6f3b8a96e973083379aace3d3402830ddc03/alembic-1.19.2.tar.gz", hash = "sha256:297950a8a91f6770eb82bfbce9bea55c728b90a5386c6e81430191a319d138b0", size = 2082643, upload-time = "2026-09-04T17:10:11.212Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2", size = 267399, upload-time = "2026-09-04T17:10:12.796Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "greenlet"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" },
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" },
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" },
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" },
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" },
{ url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "mako"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "ruff"
version = "0.16.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
{ url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
{ url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
{ url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
{ url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
{ url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
{ url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
{ url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
{ url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
{ url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" },
{ url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" },
{ url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" },
{ url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" },
{ url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" },
{ url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" },
{ url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" },
{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.52.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
]
[[package]]
name = "websockets"
version = "17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
{ url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
{ url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
{ url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
{ url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
{ url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
{ url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
{ url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
{ url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
{ url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
{ url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
{ url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
]
[[package]]
name = "wq-alpha-system"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "aiosqlite" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.15,<2" },
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "cryptography", specifier = ">=44,<50" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pydantic-settings", specifier = ">=2.8,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38,<2.1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "aiosqlite", specifier = ">=0.21,<1" },
{ name = "pytest", specifier = ">=8,<10" },
{ name = "pytest-asyncio", specifier = ">=0.25,<2" },
{ name = "ruff", specifier = ">=0.11,<1" },
]