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:
@@ -0,0 +1,3 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
@@ -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"])
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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, "系统登录已过期,请重新登录")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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 ###
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Generated
+699
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user