From b90f09d8b6ebc7c1eb21b263792b4ff61c584eff Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Thu, 8 Jan 2026 12:47:43 -0500 Subject: [PATCH 1/8] feat: enhance scenario configuration by adding payloads and forkchoice update paths, and network options --- example-expb.yaml | 13 +++++++------ src/expb/configs/scenarios.py | 33 ++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/example-expb.yaml b/example-expb.yaml index 2803cd9..08686bb 100644 --- a/example-expb.yaml +++ b/example-expb.yaml @@ -1,7 +1,4 @@ -# Network to use -network: mainnet - -# Pull images before execution +# Pull images before execution pull_images: true # Docker images to use @@ -11,8 +8,6 @@ images: # Paths for the payloads jsonl file, work directory, and outputs directory paths: - payloads: ./payloads.jsonl # Each line is a payload request body - fcus: ./fcus.jsonl # Each line is a forkchoice update request body work: ./work outputs: ./outputs @@ -58,6 +53,12 @@ scenarios: client: nethermind # Available clients: nethermind, besu, erigon, geth, reth # Required: Snapshot source for the selected client and network (either a path or zfs snapshot name) snapshot_source: ./snapthots/nethermind + # Required: Path to the new payloads requests. + payloads: ./payloads.jsonl # Each line is a payload request body + # Required: Path to the new forkchoice update requests. + fcus: ./fcus.jsonl # Each line is a forkchoice update request body + # Optional: Network to use for the scenario. Defaults to mainnet + network: mainnet # Optional: Snapshot backend to use. Available snapshot backends: overlay, zfs, copy. Defaults to overlay snapshot_backend: overlay # Optional: Snapshot path for copy backend. If defined, this path will be used instead of work_dir/snapshot. Only used when snapshot_backend is copy diff --git a/src/expb/configs/scenarios.py b/src/expb/configs/scenarios.py index e66e6da..f652a1f 100644 --- a/src/expb/configs/scenarios.py +++ b/src/expb/configs/scenarios.py @@ -41,6 +41,23 @@ def __init__( if client_name is None or not isinstance(client_name, str): raise ValueError(f"Client name is required for scenario {name}") self.client: Client = Client[client_name.upper()] + # Path to the payloads requests + payloads_file: str = config.get("payloads", PAYLOADS_DEFAULT_FILE) + self.payloads_file = Path(payloads_file) + if not self.payloads_file.exists() or not self.payloads_file.is_file(): + raise FileNotFoundError( + f"Payloads file {self.payloads_file} not found or not a file" + ) + # Path to the forkchoice updated requests + fcus_file: str = config.get("fcus", FCUS_DEFAULT_FILE) + self.fcus_file = Path(fcus_file) + if not self.fcus_file.exists() or not self.fcus_file.is_file(): + raise FileNotFoundError( + f"Fcus file {self.fcus_file} not found or not a file" + ) + # Network of the scenario + config_network: str = config.get("network", Network.MAINNET.name) + self.network = Network[config_network.upper()] # Image of the client to use self.client_image: str | None = config.get("image", None) # Skip number of payloads @@ -129,10 +146,6 @@ def __init__(self, config_file: Path): if not isinstance(config, dict): raise ValueError("Invalid config file") - # Parse network configuration - config_network: str = config.get("network", Network.MAINNET.name) - self.network = Network[config_network.upper()] - # Parse docker images configurations pull_images: bool = config.get("pull_images", False) self.pull_images = pull_images @@ -143,12 +156,6 @@ def __init__(self, config_file: Path): # Paths for the payloads jsonl file, fcus jsonl file, work directory, and outputs directory paths: dict[str, str] = config.get("paths", {}) - payloads_file: str = paths.get("payloads", PAYLOADS_DEFAULT_FILE) - self.payloads_file = Path(payloads_file) - - fcus_file: str = paths.get("fcus", FCUS_DEFAULT_FILE) - self.fcus_file = Path(fcus_file) - work_dir: str = paths.get("work", WORK_DEFAULT_DIR) self.work_dir = Path(work_dir) @@ -209,7 +216,7 @@ def get_scenario_executor( scenario_name=scenario.name, snapshot_source=scenario.snapshot_source, snapshot_service=snapshot_service, - network=self.network, + network=scenario.network, execution_client=scenario.client, execution_client_image=scenario.client_image, execution_client_extra_flags=scenario.extra_flags, @@ -217,8 +224,8 @@ def get_scenario_executor( execution_client_extra_volumes=scenario.extra_volumes, execution_client_extra_commands=scenario.extra_commands, startup_wait=scenario.startup_wait, - payloads_file=self.payloads_file, - fcus_file=self.fcus_file, + payloads_file=scenario.payloads_file, + fcus_file=scenario.fcus_file, work_dir=self.work_dir, docker_container_cpus=self.docker_container_cpus, docker_container_download_speed=self.docker_container_download_speed, From 9b250a9428533e1dce79ea196eaa403ff934226b Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Tue, 24 Feb 2026 13:35:54 -0500 Subject: [PATCH 2/8] feat: implement API for benchmark runs and scenarios with FastAPI integration - Added FastAPI application structure with routes for managing benchmark runs and scenarios. - Implemented database models for run tracking and API token management. - Introduced authentication via Bearer tokens for API access. - Created endpoints for submitting runs, listing runs, and retrieving run details. - Added functionality for downloading run outputs as ZIP archives. - Integrated metrics parsing from K6 summary files for detailed performance insights. - Established a background worker for processing benchmark runs asynchronously. - Updated CLI commands to support new API features and improved logging capabilities. --- pyproject.toml | 3 + src/expb/__init__.py | 19 +- src/expb/api/__init__.py | 0 src/expb/api/app.py | 71 ++++++ src/expb/api/auth.py | 36 +++ src/expb/api/db/__init__.py | 0 src/expb/api/db/engine.py | 41 ++++ src/expb/api/db/models.py | 61 ++++++ src/expb/api/dependencies.py | 14 ++ src/expb/api/metrics.py | 63 ++++++ src/expb/api/routes/__init__.py | 0 src/expb/api/routes/runs.py | 175 +++++++++++++++ src/expb/api/routes/scenarios.py | 34 +++ src/expb/api/schemas/__init__.py | 0 src/expb/api/schemas/runs.py | 79 +++++++ src/expb/api/worker.py | 210 ++++++++++++++++++ src/expb/cli/__init__.py | 20 ++ src/expb/cli/api/__init__.py | 8 + src/expb/cli/api/serve.py | 63 ++++++ src/expb/cli/api/tokens.py | 125 +++++++++++ src/expb/{ => cli}/compress_payloads.py | 0 src/expb/{ => cli}/execute_scenario.py | 0 src/expb/{ => cli}/execute_scenarios.py | 0 src/expb/{ => cli}/generate_payloads.py | 0 src/expb/{ => cli}/send_payloads.py | 0 uv.lock | 277 +++++++++++++++++++++++- 26 files changed, 1280 insertions(+), 19 deletions(-) create mode 100644 src/expb/api/__init__.py create mode 100644 src/expb/api/app.py create mode 100644 src/expb/api/auth.py create mode 100644 src/expb/api/db/__init__.py create mode 100644 src/expb/api/db/engine.py create mode 100644 src/expb/api/db/models.py create mode 100644 src/expb/api/dependencies.py create mode 100644 src/expb/api/metrics.py create mode 100644 src/expb/api/routes/__init__.py create mode 100644 src/expb/api/routes/runs.py create mode 100644 src/expb/api/routes/scenarios.py create mode 100644 src/expb/api/schemas/__init__.py create mode 100644 src/expb/api/schemas/runs.py create mode 100644 src/expb/api/worker.py create mode 100644 src/expb/cli/__init__.py create mode 100644 src/expb/cli/api/__init__.py create mode 100644 src/expb/cli/api/serve.py create mode 100644 src/expb/cli/api/tokens.py rename src/expb/{ => cli}/compress_payloads.py (100%) rename src/expb/{ => cli}/execute_scenario.py (100%) rename src/expb/{ => cli}/execute_scenarios.py (100%) rename src/expb/{ => cli}/generate_payloads.py (100%) rename src/expb/{ => cli}/send_payloads.py (100%) diff --git a/pyproject.toml b/pyproject.toml index baff164..1a0fb38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,16 @@ authors = [ requires-python = ">=3.13" dependencies = [ "docker>=7.1.0", + "fastapi>=0.133.0", "filelock>=3.24.3", "jinja2>=3.1.6", "pydantic>=2.11.7", "pyyaml>=6.0.2", "rich>=14.0.0", + "sqlalchemy>=2.0.46", "structlog>=25.4.0", "typer>=0.16.0", + "uvicorn[standard]>=0.41.0", "web3>=7.12.0", ] diff --git a/src/expb/__init__.py b/src/expb/__init__.py index 7dcb11f..8ea6e16 100644 --- a/src/expb/__init__.py +++ b/src/expb/__init__.py @@ -1,21 +1,8 @@ import typer -from expb.compress_payloads import app as compress_payloads_app -from expb.execute_scenario import app as execute_scenario_app -from expb.execute_scenarios import app as execute_scenarios_app -from expb.generate_payloads import app as generate_payloads_app -from expb.send_payloads import app as send_payloads_app +from expb.cli import app as cli_app app = typer.Typer() -typer_apps = [ - generate_payloads_app, - execute_scenario_app, - execute_scenarios_app, - compress_payloads_app, - send_payloads_app, -] - - -for typer_app in typer_apps: - app.add_typer(typer_app) +# All commands (including the `api` sub-group) are registered via cli/ +app.add_typer(cli_app) diff --git a/src/expb/api/__init__.py b/src/expb/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/expb/api/app.py b/src/expb/api/app.py new file mode 100644 index 0000000..5db10c9 --- /dev/null +++ b/src/expb/api/app.py @@ -0,0 +1,71 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +import yaml +from fastapi import FastAPI + +from expb.api.db.engine import init_db +from expb.api.worker import BenchmarkWorker +from expb.configs.scenarios import Scenarios + + +def create_app( + config_file: Path, + db_path: Path, + log_level: str = "INFO", +) -> FastAPI: + """ + FastAPI application factory. + + Creates the app, wires up the DB, loads the scenarios config, starts the + background benchmark worker, and registers all routers. + + Parameters + ---------- + config_file: + Path to the expb YAML configuration file. + db_path: + Path to the SQLite database file. + log_level: + Log level string passed to the worker's structured logger. + """ + + @asynccontextmanager + async def lifespan(app: FastAPI): + # 1. Initialise DB (creates tables, enables WAL mode) + init_db(db_path) + + # 2. Load scenarios config and stash on app.state for routes to access + with config_file.open() as f: + raw = yaml.safe_load(f) + scenarios = Scenarios(**raw) + app.state.scenarios = scenarios + app.state.config_file = config_file + + # 3. Start the background benchmark worker thread + worker = BenchmarkWorker(scenarios=scenarios, log_level=log_level) + app.state.worker = worker + worker.start() + + yield + + # 4. Graceful shutdown: signal worker to finish current job then stop + worker.stop() + + app = FastAPI( + title="expb Benchmark Queue API", + description=( + "Queue and monitor Ethereum execution client benchmark runs. " + "All endpoints require Bearer token authentication." + ), + version="0.1.0", + lifespan=lifespan, + ) + + from expb.api.routes.runs import router as runs_router + from expb.api.routes.scenarios import router as scenarios_router + + app.include_router(runs_router, prefix="/runs", tags=["runs"]) + app.include_router(scenarios_router, prefix="/scenarios", tags=["scenarios"]) + + return app diff --git a/src/expb/api/auth.py b/src/expb/api/auth.py new file mode 100644 index 0000000..bef9591 --- /dev/null +++ b/src/expb/api/auth.py @@ -0,0 +1,36 @@ +import hashlib +import hmac + +from fastapi import Depends, HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from expb.api.dependencies import get_db +from expb.api.db.models import ApiToken + +_bearer_scheme = HTTPBearer(auto_error=True) + + +def _hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() + + +def verify_token( + credentials: HTTPAuthorizationCredentials = Security(_bearer_scheme), + db: Session = Depends(get_db), +) -> None: + """ + FastAPI dependency that validates a Bearer token against the DB. + + Raises HTTP 401 if the token is missing, invalid, or revoked. + Use as: ``_: None = Depends(verify_token)`` + """ + computed_hash = _hash_token(credentials.credentials) + + # Load all hashes and compare with hmac.compare_digest to resist timing attacks. + tokens = db.query(ApiToken.token_hash).all() + for (stored_hash,) in tokens: + if hmac.compare_digest(stored_hash, computed_hash): + return + + raise HTTPException(status_code=401, detail="Invalid or revoked token.") diff --git a/src/expb/api/db/__init__.py b/src/expb/api/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/expb/api/db/engine.py b/src/expb/api/db/engine.py new file mode 100644 index 0000000..a778849 --- /dev/null +++ b/src/expb/api/db/engine.py @@ -0,0 +1,41 @@ +from pathlib import Path + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +_engine = None +_SessionLocal = None + + +def init_db(db_path: Path) -> None: + global _engine, _SessionLocal + + _engine = create_engine( + f"sqlite:///{db_path}", + # Required: SQLite connections may be used from multiple threads + # (FastAPI request threads + the background worker thread). + connect_args={"check_same_thread": False}, + ) + + # Enable WAL journal mode so that concurrent reads from FastAPI handlers + # do not block the worker's writes, and vice versa. + with _engine.connect() as conn: + conn.execute(text("PRAGMA journal_mode=WAL")) + + _SessionLocal = sessionmaker(bind=_engine, autoflush=False, autocommit=False) + + from expb.api.db.models import Base + + Base.metadata.create_all(_engine) + + +def get_engine(): + if _engine is None: + raise RuntimeError("Database has not been initialised. Call init_db() first.") + return _engine + + +def get_session() -> Session: + if _SessionLocal is None: + raise RuntimeError("Database has not been initialised. Call init_db() first.") + return _SessionLocal() diff --git a/src/expb/api/db/models.py b/src/expb/api/db/models.py new file mode 100644 index 0000000..14431c2 --- /dev/null +++ b/src/expb/api/db/models.py @@ -0,0 +1,61 @@ +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class RunStatus(str, enum.Enum): + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class Run(Base): + __tablename__ = "runs" + + run_id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + scenario_name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column( + SAEnum(RunStatus), nullable=False, default=RunStatus.QUEUED, index=True + ) + queued_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.now(timezone.utc) + ) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Absolute path to the expb-executor--/ output directory + output_dir: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + # Parsed K6 metrics from k6-summary.json, keyed by group name + # {"engine_newPayload": {"avg": ..., "min": ..., "max": ..., + # "med": ..., "p90": ..., "p95": ..., "p99": ...}, + # "engine_forkchoiceUpdated": {...}} + k6_metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # Full override dict from the API request, stored for audit/replay + overrides: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + +class ApiToken(Base): + __tablename__ = "api_tokens" + + token_id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column( + String(255), nullable=False, unique=True, index=True + ) + # SHA-256 hex digest of the raw token — the raw value is never stored + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.now(timezone.utc) + ) diff --git a/src/expb/api/dependencies.py b/src/expb/api/dependencies.py new file mode 100644 index 0000000..87a9c99 --- /dev/null +++ b/src/expb/api/dependencies.py @@ -0,0 +1,14 @@ +from collections.abc import Generator + +from sqlalchemy.orm import Session + +from expb.api.db.engine import get_session + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency that provides a per-request DB session.""" + db = get_session() + try: + yield db + finally: + db.close() diff --git a/src/expb/api/metrics.py b/src/expb/api/metrics.py new file mode 100644 index 0000000..ef45b6c --- /dev/null +++ b/src/expb/api/metrics.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path + + +def parse_k6_summary(summary_path: Path) -> dict | None: + """ + Parse a k6-summary.json file (produced by K6's ``--summary-export`` flag) + and extract per-group HTTP request duration statistics. + + Returns a dict of the form:: + + { + "engine_newPayload": { + "avg": float, "min": float, "max": float, + "med": float, "p90": float, "p95": float, "p99": float, + }, + "engine_forkchoiceUpdated": { ... }, + } + + Keys whose values are missing from the summary file are set to ``None``. + Returns ``None`` if the file cannot be read or parsed. + """ + try: + with summary_path.open() as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + + metrics = data.get("metrics", {}) + if not metrics: + return None + + # K6 stores group-scoped metrics with keys like: + # "http_req_duration{group:::engine_newPayload}" + # "http_req_duration{group:::engine_forkchoiceUpdated}" + group_keys = { + "engine_newPayload": "http_req_duration{group:::engine_newPayload}", + "engine_forkchoiceUpdated": "http_req_duration{group:::engine_forkchoiceUpdated}", + } + + result: dict[str, dict] = {} + + for group_name, metric_key in group_keys.items(): + metric_data = metrics.get(metric_key) + if metric_data is None: + continue + + values = metric_data.get("values", {}) + if not values: + continue + + # K6 uses "p(90)" notation; normalise to "p90" for clean storage / API output. + result[group_name] = { + "avg": values.get("avg"), + "min": values.get("min"), + "max": values.get("max"), + "med": values.get("med"), + "p90": values.get("p(90)"), + "p95": values.get("p(95)"), + "p99": values.get("p(99)"), + } + + return result if result else None diff --git a/src/expb/api/routes/__init__.py b/src/expb/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/expb/api/routes/runs.py b/src/expb/api/routes/runs.py new file mode 100644 index 0000000..4a51938 --- /dev/null +++ b/src/expb/api/routes/runs.py @@ -0,0 +1,175 @@ +import io +import uuid +import zipfile +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from sqlalchemy.orm import Session + +from expb.api.auth import verify_token +from expb.api.db.models import Run, RunStatus +from expb.api.dependencies import get_db +from expb.api.schemas.runs import ( + K6MetricGroup, + K6Metrics, + RunListResponse, + RunResponse, + SubmitRunRequest, +) +from expb.api.worker import BenchmarkWorker + +router = APIRouter() + + +def _get_worker(request: Request) -> BenchmarkWorker: + return request.app.state.worker + + +def _run_to_response(run: Run) -> RunResponse: + k6: K6Metrics | None = None + if run.k6_metrics: + raw = run.k6_metrics + k6 = K6Metrics( + engine_new_payload=( + K6MetricGroup(**raw["engine_newPayload"]) + if raw.get("engine_newPayload") + else None + ), + engine_forkchoice_updated=( + K6MetricGroup(**raw["engine_forkchoiceUpdated"]) + if raw.get("engine_forkchoiceUpdated") + else None + ), + ) + return RunResponse( + run_id=run.run_id, + scenario_name=run.scenario_name, + status=run.status, + queued_at=run.queued_at, + started_at=run.started_at, + completed_at=run.completed_at, + output_dir=run.output_dir, + error_message=run.error_message, + k6_metrics=k6, + overrides=run.overrides, + ) + + +@router.post("", response_model=RunResponse, status_code=201) +def submit_run( + body: SubmitRunRequest, + request: Request, + db: Session = Depends(get_db), + _: None = Depends(verify_token), +) -> RunResponse: + """Submit a new benchmark run to the execution queue.""" + scenarios = request.app.state.scenarios + if body.scenario_name not in scenarios.scenarios_configs: + raise HTTPException( + status_code=422, + detail=f"Scenario '{body.scenario_name}' not found in the loaded config.", + ) + + run_id = str(uuid.uuid4()) + overrides = body.model_dump(exclude={"scenario_name"}) + + run = Run( + run_id=run_id, + scenario_name=body.scenario_name, + status=RunStatus.QUEUED, + overrides=overrides, + ) + db.add(run) + db.commit() + db.refresh(run) + + _get_worker(request).enqueue(run_id) + + return _run_to_response(run) + + +@router.get("", response_model=RunListResponse) +def list_runs( + db: Session = Depends(get_db), + _: None = Depends(verify_token), + status: str | None = Query(default=None, description="Filter by run status."), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), +) -> RunListResponse: + """List benchmark runs, optionally filtered by status.""" + query = db.query(Run) + if status is not None: + query = query.filter(Run.status == status) + + total = query.count() + runs = ( + query.order_by(Run.queued_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return RunListResponse( + runs=[_run_to_response(r) for r in runs], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{run_id}", response_model=RunResponse) +def get_run( + run_id: str, + db: Session = Depends(get_db), + _: None = Depends(verify_token), +) -> RunResponse: + """Get details and metrics for a single run.""" + run = db.query(Run).filter(Run.run_id == run_id).first() + if run is None: + raise HTTPException(status_code=404, detail="Run not found.") + return _run_to_response(run) + + +@router.get("/{run_id}/download") +def download_run_output( + run_id: str, + db: Session = Depends(get_db), + _: None = Depends(verify_token), +) -> Response: + """Download the output directory of a finished run as a ZIP archive.""" + run = db.query(Run).filter(Run.run_id == run_id).first() + if run is None: + raise HTTPException(status_code=404, detail="Run not found.") + + if run.status not in (RunStatus.COMPLETED, RunStatus.FAILED): + raise HTTPException( + status_code=409, + detail=f"Run has not finished yet (current status: {run.status}).", + ) + + if not run.output_dir: + raise HTTPException( + status_code=404, + detail="No output directory recorded for this run.", + ) + + output_path = Path(run.output_dir) + if not output_path.exists(): + raise HTTPException( + status_code=404, + detail="Output directory no longer exists on disk.", + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for fpath in output_path.rglob("*"): + if fpath.is_file(): + zf.write(fpath, fpath.relative_to(output_path)) + buf.seek(0) + + return Response( + content=buf.read(), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="run-{run_id}.zip"', + }, + ) diff --git a/src/expb/api/routes/scenarios.py b/src/expb/api/routes/scenarios.py new file mode 100644 index 0000000..cb2ba3d --- /dev/null +++ b/src/expb/api/routes/scenarios.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from expb.api.auth import verify_token + +router = APIRouter() + + +class ScenarioInfo(BaseModel): + name: str + client: str + network: str + + +class ScenarioListResponse(BaseModel): + scenarios: list[ScenarioInfo] + + +@router.get("", response_model=ScenarioListResponse) +def list_scenarios( + request: Request, + _: None = Depends(verify_token), +) -> ScenarioListResponse: + """List all scenarios available in the loaded config file.""" + scenarios = request.app.state.scenarios + result = [ + ScenarioInfo( + name=name, + client=sc.client.value.name, + network=sc.network.value.name, + ) + for name, sc in scenarios.scenarios_configs.items() + ] + return ScenarioListResponse(scenarios=result) diff --git a/src/expb/api/schemas/__init__.py b/src/expb/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/expb/api/schemas/runs.py b/src/expb/api/schemas/runs.py new file mode 100644 index 0000000..5fbd3df --- /dev/null +++ b/src/expb/api/schemas/runs.py @@ -0,0 +1,79 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class SubmitRunRequest(BaseModel): + scenario_name: str = Field(description="Name of the scenario defined in the config file.") + # Execution options + per_payload_metrics: bool = Field( + default=False, + description="Collect per-payload K6 metrics (high cardinality).", + ) + print_logs: bool = Field( + default=False, + description="Print K6 and execution client logs to the worker console.", + ) + # Payload parameter overrides — None means use the scenario's default + payloads_amount: int | None = Field( + default=None, + ge=1, + description="Override the number of payloads to execute.", + ) + payloads_skip: int | None = Field( + default=None, + ge=0, + description="Override the number of payloads to skip at the start.", + ) + payloads_delay: float | None = Field( + default=None, + ge=0.0, + description="Override the delay between payload requests (seconds).", + ) + payloads_warmup: int | None = Field( + default=None, + ge=0, + description="Override the number of warmup payloads (no metrics collected).", + ) + + +class K6MetricGroup(BaseModel): + avg: float | None = None + min: float | None = None + max: float | None = None + med: float | None = None + p90: float | None = None + p95: float | None = None + p99: float | None = None + + +class K6Metrics(BaseModel): + engine_new_payload: K6MetricGroup | None = Field(default=None, alias="engine_newPayload") + engine_forkchoice_updated: K6MetricGroup | None = Field( + default=None, alias="engine_forkchoiceUpdated" + ) + + model_config = {"populate_by_name": True} + + +class RunResponse(BaseModel): + run_id: str + scenario_name: str + status: str + queued_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + output_dir: str | None = None + error_message: str | None = None + k6_metrics: K6Metrics | None = None + overrides: dict[str, Any] | None = None + + model_config = {"from_attributes": True} + + +class RunListResponse(BaseModel): + runs: list[RunResponse] + total: int + page: int + page_size: int diff --git a/src/expb/api/worker.py b/src/expb/api/worker.py new file mode 100644 index 0000000..951b076 --- /dev/null +++ b/src/expb/api/worker.py @@ -0,0 +1,210 @@ +import queue +import threading +import traceback +from datetime import datetime, timezone + +from expb.api.db.engine import get_session +from expb.api.db.models import Run, RunStatus +from expb.api.metrics import parse_k6_summary +from expb.configs.scenarios import Scenarios +from expb.logging import Logger, setup_logging +from expb.payloads import Executor, ExecutorExecuteOptions + + +class BenchmarkWorker: + """ + Single background daemon thread that consumes ``run_id`` strings from an + in-memory queue and executes benchmark scenarios one at a time. + + Thread safety notes + ------------------- + * The ``Scenarios`` object is read-only after server startup — no locking needed. + * Each call to ``_process_run`` opens and closes its own DB session so it + never shares a connection with the FastAPI request threads. + * The ``queue.Queue`` is thread-safe by design. + """ + + def __init__( + self, + scenarios: Scenarios, + log_level: str = "INFO", + ) -> None: + self._scenarios = scenarios + self._queue: queue.Queue[str | None] = queue.Queue() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._logger: Logger = setup_logging(log_level) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Spawn the background worker thread and recover any orphaned runs.""" + self._recover_orphaned_runs() + self._thread = threading.Thread( + target=self._run_loop, + name="expb-benchmark-worker", + daemon=True, + ) + self._thread.start() + self._logger.info("Benchmark worker started") + + def stop(self) -> None: + """Signal the worker to stop after the current job completes.""" + self._stop_event.set() + self._queue.put(None) # Unblock queue.get() + if self._thread: + self._thread.join(timeout=5) + self._logger.info("Benchmark worker stopped") + + def enqueue(self, run_id: str) -> None: + """Add a run_id to the execution queue.""" + self._queue.put(run_id) + self._logger.info("Run enqueued", run_id=run_id) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _recover_orphaned_runs(self) -> None: + """ + Mark any runs left in QUEUED or RUNNING state (from a previous server + instance) as FAILED so they don't silently disappear. + """ + db = get_session() + try: + orphaned = ( + db.query(Run) + .filter(Run.status.in_([RunStatus.QUEUED, RunStatus.RUNNING])) + .all() + ) + if orphaned: + for run in orphaned: + run.status = RunStatus.FAILED + run.completed_at = datetime.now(timezone.utc) + run.error_message = "Run was interrupted by a server restart." + db.commit() + self._logger.warning( + "Marked orphaned runs as failed", + count=len(orphaned), + ) + finally: + db.close() + + def _run_loop(self) -> None: + while not self._stop_event.is_set(): + try: + run_id = self._queue.get(block=True, timeout=1.0) + except queue.Empty: + continue + + if run_id is None: # Shutdown sentinel + break + + self._process_run(run_id) + + def _process_run(self, run_id: str) -> None: + db = get_session() + try: + run = db.query(Run).filter(Run.run_id == run_id).first() + if run is None: + self._logger.error("Run not found in DB", run_id=run_id) + return + + # --- Mark as RUNNING --- + run.status = RunStatus.RUNNING + run.started_at = datetime.now(timezone.utc) + db.commit() + + self._logger.info( + "Starting run", + run_id=run_id, + scenario=run.scenario_name, + ) + + # --- Build executor --- + executor = Executor.from_scenarios( + self._scenarios, + scenario_name=run.scenario_name, + logger=self._logger, + ) + + # Apply overrides to executor.config (not to the shared Scenario model) + self._apply_overrides(executor, run.overrides or {}) + + # --- Execute (blocking) --- + options = self._build_options(run.overrides or {}) + executor.execute_scenario(options=options) + + # --- Capture outputs --- + output_dir = str(executor.config.outputs_dir) + k6_summary_path = executor.config.outputs_dir / "k6-summary.json" + k6_metrics = parse_k6_summary(k6_summary_path) + + # --- Mark as COMPLETED --- + run.status = RunStatus.COMPLETED + run.completed_at = datetime.now(timezone.utc) + run.output_dir = output_dir + run.k6_metrics = k6_metrics + db.commit() + + self._logger.info( + "Run completed", + run_id=run_id, + output_dir=output_dir, + ) + + except Exception as exc: + self._logger.error("Run failed", run_id=run_id, error=str(exc)) + try: + # Re-fetch run in case the session state is stale after the exception + run = db.query(Run).filter(Run.run_id == run_id).first() + if run: + run.status = RunStatus.FAILED + run.completed_at = datetime.now(timezone.utc) + run.error_message = ( + f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}" + ) + db.commit() + except Exception as db_exc: + self._logger.error( + "Failed to persist run failure to DB", + run_id=run_id, + error=str(db_exc), + ) + finally: + db.close() + + @staticmethod + def _apply_overrides(executor: Executor, overrides: dict) -> None: + """ + Apply API-provided overrides directly to ``executor.config`` attributes. + + Overrides target the ``ExecutorConfig`` plain-object fields rather than + the shared ``Scenario`` Pydantic model, so there is no risk of polluting + the loaded scenarios for subsequent runs. + """ + if overrides.get("payloads_amount") is not None: + executor.config.k6_payloads_amount = overrides["payloads_amount"] + + if overrides.get("payloads_skip") is not None: + executor.config.k6_payloads_skip = overrides["payloads_skip"] + + if overrides.get("payloads_delay") is not None: + executor.config.k6_payloads_delay = overrides["payloads_delay"] + # Mirror the Pydantic model_validator: warmup_delay defaults to delay + # unless the scenario already set them independently. + executor.config.k6_payloads_warmup_delay = overrides["payloads_delay"] + + if overrides.get("payloads_warmup") is not None: + executor.config.k6_payloads_warmup = overrides["payloads_warmup"] + + @staticmethod + def _build_options(overrides: dict) -> ExecutorExecuteOptions: + return ExecutorExecuteOptions( + print_logs_to_console=overrides.get("print_logs", False), + collect_per_payload_metrics=overrides.get("per_payload_metrics", False), + # per_payload_metrics_logs prints a table to stdout — not useful in API context + per_payload_metrics_logs=False, + ) diff --git a/src/expb/cli/__init__.py b/src/expb/cli/__init__.py new file mode 100644 index 0000000..ab58849 --- /dev/null +++ b/src/expb/cli/__init__.py @@ -0,0 +1,20 @@ +import typer + +from expb.cli.api import app as api_app +from expb.cli.compress_payloads import app as compress_payloads_app +from expb.cli.execute_scenario import app as execute_scenario_app +from expb.cli.execute_scenarios import app as execute_scenarios_app +from expb.cli.generate_payloads import app as generate_payloads_app +from expb.cli.send_payloads import app as send_payloads_app + +app = typer.Typer() + +for _sub in [ + generate_payloads_app, + execute_scenario_app, + execute_scenarios_app, + compress_payloads_app, + send_payloads_app, + api_app, +]: + app.add_typer(_sub) diff --git a/src/expb/cli/api/__init__.py b/src/expb/cli/api/__init__.py new file mode 100644 index 0000000..23057fd --- /dev/null +++ b/src/expb/cli/api/__init__.py @@ -0,0 +1,8 @@ +import typer + +from expb.cli.api.serve import app as serve_app +from expb.cli.api.tokens import app as tokens_app + +app = typer.Typer(name="api", help="API server and token management commands.") +app.add_typer(serve_app) +app.add_typer(tokens_app) diff --git a/src/expb/cli/api/serve.py b/src/expb/cli/api/serve.py new file mode 100644 index 0000000..12eb8fb --- /dev/null +++ b/src/expb/cli/api/serve.py @@ -0,0 +1,63 @@ +from pathlib import Path +from typing import Annotated + +import typer + +app = typer.Typer() + + +@app.command() +def serve( + config_file: Annotated[ + Path, + typer.Option(help="Path to the expb YAML configuration file."), + ] = Path("expb.yaml"), + db_file: Annotated[ + Path, + typer.Option( + help="Path to the SQLite database file used for run history and API tokens." + ), + ] = Path("expb-api.db"), + host: Annotated[ + str, + typer.Option(help="Host address to bind the HTTP server."), + ] = "0.0.0.0", + port: Annotated[ + int, + typer.Option(help="Port to bind the HTTP server."), + ] = 8080, + log_level: Annotated[ + str, + typer.Option(help="Log level (DEBUG, INFO, WARNING, ERROR)."), + ] = "INFO", +) -> None: + """ + Start the expb benchmark queue API server. + + Launches a FastAPI HTTP server and a single background worker thread that + executes benchmark runs sequentially. Only one run executes at a time. + """ + import uvicorn + + if not config_file.exists() or not config_file.is_file(): + typer.echo(f"Error: config file '{config_file}' not found.", err=True) + raise typer.Exit(code=1) + + from expb.api.app import create_app + + fastapi_app = create_app( + config_file=config_file, + db_path=db_file, + log_level=log_level, + ) + + # workers must stay at 1: the background benchmark worker thread lives + # inside this process. Multiple uvicorn workers would each start their own + # thread, leading to concurrent benchmark executions. + uvicorn.run( + fastapi_app, + host=host, + port=port, + log_level=log_level.lower(), + workers=1, + ) diff --git a/src/expb/cli/api/tokens.py b/src/expb/cli/api/tokens.py new file mode 100644 index 0000000..8adaaa9 --- /dev/null +++ b/src/expb/cli/api/tokens.py @@ -0,0 +1,125 @@ +import hashlib +import secrets +import uuid +from datetime import datetime +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +app = typer.Typer(name="tokens", help="Manage API tokens for the expb API server.") +console = Console() + +_DEFAULT_DB = Path("expb-api.db") + + +def _db_file_option() -> Path: + # Helper only used as a default factory in the type annotations below. + return _DEFAULT_DB + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +def _init(db_file: Path) -> None: + from expb.api.db.engine import init_db + + init_db(db_file) + + +@app.command("add") +def add_token( + name: Annotated[str, typer.Argument(help="Friendly name for the new token.")], + db_file: Annotated[ + Path, + typer.Option(help="Path to the SQLite database file."), + ] = _DEFAULT_DB, +) -> None: + """ + Generate a new API token, store its hash in the DB, and print the raw + token value once. The value is never stored and cannot be recovered. + """ + from expb.api.db.engine import get_session + from expb.api.db.models import ApiToken + + _init(db_file) + db = get_session() + try: + existing = db.query(ApiToken).filter(ApiToken.name == name).first() + if existing: + console.print(f"[red]Error:[/red] A token named '[bold]{name}[/bold]' already exists.") + raise typer.Exit(code=1) + + raw_token = secrets.token_hex(32) # 256 bits of entropy + token = ApiToken( + token_id=str(uuid.uuid4()), + name=name, + token_hash=_hash_token(raw_token), + created_at=datetime.utcnow(), + ) + db.add(token) + db.commit() + + console.print(f"\n[green]Token '[bold]{name}[/bold]' created successfully.[/green]") + console.print("[yellow bold]Copy the token below — it will not be shown again:[/yellow bold]") + console.print(f"\n {raw_token}\n") + finally: + db.close() + + +@app.command("list") +def list_tokens( + db_file: Annotated[ + Path, + typer.Option(help="Path to the SQLite database file."), + ] = _DEFAULT_DB, +) -> None: + """List all token names and creation dates. Token values are never shown.""" + from expb.api.db.engine import get_session + from expb.api.db.models import ApiToken + + _init(db_file) + db = get_session() + try: + tokens = db.query(ApiToken).order_by(ApiToken.created_at.asc()).all() + if not tokens: + console.print("[yellow]No API tokens found.[/yellow]") + return + + table = Table(title="API Tokens") + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Created At", style="green") + for t in tokens: + table.add_row(t.name, str(t.created_at)) + console.print(table) + finally: + db.close() + + +@app.command("revoke") +def revoke_token( + name: Annotated[str, typer.Argument(help="Name of the token to revoke.")], + db_file: Annotated[ + Path, + typer.Option(help="Path to the SQLite database file."), + ] = _DEFAULT_DB, +) -> None: + """Revoke (permanently delete) an API token by name.""" + from expb.api.db.engine import get_session + from expb.api.db.models import ApiToken + + _init(db_file) + db = get_session() + try: + token = db.query(ApiToken).filter(ApiToken.name == name).first() + if token is None: + console.print(f"[red]Error:[/red] Token '[bold]{name}[/bold]' not found.") + raise typer.Exit(code=1) + db.delete(token) + db.commit() + console.print(f"[green]Token '[bold]{name}[/bold]' revoked successfully.[/green]") + finally: + db.close() diff --git a/src/expb/compress_payloads.py b/src/expb/cli/compress_payloads.py similarity index 100% rename from src/expb/compress_payloads.py rename to src/expb/cli/compress_payloads.py diff --git a/src/expb/execute_scenario.py b/src/expb/cli/execute_scenario.py similarity index 100% rename from src/expb/execute_scenario.py rename to src/expb/cli/execute_scenario.py diff --git a/src/expb/execute_scenarios.py b/src/expb/cli/execute_scenarios.py similarity index 100% rename from src/expb/execute_scenarios.py rename to src/expb/cli/execute_scenarios.py diff --git a/src/expb/generate_payloads.py b/src/expb/cli/generate_payloads.py similarity index 100% rename from src/expb/generate_payloads.py rename to src/expb/cli/generate_payloads.py diff --git a/src/expb/send_payloads.py b/src/expb/cli/send_payloads.py similarity index 100% rename from src/expb/send_payloads.py rename to src/expb/cli/send_payloads.py diff --git a/uv.lock b/uv.lock index d9aad35..f424307 100644 --- a/uv.lock +++ b/uv.lock @@ -57,6 +57,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -66,6 +75,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -329,29 +350,51 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "docker" }, + { name = "fastapi" }, { name = "filelock" }, { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "rich" }, + { name = "sqlalchemy" }, { name = "structlog" }, { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, { name = "web3" }, ] [package.metadata] requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, + { name = "fastapi", specifier = ">=0.133.0" }, { name = "filelock", specifier = ">=3.24.3" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "rich", specifier = ">=14.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.46" }, { name = "structlog", specifier = ">=25.4.0" }, { name = "typer", specifier = ">=0.16.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0" }, { name = "web3", specifier = ">=7.12.0" }, ] +[[package]] +name = "fastapi" +version = "0.133.0" +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/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265, upload-time = "2026-02-24T09:53:40.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, +] + [[package]] name = "filelock" version = "3.24.3" @@ -404,6 +447,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[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 = "hexbytes" version = "1.3.1" @@ -413,6 +499,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/e0/3b31492b1c89da3c5a846680517871455b30c54738486fc57ac79a5761bd/hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7", size = 5074, upload-time = "2025-05-14T16:45:16.179Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -663,6 +771,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "pyunormalize" version = "16.0.0" @@ -771,6 +888,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.46" +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/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "structlog" version = "25.4.0" @@ -827,14 +991,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -846,6 +1010,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + [[package]] name = "web3" version = "7.12.0" From 9246aefe5c978af883b20388e83efe7b2fbd86e3 Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 10:29:20 -0500 Subject: [PATCH 3/8] feat: enhance API with health check and run cancellation features - Added a health check endpoint to monitor API status and database connectivity. - Implemented functionality to cancel queued benchmark runs, updating their status accordingly. - Introduced a new response model for run status retrieval. - Updated token management to track the last used timestamp upon successful authentication. - Added tests for new endpoints and functionalities to ensure reliability. --- pyproject.toml | 11 ++ src/expb/api/app.py | 2 + src/expb/api/auth.py | 12 +- src/expb/api/db/models.py | 2 + src/expb/api/routes/health.py | 50 ++++++++ src/expb/api/routes/runs.py | 40 ++++++ src/expb/api/routes/scenarios.py | 23 ++++ src/expb/api/schemas/runs.py | 5 + src/expb/cli/api/tokens.py | 3 +- tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/test_auth.py | 56 +++++++++ tests/api/test_health.py | 32 +++++ tests/api/test_runs.py | 202 ++++++++++++++++++++++++++++++ tests/api/test_scenarios.py | 39 ++++++ tests/conftest.py | 204 +++++++++++++++++++++++++++++++ uv.lock | 83 +++++++++++++ 17 files changed, 759 insertions(+), 5 deletions(-) create mode 100644 src/expb/api/routes/health.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_auth.py create mode 100644 tests/api/test_health.py create mode 100644 tests/api/test_runs.py create mode 100644 tests/api/test_scenarios.py create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 1a0fb38..6b3962e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,14 @@ packages = ["src/expb"] [tool.hatch.build.targets.wheel.sources] "src" = "" + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.2", +] + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::DeprecationWarning:websockets", +] diff --git a/src/expb/api/app.py b/src/expb/api/app.py index 5db10c9..1ad9939 100644 --- a/src/expb/api/app.py +++ b/src/expb/api/app.py @@ -62,9 +62,11 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) + from expb.api.routes.health import router as health_router from expb.api.routes.runs import router as runs_router from expb.api.routes.scenarios import router as scenarios_router + app.include_router(health_router) app.include_router(runs_router, prefix="/runs", tags=["runs"]) app.include_router(scenarios_router, prefix="/scenarios", tags=["scenarios"]) diff --git a/src/expb/api/auth.py b/src/expb/api/auth.py index bef9591..3f2f550 100644 --- a/src/expb/api/auth.py +++ b/src/expb/api/auth.py @@ -1,5 +1,6 @@ import hashlib import hmac +from datetime import datetime, timezone from fastapi import Depends, HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -22,15 +23,18 @@ def verify_token( """ FastAPI dependency that validates a Bearer token against the DB. + On success, updates the token's ``last_used_at`` timestamp. Raises HTTP 401 if the token is missing, invalid, or revoked. Use as: ``_: None = Depends(verify_token)`` """ computed_hash = _hash_token(credentials.credentials) - # Load all hashes and compare with hmac.compare_digest to resist timing attacks. - tokens = db.query(ApiToken.token_hash).all() - for (stored_hash,) in tokens: - if hmac.compare_digest(stored_hash, computed_hash): + # Fetch all tokens and compare with hmac.compare_digest to resist timing attacks. + tokens = db.query(ApiToken).all() + for token in tokens: + if hmac.compare_digest(token.token_hash, computed_hash): + token.last_used_at = datetime.now(timezone.utc) + db.commit() return raise HTTPException(status_code=401, detail="Invalid or revoked token.") diff --git a/src/expb/api/db/models.py b/src/expb/api/db/models.py index 14431c2..33f26d4 100644 --- a/src/expb/api/db/models.py +++ b/src/expb/api/db/models.py @@ -16,6 +16,7 @@ class RunStatus(str, enum.Enum): RUNNING = "running" COMPLETED = "completed" FAILED = "failed" + CANCELLED = "cancelled" class Run(Base): @@ -59,3 +60,4 @@ class ApiToken(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, default=datetime.now(timezone.utc) ) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/src/expb/api/routes/health.py b/src/expb/api/routes/health.py new file mode 100644 index 0000000..224b48d --- /dev/null +++ b/src/expb/api/routes/health.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from expb.api.db.models import Run, RunStatus +from expb.api.dependencies import get_db + +router = APIRouter() + + +class HealthResponse(BaseModel): + status: str + version: str + queue_size: int + active_jobs: int + database_connected: bool + + +@router.get("/health", response_model=HealthResponse, tags=["health"]) +def health_check( + request: Request, + db: Session = Depends(get_db), +) -> HealthResponse: + """ + Health check endpoint. No authentication required. + + Returns overall API status, DB connectivity, and queue information + derived live from the database. + """ + database_connected = False + try: + db.execute(text("SELECT 1")) + database_connected = True + except Exception: + pass + + queue_size = 0 + active_jobs = 0 + if database_connected: + queue_size = db.query(Run).filter(Run.status == RunStatus.QUEUED).count() + active_jobs = db.query(Run).filter(Run.status == RunStatus.RUNNING).count() + + return HealthResponse( + status="ok" if database_connected else "degraded", + version=request.app.version, + queue_size=queue_size, + active_jobs=active_jobs, + database_connected=database_connected, + ) diff --git a/src/expb/api/routes/runs.py b/src/expb/api/routes/runs.py index 4a51938..0a12575 100644 --- a/src/expb/api/routes/runs.py +++ b/src/expb/api/routes/runs.py @@ -1,6 +1,7 @@ import io import uuid import zipfile +from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response @@ -14,6 +15,7 @@ K6Metrics, RunListResponse, RunResponse, + RunStatusResponse, SubmitRunRequest, ) from expb.api.worker import BenchmarkWorker @@ -129,6 +131,44 @@ def get_run( return _run_to_response(run) +@router.get("/{run_id}/status", response_model=RunStatusResponse) +def get_run_status( + run_id: str, + db: Session = Depends(get_db), + _: None = Depends(verify_token), +) -> RunStatusResponse: + """Lightweight status-only check for a run. Useful for polling.""" + run = db.query(Run).filter(Run.run_id == run_id).first() + if run is None: + raise HTTPException(status_code=404, detail="Run not found.") + return RunStatusResponse(run_id=run.run_id, status=run.status) + + +@router.delete("/{run_id}", status_code=204) +def cancel_run( + run_id: str, + db: Session = Depends(get_db), + _: None = Depends(verify_token), +) -> None: + """ + Cancel a queued run. + + Only runs in ``queued`` status can be cancelled. A run that is already + executing cannot be stopped mid-flight. + """ + run = db.query(Run).filter(Run.run_id == run_id).first() + if run is None: + raise HTTPException(status_code=404, detail="Run not found.") + if run.status != RunStatus.QUEUED: + raise HTTPException( + status_code=409, + detail=f"Only queued runs can be cancelled (current status: {run.status}).", + ) + run.status = RunStatus.CANCELLED + run.completed_at = datetime.now(timezone.utc) + db.commit() + + @router.get("/{run_id}/download") def download_run_output( run_id: str, diff --git a/src/expb/api/routes/scenarios.py b/src/expb/api/routes/scenarios.py index cb2ba3d..8900c9a 100644 --- a/src/expb/api/routes/scenarios.py +++ b/src/expb/api/routes/scenarios.py @@ -5,11 +5,28 @@ router = APIRouter() +_OVERRIDABLE_PARAMS = [ + "payloads_amount", + "payloads_skip", + "payloads_delay", + "payloads_warmup", + "per_payload_metrics", + "print_logs", +] + class ScenarioInfo(BaseModel): name: str client: str network: str + # Scenario defaults — lets API consumers know what they are overriding + default_duration: str + default_warmup_duration: str + default_delay: float + default_warmup: int | None + default_amount: int + # Informational: which fields can be overridden in POST /runs + overridable_params: list[str] class ScenarioListResponse(BaseModel): @@ -28,6 +45,12 @@ def list_scenarios( name=name, client=sc.client.value.name, network=sc.network.value.name, + default_duration=sc.duration, + default_warmup_duration=sc.warmup_duration, + default_delay=sc.payloads_delay, + default_warmup=sc.payloads_warmup, + default_amount=sc.payloads_amount, + overridable_params=_OVERRIDABLE_PARAMS, ) for name, sc in scenarios.scenarios_configs.items() ] diff --git a/src/expb/api/schemas/runs.py b/src/expb/api/schemas/runs.py index 5fbd3df..5321db5 100644 --- a/src/expb/api/schemas/runs.py +++ b/src/expb/api/schemas/runs.py @@ -77,3 +77,8 @@ class RunListResponse(BaseModel): total: int page: int page_size: int + + +class RunStatusResponse(BaseModel): + run_id: str + status: str diff --git a/src/expb/cli/api/tokens.py b/src/expb/cli/api/tokens.py index 8adaaa9..3b863b4 100644 --- a/src/expb/cli/api/tokens.py +++ b/src/expb/cli/api/tokens.py @@ -92,8 +92,9 @@ def list_tokens( table = Table(title="API Tokens") table.add_column("Name", style="cyan", no_wrap=True) table.add_column("Created At", style="green") + table.add_column("Last Used At", style="yellow") for t in tokens: - table.add_row(t.name, str(t.created_at)) + table.add_row(t.name, str(t.created_at), str(t.last_used_at) if t.last_used_at else "never") console.print(table) finally: db.close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..ea26bc5 --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,56 @@ +"""Tests for Bearer token authentication.""" + +import hashlib + +from expb.api.db.engine import get_session +from expb.api.db.models import ApiToken + + +def _hash(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +def test_missing_token_returns_403(client): + response = client.get("/runs") + # FastAPI HTTPBearer returns 403 when the header is missing entirely + assert response.status_code in (401, 403) + + +def test_invalid_token_returns_401(client): + response = client.get("/runs", headers={"Authorization": "Bearer invalid_token"}) + assert response.status_code == 401 + + +def test_valid_token_is_accepted(client, auth_headers): + response = client.get("/runs", headers=auth_headers) + assert response.status_code == 200 + + +def test_valid_token_updates_last_used_at(client, db_path, auth_headers, raw_token): + """Successful auth should stamp last_used_at on the token row.""" + # Before the request, last_used_at should be None + db = get_session() + token = db.query(ApiToken).filter(ApiToken.name == "test-token").first() + assert token is not None + assert token.last_used_at is None + db.close() + + client.get("/runs", headers=auth_headers) + + db = get_session() + token = db.query(ApiToken).filter(ApiToken.name == "test-token").first() + assert token is not None + assert token.last_used_at is not None + db.close() + + +def test_revoked_token_returns_401(client, db_path, raw_token): + """After deleting a token, requests using it should be rejected.""" + db = get_session() + token = db.query(ApiToken).filter(ApiToken.name == "test-token").first() + db.delete(token) + db.commit() + db.close() + + response = client.get("/runs", headers={"Authorization": f"Bearer {raw_token}"}) + assert response.status_code == 401 diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..b95dd1e --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,32 @@ +"""Tests for GET /health.""" + + +def test_health_no_auth_required(client): + """Health endpoint should not require a Bearer token.""" + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_response_structure(client): + response = client.get("/health") + data = response.json() + assert "status" in data + assert "version" in data + assert "queue_size" in data + assert "active_jobs" in data + assert "database_connected" in data + + +def test_health_database_connected(client): + response = client.get("/health") + data = response.json() + assert data["database_connected"] is True + assert data["status"] == "ok" + + +def test_health_queue_counts_reflect_db(client, auth_headers, queued_run): + """queue_size should reflect runs in QUEUED status.""" + response = client.get("/health") + data = response.json() + assert data["queue_size"] >= 1 + assert data["active_jobs"] == 0 diff --git a/tests/api/test_runs.py b/tests/api/test_runs.py new file mode 100644 index 0000000..f793693 --- /dev/null +++ b/tests/api/test_runs.py @@ -0,0 +1,202 @@ +"""Tests for /runs endpoints.""" + +import uuid + +from expb.api.db.engine import get_session +from expb.api.db.models import Run, RunStatus + +# --------------------------------------------------------------------------- +# POST /runs +# --------------------------------------------------------------------------- + + +def test_submit_run_returns_201(client, auth_headers): + response = client.post( + "/runs", + json={"scenario_name": "test-scenario"}, + headers=auth_headers, + ) + assert response.status_code == 201 + + +def test_submit_run_response_structure(client, auth_headers): + response = client.post( + "/runs", + json={"scenario_name": "test-scenario"}, + headers=auth_headers, + ) + data = response.json() + assert "run_id" in data + assert data["scenario_name"] == "test-scenario" + assert data["status"] == "queued" + assert data["queued_at"] is not None + assert data["started_at"] is None + assert data["completed_at"] is None + + +def test_submit_run_enqueues_to_worker(client, auth_headers, app): + client.post( + "/runs", + json={"scenario_name": "test-scenario"}, + headers=auth_headers, + ) + app.state.worker.enqueue.assert_called_once() + + +def test_submit_run_unknown_scenario_returns_422(client, auth_headers): + response = client.post( + "/runs", + json={"scenario_name": "nonexistent"}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +def test_submit_run_with_overrides(client, auth_headers): + response = client.post( + "/runs", + json={ + "scenario_name": "test-scenario", + "payloads_amount": 5, + "payloads_delay": 0.5, + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["overrides"]["payloads_amount"] == 5 + assert data["overrides"]["payloads_delay"] == 0.5 + + +# --------------------------------------------------------------------------- +# GET /runs +# --------------------------------------------------------------------------- + + +def test_list_runs_empty(client, auth_headers): + response = client.get("/runs", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["runs"] == [] + assert data["total"] == 0 + + +def test_list_runs_returns_submitted(client, auth_headers, queued_run): + response = client.get("/runs", headers=auth_headers) + data = response.json() + assert data["total"] >= 1 + ids = [r["run_id"] for r in data["runs"]] + assert queued_run.run_id in ids + + +def test_list_runs_filter_by_status(client, auth_headers, queued_run, completed_run): + response = client.get("/runs?status=queued", headers=auth_headers) + data = response.json() + statuses = {r["status"] for r in data["runs"]} + assert statuses == {"queued"} + + +def test_list_runs_pagination(client, auth_headers): + # Submit 3 runs + for _ in range(3): + client.post( + "/runs", json={"scenario_name": "test-scenario"}, headers=auth_headers + ) + + resp1 = client.get("/runs?page=1&page_size=2", headers=auth_headers) + resp2 = client.get("/runs?page=2&page_size=2", headers=auth_headers) + assert len(resp1.json()["runs"]) == 2 + assert len(resp2.json()["runs"]) >= 1 + + +# --------------------------------------------------------------------------- +# GET /runs/{run_id} +# --------------------------------------------------------------------------- + + +def test_get_run_returns_200(client, auth_headers, queued_run): + response = client.get(f"/runs/{queued_run.run_id}", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["run_id"] == queued_run.run_id + + +def test_get_run_unknown_returns_404(client, auth_headers): + response = client.get(f"/runs/{uuid.uuid4()}", headers=auth_headers) + assert response.status_code == 404 + + +def test_get_run_includes_k6_metrics(client, auth_headers, completed_run): + response = client.get(f"/runs/{completed_run.run_id}", headers=auth_headers) + data = response.json() + assert data["k6_metrics"] is not None + enp = data["k6_metrics"]["engine_newPayload"] + assert enp["avg"] == 100.0 + assert enp["p99"] == 195.0 + + +# --------------------------------------------------------------------------- +# GET /runs/{run_id}/status +# --------------------------------------------------------------------------- + + +def test_get_run_status(client, auth_headers, queued_run): + response = client.get(f"/runs/{queued_run.run_id}/status", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["run_id"] == queued_run.run_id + assert data["status"] == "queued" + + +def test_get_run_status_unknown_returns_404(client, auth_headers): + response = client.get(f"/runs/{uuid.uuid4()}/status", headers=auth_headers) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /runs/{run_id} (cancel) +# --------------------------------------------------------------------------- + + +def test_cancel_queued_run_returns_204(client, auth_headers, queued_run): + response = client.delete(f"/runs/{queued_run.run_id}", headers=auth_headers) + assert response.status_code == 204 + + +def test_cancel_queued_run_sets_cancelled_status( + client, db_path, auth_headers, queued_run +): + client.delete(f"/runs/{queued_run.run_id}", headers=auth_headers) + db = get_session() + run = db.query(Run).filter(Run.run_id == queued_run.run_id).first() + assert run is not None + assert run.status == RunStatus.CANCELLED + assert run.completed_at is not None + db.close() + + +def test_cancel_non_queued_run_returns_409(client, auth_headers, completed_run): + response = client.delete(f"/runs/{completed_run.run_id}", headers=auth_headers) + assert response.status_code == 409 + + +def test_cancel_unknown_run_returns_404(client, auth_headers): + response = client.delete(f"/runs/{uuid.uuid4()}", headers=auth_headers) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /runs/{run_id}/download +# --------------------------------------------------------------------------- + + +def test_download_completed_run(client, auth_headers, completed_run): + response = client.get( + f"/runs/{completed_run.run_id}/download", headers=auth_headers + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/zip" + + +def test_download_queued_run_returns_409(client, auth_headers, queued_run): + response = client.get(f"/runs/{queued_run.run_id}/download", headers=auth_headers) + assert response.status_code == 409 diff --git a/tests/api/test_scenarios.py b/tests/api/test_scenarios.py new file mode 100644 index 0000000..1225f88 --- /dev/null +++ b/tests/api/test_scenarios.py @@ -0,0 +1,39 @@ +"""Tests for GET /scenarios.""" + + +def test_list_scenarios_returns_200(client, auth_headers): + response = client.get("/scenarios", headers=auth_headers) + assert response.status_code == 200 + + +def test_list_scenarios_contains_test_scenario(client, auth_headers): + response = client.get("/scenarios", headers=auth_headers) + data = response.json() + names = [s["name"] for s in data["scenarios"]] + assert "test-scenario" in names + + +def test_scenario_info_structure(client, auth_headers): + response = client.get("/scenarios", headers=auth_headers) + scenario = response.json()["scenarios"][0] + assert "name" in scenario + assert "client" in scenario + assert "network" in scenario + assert "default_duration" in scenario + assert "default_warmup_duration" in scenario + assert "default_delay" in scenario + assert "default_amount" in scenario + assert "overridable_params" in scenario + + +def test_scenario_overridable_params_listed(client, auth_headers): + response = client.get("/scenarios", headers=auth_headers) + scenario = response.json()["scenarios"][0] + params = scenario["overridable_params"] + assert "payloads_amount" in params + assert "payloads_delay" in params + + +def test_list_scenarios_requires_auth(client): + response = client.get("/scenarios") + assert response.status_code in (401, 403) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6f798d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,204 @@ +""" +Shared pytest fixtures for the expb API test suite. +""" + +import hashlib +import secrets +import uuid +from pathlib import Path + +import pytest +import yaml +from fastapi.testclient import TestClient + +from expb.api.db.engine import get_session, init_db +from expb.api.db.models import ApiToken, Run, RunStatus + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +# --------------------------------------------------------------------------- +# Fixtures: file system +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def tmp_dir(tmp_path: Path) -> Path: + return tmp_path + + +@pytest.fixture() +def db_path(tmp_path: Path) -> Path: + return tmp_path / "test.db" + + +# --------------------------------------------------------------------------- +# Fixtures: scenarios config +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def scenarios_config_file(tmp_path: Path) -> Path: + """ + Write a minimal valid expb.yaml to a temp file and return its path. + The payloads / fcus files are empty stubs so Pydantic's FilePath + validators pass. + """ + payloads = tmp_path / "payloads.jsonl" + fcus = tmp_path / "fcus.jsonl" + payloads.write_text("") + fcus.write_text("") + + config = { + "paths": { + "work": str(tmp_path / "work"), + "outputs": str(tmp_path / "outputs"), + }, + "scenarios": { + "test-scenario": { + "client": "nethermind", + "snapshot_source": str(tmp_path / "snapshot"), + "payloads": str(payloads), + "fcus": str(fcus), + "amount": 10, + "duration": "5m", + "warmup_duration": "2m", + "delay": 0.0, + } + }, + } + + cfg_file = tmp_path / "expb-test.yaml" + cfg_file.write_text(yaml.dump(config)) + return cfg_file + + +# --------------------------------------------------------------------------- +# Fixtures: FastAPI test client +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def app(db_path: Path, scenarios_config_file: Path): + """Create the FastAPI app with a fresh in-process DB (no worker started).""" + from expb.api.app import create_app + + init_db(db_path) + + fastapi_app = create_app( + config_file=scenarios_config_file, + db_path=db_path, + ) + return fastapi_app + + +@pytest.fixture() +def client(app) -> TestClient: + from unittest.mock import MagicMock, patch + + mock_worker = MagicMock() + mock_worker.enqueue = MagicMock() + + # Keep the patch active across the full lifespan (enter → yield → exit). + with patch("expb.api.app.BenchmarkWorker", return_value=mock_worker): + with TestClient(app, raise_server_exceptions=True) as c: + yield c + + +# --------------------------------------------------------------------------- +# Fixtures: auth token +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def raw_token(db_path: Path) -> str: + """Insert an active API token into the DB and return the raw value.""" + init_db(db_path) + db = get_session() + raw = secrets.token_hex(32) + token = ApiToken( + token_id=str(uuid.uuid4()), + name="test-token", + token_hash=_hash_token(raw), + ) + db.add(token) + db.commit() + db.close() + return raw + + +@pytest.fixture() +def auth_headers(raw_token: str) -> dict: + return {"Authorization": f"Bearer {raw_token}"} + + +# --------------------------------------------------------------------------- +# Fixtures: pre-existing runs +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def queued_run(db_path: Path) -> Run: + init_db(db_path) + db = get_session() + run = Run( + run_id=str(uuid.uuid4()), + scenario_name="test-scenario", + status=RunStatus.QUEUED, + overrides={}, + ) + db.add(run) + db.commit() + db.refresh(run) + run_id = run.run_id + db.close() + + # Re-fetch in a fresh session so the object is detached/clean for test use + db2 = get_session() + r = db2.query(Run).filter(Run.run_id == run_id).first() + db2.close() + return r + + +@pytest.fixture() +def completed_run(db_path: Path, tmp_path: Path) -> Run: + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "k6.log").write_text("log content") + + init_db(db_path) + db = get_session() + run = Run( + run_id=str(uuid.uuid4()), + scenario_name="test-scenario", + status=RunStatus.COMPLETED, + overrides={}, + output_dir=str(output_dir), + k6_metrics={ + "engine_newPayload": { + "avg": 100.0, + "min": 50.0, + "max": 200.0, + "med": 95.0, + "p90": 150.0, + "p95": 175.0, + "p99": 195.0, + }, + }, + ) + db.add(run) + db.commit() + db.refresh(run) + run_id = run.run_id + db.close() + + db2 = get_session() + r = db2.query(Run).filter(Run.run_id == run_id).first() + db2.close() + return r diff --git a/uv.lock b/uv.lock index f424307..6b71289 100644 --- a/uv.lock +++ b/uv.lock @@ -363,6 +363,12 @@ dependencies = [ { name = "web3" }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, @@ -379,6 +385,12 @@ requires-dist = [ { name = "web3", specifier = ">=7.12.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + [[package]] name = "fastapi" version = "0.133.0" @@ -499,6 +511,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/e0/3b31492b1c89da3c5a846680517871455b30c54738486fc57ac79a5761bd/hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7", size = 5074, upload-time = "2025-05-14T16:45:16.179Z" }, ] +[[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.7.1" @@ -521,6 +546,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[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.10" @@ -530,6 +570,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[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 = "jinja2" version = "3.1.6" @@ -636,6 +685,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181, upload-time = "2025-06-17T14:15:55.156Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "parsimonious" version = "0.10.0" @@ -648,6 +706,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, ] +[[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 = "propcache" version = "0.3.2" @@ -771,6 +838,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" From 500718eefc7352aa2b613ae7e5d4a1c9a225112e Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 11:57:31 -0500 Subject: [PATCH 4/8] feat: update metrics parsing and configuration for K6 scripts --- .gitignore | 1 + src/expb/api/metrics.py | 19 ++++++++----------- src/expb/payloads/executor/services/k6.py | 4 ++++ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 063acab..f060b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ celerybeat.pid # Environments .env +.remote-test.env .venv env/ venv/ diff --git a/src/expb/api/metrics.py b/src/expb/api/metrics.py index ef45b6c..cb475d9 100644 --- a/src/expb/api/metrics.py +++ b/src/expb/api/metrics.py @@ -45,19 +45,16 @@ def parse_k6_summary(summary_path: Path) -> dict | None: if metric_data is None: continue - values = metric_data.get("values", {}) - if not values: - continue - # K6 uses "p(90)" notation; normalise to "p90" for clean storage / API output. + # Values are stored directly on the metric object (no "values" sub-key). result[group_name] = { - "avg": values.get("avg"), - "min": values.get("min"), - "max": values.get("max"), - "med": values.get("med"), - "p90": values.get("p(90)"), - "p95": values.get("p(95)"), - "p99": values.get("p(99)"), + "avg": metric_data.get("avg"), + "min": metric_data.get("min"), + "max": metric_data.get("max"), + "med": metric_data.get("med"), + "p90": metric_data.get("p(90)"), + "p95": metric_data.get("p(95)"), + "p99": metric_data.get("p(99)"), } return result if result else None diff --git a/src/expb/payloads/executor/services/k6.py b/src/expb/payloads/executor/services/k6.py index 2c03e3d..bf3eaad 100644 --- a/src/expb/payloads/executor/services/k6.py +++ b/src/expb/payloads/executor/services/k6.py @@ -51,6 +51,10 @@ def build_k6_script_config( "scenarios": {scenario_name: scenario}, "thresholds": { "http_req_failed": ["rate < 0.01"], + # Empty thresholds force K6 to track and export these group-tagged + # sub-metrics in the --summary-export JSON file. + "http_req_duration{group:::engine_newPayload}": [], + "http_req_duration{group:::engine_forkchoiceUpdated}": [], }, "systemTags": [ "scenario", From 84b0f202083b815b7d5d8101ef7b5c79d0d62bed Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 12:10:23 -0500 Subject: [PATCH 5/8] refactor: remove output_dir from RunResponse model and response conversion --- src/expb/api/routes/runs.py | 1 - src/expb/api/schemas/runs.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/expb/api/routes/runs.py b/src/expb/api/routes/runs.py index 0a12575..a1fa1ab 100644 --- a/src/expb/api/routes/runs.py +++ b/src/expb/api/routes/runs.py @@ -50,7 +50,6 @@ def _run_to_response(run: Run) -> RunResponse: queued_at=run.queued_at, started_at=run.started_at, completed_at=run.completed_at, - output_dir=run.output_dir, error_message=run.error_message, k6_metrics=k6, overrides=run.overrides, diff --git a/src/expb/api/schemas/runs.py b/src/expb/api/schemas/runs.py index 5321db5..d6b6453 100644 --- a/src/expb/api/schemas/runs.py +++ b/src/expb/api/schemas/runs.py @@ -64,7 +64,6 @@ class RunResponse(BaseModel): queued_at: datetime started_at: datetime | None = None completed_at: datetime | None = None - output_dir: str | None = None error_message: str | None = None k6_metrics: K6Metrics | None = None overrides: dict[str, Any] | None = None From 1d3d2d1f8d9fe39fa8928fb2ee3f0cb4daa329ae Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 14:38:05 -0500 Subject: [PATCH 6/8] feat: enhance API functionality with scenario overrides and token management improvements - Introduced ScenarioOverrides model for optional per-run configuration adjustments. - Updated SubmitRunRequest to include scenario overrides. - Improved token verification logic to raise appropriate HTTP status codes. - Refined the BenchmarkWorker to handle scenario execution with applied overrides. - Adjusted database models to ensure accurate timestamp handling. - Cleaned up deprecated filter warnings in configuration files. --- pyproject.toml | 2 +- src/expb/api/app.py | 2 +- src/expb/api/auth.py | 18 +++--- src/expb/api/db/models.py | 4 +- src/expb/api/routes/scenarios.py | 11 +--- src/expb/api/schemas/runs.py | 106 ++++++++++++++++++++++++++----- src/expb/api/worker.py | 80 ++++++++++++++--------- src/expb/cli/api/tokens.py | 9 +-- tests/conftest.py | 2 - 9 files changed, 158 insertions(+), 76 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fad6832..30d4bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,4 +41,4 @@ packages = ["src/expb"] dev = ["httpx>=0.28.1", "pytest>=9.0.2", "hatchling>=1.29.0"] [tool.pytest.ini_options] -filterwarnings = ["ignore::DeprecationWarning:websockets", "hatchling>=1.29.0"] +filterwarnings = ["ignore::DeprecationWarning:websockets"] diff --git a/src/expb/api/app.py b/src/expb/api/app.py index 1ad9939..e16f930 100644 --- a/src/expb/api/app.py +++ b/src/expb/api/app.py @@ -56,7 +56,7 @@ async def lifespan(app: FastAPI): title="expb Benchmark Queue API", description=( "Queue and monitor Ethereum execution client benchmark runs. " - "All endpoints require Bearer token authentication." + "All endpoints except /health require Bearer token authentication." ), version="0.1.0", lifespan=lifespan, diff --git a/src/expb/api/auth.py b/src/expb/api/auth.py index 3f2f550..b0efd8b 100644 --- a/src/expb/api/auth.py +++ b/src/expb/api/auth.py @@ -1,5 +1,4 @@ import hashlib -import hmac from datetime import datetime, timezone from fastapi import Depends, HTTPException, Security @@ -24,17 +23,16 @@ def verify_token( FastAPI dependency that validates a Bearer token against the DB. On success, updates the token's ``last_used_at`` timestamp. - Raises HTTP 401 if the token is missing, invalid, or revoked. + Raises HTTP 403 if the ``Authorization`` header is missing (FastAPI default + for ``HTTPBearer``). Raises HTTP 401 if the token is invalid or revoked. Use as: ``_: None = Depends(verify_token)`` """ computed_hash = _hash_token(credentials.credentials) - # Fetch all tokens and compare with hmac.compare_digest to resist timing attacks. - tokens = db.query(ApiToken).all() - for token in tokens: - if hmac.compare_digest(token.token_hash, computed_hash): - token.last_used_at = datetime.now(timezone.utc) - db.commit() - return + # Query directly by the indexed hash column — no need to scan all tokens. + token = db.query(ApiToken).filter(ApiToken.token_hash == computed_hash).first() + if token is None: + raise HTTPException(status_code=401, detail="Invalid or revoked token.") - raise HTTPException(status_code=401, detail="Invalid or revoked token.") + token.last_used_at = datetime.now(timezone.utc) + db.commit() diff --git a/src/expb/api/db/models.py b/src/expb/api/db/models.py index 33f26d4..7c9c45e 100644 --- a/src/expb/api/db/models.py +++ b/src/expb/api/db/models.py @@ -30,7 +30,7 @@ class Run(Base): SAEnum(RunStatus), nullable=False, default=RunStatus.QUEUED, index=True ) queued_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.now(timezone.utc) + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc) ) started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @@ -58,6 +58,6 @@ class ApiToken(Base): # SHA-256 hex digest of the raw token — the raw value is never stored token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.now(timezone.utc) + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc) ) last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/src/expb/api/routes/scenarios.py b/src/expb/api/routes/scenarios.py index 8900c9a..518d8b3 100644 --- a/src/expb/api/routes/scenarios.py +++ b/src/expb/api/routes/scenarios.py @@ -2,17 +2,12 @@ from pydantic import BaseModel from expb.api.auth import verify_token +from expb.api.schemas.runs import ScenarioOverrides router = APIRouter() -_OVERRIDABLE_PARAMS = [ - "payloads_amount", - "payloads_skip", - "payloads_delay", - "payloads_warmup", - "per_payload_metrics", - "print_logs", -] +# Derived directly from the ScenarioOverrides model so it stays in sync automatically. +_OVERRIDABLE_PARAMS: list[str] = list(ScenarioOverrides.model_fields.keys()) class ScenarioInfo(BaseModel): diff --git a/src/expb/api/schemas/runs.py b/src/expb/api/schemas/runs.py index d6b6453..63581fd 100644 --- a/src/expb/api/schemas/runs.py +++ b/src/expb/api/schemas/runs.py @@ -3,38 +3,114 @@ from pydantic import BaseModel, Field +from expb.configs.scenarios import ScenarioExtraVolume -class SubmitRunRequest(BaseModel): - scenario_name: str = Field(description="Name of the scenario defined in the config file.") - # Execution options - per_payload_metrics: bool = Field( - default=False, - description="Collect per-payload K6 metrics (high cardinality).", + +class ScenarioOverrides(BaseModel): + """ + Optional per-run overrides for a scenario's configuration. + + All fields default to ``None``, meaning the base scenario's configured + value is used unchanged. + + The following scenario fields are intentionally NOT overridable via the + API: ``name``, ``payloads``, ``fcus``, ``network``, ``snapshot_source``, + ``snapshot_backend``, ``snapshot_path``. + """ + + # --- Client --- + client: str | None = Field( + default=None, + description="Override the execution client (e.g. 'nethermind', 'geth').", ) - print_logs: bool = Field( - default=False, - description="Print K6 and execution client logs to the worker console.", + image: str | None = Field( + default=None, + description="Override the execution client Docker image.", ) - # Payload parameter overrides — None means use the scenario's default - payloads_amount: int | None = Field( + # --- Payload parameters --- + repeat: int | None = Field( + default=None, + ge=1, + description="Override the number of times to repeat the scenario.", + ) + amount: int | None = Field( default=None, ge=1, description="Override the number of payloads to execute.", ) - payloads_skip: int | None = Field( + skip: int | None = Field( default=None, ge=0, description="Override the number of payloads to skip at the start.", ) - payloads_delay: float | None = Field( + warmup: int | None = Field( + default=None, + ge=0, + description="Override the number of warmup payloads (no metrics collected).", + ) + delay: float | None = Field( default=None, ge=0.0, description="Override the delay between payload requests (seconds).", ) - payloads_warmup: int | None = Field( + warmup_delay: float | None = Field( + default=None, + ge=0.0, + description="Override the delay between warmup payload requests (seconds).", + ) + # --- Timing --- + duration: str | None = Field( + default=None, + description="Override the max scenario duration (e.g. '10m').", + ) + warmup_duration: str | None = Field( + default=None, + description="Override the max warmup phase duration (e.g. '5m').", + ) + startup_wait: int | None = Field( default=None, ge=0, - description="Override the number of warmup payloads (no metrics collected).", + description="Override the client startup wait time (seconds).", + ) + warmup_wait: int | None = Field( + default=None, + ge=0, + description="Override the wait between warmup and benchmark payloads (seconds).", + ) + # --- Execution client configuration --- + extra_flags: list[str] | None = Field( + default=None, + description="Override extra CLI flags passed to the execution client.", + ) + extra_env: dict[str, str] | None = Field( + default=None, + description="Override extra environment variables for the execution client.", + ) + extra_commands: list[str] | None = Field( + default=None, + description="Override extra commands run inside the execution client container.", + ) + extra_volumes: dict[str, ScenarioExtraVolume] | None = Field( + default=None, + description="Override extra volume mounts for the execution client container.", + ) + + +class SubmitRunRequest(BaseModel): + scenario_name: str = Field(description="Name of the scenario defined in the config file.") + # Execution options — server-side behaviour, not scenario configuration + per_payload_metrics: bool = Field( + default=False, + description="Collect per-payload K6 metrics (high cardinality).", + ) + print_logs: bool = Field( + default=False, + description="Print K6 and execution client logs to the worker console.", + ) + # Optional scenario overrides + overrides: ScenarioOverrides | None = Field( + default=None, + description="Optional overrides for the base scenario configuration.", ) diff --git a/src/expb/api/worker.py b/src/expb/api/worker.py index 951b076..18ad5b8 100644 --- a/src/expb/api/worker.py +++ b/src/expb/api/worker.py @@ -6,9 +6,11 @@ from expb.api.db.engine import get_session from expb.api.db.models import Run, RunStatus from expb.api.metrics import parse_k6_summary -from expb.configs.scenarios import Scenarios +from expb.api.schemas.runs import ScenarioOverrides +from expb.configs.scenarios import Scenario, Scenarios from expb.logging import Logger, setup_logging -from expb.payloads import Executor, ExecutorExecuteOptions +from expb.payloads import Executor, ExecutorConfig, ExecutorExecuteOptions +from expb.payloads.executor.services.snapshots import setup_snapshot_service class BenchmarkWorker: @@ -112,6 +114,11 @@ def _process_run(self, run_id: str) -> None: self._logger.error("Run not found in DB", run_id=run_id) return + # Guard against runs cancelled via the API while still in the queue. + if run.status == RunStatus.CANCELLED: + self._logger.info("Skipping cancelled run", run_id=run_id) + return + # --- Mark as RUNNING --- run.status = RunStatus.RUNNING run.started_at = datetime.now(timezone.utc) @@ -123,18 +130,32 @@ def _process_run(self, run_id: str) -> None: scenario=run.scenario_name, ) - # --- Build executor --- - executor = Executor.from_scenarios( - self._scenarios, - scenario_name=run.scenario_name, + # --- Build scenario (with overrides applied) --- + base_scenario = self._scenarios.scenarios_configs[run.scenario_name] + stored_overrides = run.overrides or {} + scenario_overrides_data = stored_overrides.get("overrides") or {} + scenario_overrides = ScenarioOverrides.model_validate(scenario_overrides_data) + scenario = self._apply_overrides_to_scenario(base_scenario, scenario_overrides) + + # --- Build executor from the (possibly modified) scenario --- + # This mirrors Executor.from_scenarios but uses our derived scenario so + # ExecutorConfig performs all its own construction logic correctly. + snapshot_service = setup_snapshot_service(self._scenarios, scenario) + executor = Executor( + config=ExecutorConfig( + scenario=scenario, + snapshot_service=snapshot_service, + paths=self._scenarios.paths, + resources=self._scenarios.resources, + pull_images=self._scenarios.pull_images, + docker_images=self._scenarios.docker_images, + exports=self._scenarios.exports, + ), logger=self._logger, ) - # Apply overrides to executor.config (not to the shared Scenario model) - self._apply_overrides(executor, run.overrides or {}) - # --- Execute (blocking) --- - options = self._build_options(run.overrides or {}) + options = self._build_options(stored_overrides) executor.execute_scenario(options=options) # --- Capture outputs --- @@ -177,34 +198,33 @@ def _process_run(self, run_id: str) -> None: db.close() @staticmethod - def _apply_overrides(executor: Executor, overrides: dict) -> None: + def _apply_overrides_to_scenario(base: Scenario, overrides: ScenarioOverrides) -> Scenario: """ - Apply API-provided overrides directly to ``executor.config`` attributes. + Return a new ``Scenario`` with the given overrides applied. - Overrides target the ``ExecutorConfig`` plain-object fields rather than - the shared ``Scenario`` Pydantic model, so there is no risk of polluting - the loaded scenarios for subsequent runs. - """ - if overrides.get("payloads_amount") is not None: - executor.config.k6_payloads_amount = overrides["payloads_amount"] + Serialises the base scenario to an alias-keyed JSON dict, merges the + non-``None`` override values (whose field names intentionally match the + Scenario aliases), then reconstructs via ``Scenario.model_validate`` so + all validators — including the ``payloads_warmup_delay`` defaulting logic + — run on the final result. - if overrides.get("payloads_skip") is not None: - executor.config.k6_payloads_skip = overrides["payloads_skip"] + The shared base scenario object is never mutated. + """ + # Produce an alias-keyed, JSON-serialisable snapshot of the base scenario. + # model_validate accepts this format, and all Scenario aliases are used as keys. + data = base.model_dump(by_alias=True, mode="json") - if overrides.get("payloads_delay") is not None: - executor.config.k6_payloads_delay = overrides["payloads_delay"] - # Mirror the Pydantic model_validator: warmup_delay defaults to delay - # unless the scenario already set them independently. - executor.config.k6_payloads_warmup_delay = overrides["payloads_delay"] + # ScenarioOverrides fields are named to match the corresponding Scenario aliases, + # so a direct dict.update() is sufficient to apply them. + data.update(overrides.model_dump(mode="json", exclude_none=True)) - if overrides.get("payloads_warmup") is not None: - executor.config.k6_payloads_warmup = overrides["payloads_warmup"] + return Scenario.model_validate(data) @staticmethod - def _build_options(overrides: dict) -> ExecutorExecuteOptions: + def _build_options(stored_overrides: dict) -> ExecutorExecuteOptions: return ExecutorExecuteOptions( - print_logs_to_console=overrides.get("print_logs", False), - collect_per_payload_metrics=overrides.get("per_payload_metrics", False), + print_logs_to_console=stored_overrides.get("print_logs", False), + collect_per_payload_metrics=stored_overrides.get("per_payload_metrics", False), # per_payload_metrics_logs prints a table to stdout — not useful in API context per_payload_metrics_logs=False, ) diff --git a/src/expb/cli/api/tokens.py b/src/expb/cli/api/tokens.py index 3b863b4..95959ca 100644 --- a/src/expb/cli/api/tokens.py +++ b/src/expb/cli/api/tokens.py @@ -1,7 +1,7 @@ import hashlib import secrets import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Annotated @@ -15,11 +15,6 @@ _DEFAULT_DB = Path("expb-api.db") -def _db_file_option() -> Path: - # Helper only used as a default factory in the type annotations below. - return _DEFAULT_DB - - def _hash_token(raw: str) -> str: return hashlib.sha256(raw.encode()).hexdigest() @@ -58,7 +53,7 @@ def add_token( token_id=str(uuid.uuid4()), name=name, token_hash=_hash_token(raw_token), - created_at=datetime.utcnow(), + created_at=datetime.now(timezone.utc), ) db.add(token) db.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 6f798d6..c721595 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,8 +89,6 @@ def app(db_path: Path, scenarios_config_file: Path): """Create the FastAPI app with a fresh in-process DB (no worker started).""" from expb.api.app import create_app - init_db(db_path) - fastapi_app = create_app( config_file=scenarios_config_file, db_path=db_path, From 97ffc94faf91b5f1c2dacfb4b6fd7dc37676c8ca Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 16:09:55 -0500 Subject: [PATCH 7/8] refactor: update Run model and improve run output handling - Changed the type of the status field in the Run model to RunStatus for better type safety. - Modified the list_runs function to accept RunStatus as a filter for run status. - Enhanced the download_run_output function to use a temporary file for ZIP creation, reducing memory usage. - Updated tests to reflect changes in the structure of overrides and scenario parameters. --- src/expb/api/db/models.py | 2 +- src/expb/api/routes/runs.py | 26 +++++++++++++++++++------- tests/api/test_runs.py | 10 ++++++---- tests/api/test_scenarios.py | 7 +++++-- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/expb/api/db/models.py b/src/expb/api/db/models.py index 7c9c45e..5eb62bb 100644 --- a/src/expb/api/db/models.py +++ b/src/expb/api/db/models.py @@ -26,7 +26,7 @@ class Run(Base): String(36), primary_key=True, default=lambda: str(uuid.uuid4()) ) scenario_name: Mapped[str] = mapped_column(String(255), nullable=False) - status: Mapped[str] = mapped_column( + status: Mapped[RunStatus] = mapped_column( SAEnum(RunStatus), nullable=False, default=RunStatus.QUEUED, index=True ) queued_at: Mapped[datetime] = mapped_column( diff --git a/src/expb/api/routes/runs.py b/src/expb/api/routes/runs.py index a1fa1ab..6b9204d 100644 --- a/src/expb/api/routes/runs.py +++ b/src/expb/api/routes/runs.py @@ -1,10 +1,11 @@ -import io +import tempfile import uuid import zipfile from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from expb.api.auth import verify_token @@ -93,7 +94,7 @@ def submit_run( def list_runs( db: Session = Depends(get_db), _: None = Depends(verify_token), - status: str | None = Query(default=None, description="Filter by run status."), + status: RunStatus | None = Query(default=None, description="Filter by run status."), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), ) -> RunListResponse: @@ -198,15 +199,26 @@ def download_run_output( detail="Output directory no longer exists on disk.", ) - buf = io.BytesIO() - with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + # Write the ZIP to a temporary file so that large output directories don't + # require loading the entire archive into memory before sending. + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp_path = Path(tmp.name) + + with zipfile.ZipFile(tmp_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: for fpath in output_path.rglob("*"): if fpath.is_file(): zf.write(fpath, fpath.relative_to(output_path)) - buf.seek(0) - return Response( - content=buf.read(), + def _stream_and_cleanup(path: Path, chunk_size: int = 65536): + try: + with open(path, "rb") as f: + while chunk := f.read(chunk_size): + yield chunk + finally: + path.unlink(missing_ok=True) + + return StreamingResponse( + _stream_and_cleanup(tmp_path), media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="run-{run_id}.zip"', diff --git a/tests/api/test_runs.py b/tests/api/test_runs.py index f793693..a144f40 100644 --- a/tests/api/test_runs.py +++ b/tests/api/test_runs.py @@ -57,15 +57,17 @@ def test_submit_run_with_overrides(client, auth_headers): "/runs", json={ "scenario_name": "test-scenario", - "payloads_amount": 5, - "payloads_delay": 0.5, + "overrides": { + "amount": 5, + "delay": 0.5, + }, }, headers=auth_headers, ) assert response.status_code == 201 data = response.json() - assert data["overrides"]["payloads_amount"] == 5 - assert data["overrides"]["payloads_delay"] == 0.5 + assert data["overrides"]["overrides"]["amount"] == 5 + assert data["overrides"]["overrides"]["delay"] == 0.5 # --------------------------------------------------------------------------- diff --git a/tests/api/test_scenarios.py b/tests/api/test_scenarios.py index 1225f88..fc22e66 100644 --- a/tests/api/test_scenarios.py +++ b/tests/api/test_scenarios.py @@ -30,8 +30,11 @@ def test_scenario_overridable_params_listed(client, auth_headers): response = client.get("/scenarios", headers=auth_headers) scenario = response.json()["scenarios"][0] params = scenario["overridable_params"] - assert "payloads_amount" in params - assert "payloads_delay" in params + # Keys match ScenarioOverrides field names (Scenario alias keys where applicable) + assert "amount" in params + assert "delay" in params + assert "image" in params + assert "extra_flags" in params def test_list_scenarios_requires_auth(client): From 8881000137d2a217fce45b11704e8b30fe681dfe Mon Sep 17 00:00:00 2001 From: Carlos Bermudez Porto Date: Fri, 27 Feb 2026 16:42:44 -0500 Subject: [PATCH 8/8] fix: adjust K6 container restart policy to prevent metric overwriting - Updated the restart policy for the K6 container from "unless-stopped" to "no" to ensure that the container does not restart after the benchmark finishes, preventing the overwriting of metrics in k6-summary.json. --- src/expb/payloads/executor/executor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/expb/payloads/executor/executor.py b/src/expb/payloads/executor/executor.py index b32288a..1126cf4 100644 --- a/src/expb/payloads/executor/executor.py +++ b/src/expb/payloads/executor/executor.py @@ -352,6 +352,9 @@ def run_k6( k6_container_environment = self.config.get_k6_environment() # Execute k6 container + # K6 runs exactly once — no restart policy, so the container is never + # restarted after the benchmark finishes (a restart would overwrite the + # k6-summary.json with zeroed-out metrics from a second failed run). container = self.config.docker_client.containers.run( image=self.config.get_k6_container_image(), name=self.config.get_k6_container_name(), @@ -360,7 +363,7 @@ def run_k6( command=k6_container_command, network=container_network.name if container_network else None, detach=False, - restart_policy={"Name": "unless-stopped"}, + restart_policy={"Name": "no"}, user=self.config.docker_user, group_add=self.config.docker_group_add, stop_signal="SIGINT",