Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""Runs over the API: submit one, watch it, read what it made.
|
||||
|
||||
Submitting returns immediately with a queued run — a training run is measured
|
||||
in hours, so nothing here waits for one. The way to follow a run is to poll it
|
||||
or to listen on the flow socket, which carries its start and finish.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
|
||||
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
|
||||
from fluksio.flow.runs import RunRejected, RunService, new_run_id
|
||||
from fluksio.flow.store import FlowNotFound
|
||||
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/runs", tags=["runs"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
#: A sweep bigger than this is almost always a mistake in a loop.
|
||||
MAX_SWEEP = 1000
|
||||
|
||||
|
||||
class RunCreate(BaseModel):
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
seed: int | None = None
|
||||
#: Run the unpublished draft instead of what is published.
|
||||
draft: bool = False
|
||||
|
||||
|
||||
class SweepEntry(BaseModel):
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
seed: int | None = None
|
||||
|
||||
|
||||
class SweepCreate(BaseModel):
|
||||
runs: list[SweepEntry] = Field(default_factory=list)
|
||||
draft: bool = False
|
||||
|
||||
|
||||
class RunNodeRow(BaseModel):
|
||||
node: str
|
||||
status: str
|
||||
attempt: int
|
||||
duration_ms: float
|
||||
worker: str
|
||||
error: str
|
||||
logs: str
|
||||
|
||||
|
||||
class ArtifactRow(BaseModel):
|
||||
name: str
|
||||
node: str
|
||||
digest: str
|
||||
size: int
|
||||
media_type: str
|
||||
|
||||
|
||||
class RunRow(BaseModel):
|
||||
"""A run without its result, which is the part that can be large."""
|
||||
|
||||
id: str
|
||||
flow: str
|
||||
status: str
|
||||
status_reason: str
|
||||
cause: str
|
||||
params: dict[str, Any]
|
||||
params_digest: str
|
||||
seed: int | None
|
||||
group_id: str | None
|
||||
labels: list[str]
|
||||
created_at: Any
|
||||
started_at: Any = None
|
||||
finished_at: Any = None
|
||||
duration_ms: float
|
||||
actor: str
|
||||
|
||||
|
||||
class RunDetail(RunRow):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
commit: str = ""
|
||||
flow_version: int = 1
|
||||
nodes: list[RunNodeRow] = Field(default_factory=list)
|
||||
artifacts: list[ArtifactRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MetricPoint(BaseModel):
|
||||
step: int
|
||||
ts: float
|
||||
value: float
|
||||
|
||||
|
||||
class MetricSeries(BaseModel):
|
||||
"""The shape a chart widget already draws, so comparing runs is a binding."""
|
||||
|
||||
label: str
|
||||
points: list[list[float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SeriesAnswer(BaseModel):
|
||||
metric: str
|
||||
lines: list[MetricSeries] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _service(request: Request) -> RunService:
|
||||
service: RunService | None = getattr(request.app.state, "run_service", None)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="Runs are not available")
|
||||
return service
|
||||
|
||||
|
||||
@router.post("/flows/{name}", response_model=RunRow, status_code=202)
|
||||
async def create_run(
|
||||
name: str, body: RunCreate, request: Request, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Queue one run of a flow."""
|
||||
service = _service(request)
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
service.submit,
|
||||
name,
|
||||
params=body.params,
|
||||
seed=body.seed,
|
||||
cause="api",
|
||||
actor=user.email,
|
||||
draft=body.draft,
|
||||
)
|
||||
except FlowNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except RunRejected as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/flows/{name}/sweep", response_model=list[RunRow], status_code=202)
|
||||
async def create_sweep(
|
||||
name: str, body: SweepCreate, request: Request, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Queue many runs of one flow under a shared group.
|
||||
|
||||
An ensemble is this with the same parameters and different seeds; a grid
|
||||
search is this with the parameters spread out. Either way the caller
|
||||
builds the list — the engine does not own a sweep grammar.
|
||||
"""
|
||||
if not body.runs:
|
||||
raise HTTPException(status_code=422, detail="A sweep needs at least one run")
|
||||
if len(body.runs) > MAX_SWEEP:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"A sweep is capped at {MAX_SWEEP} runs"
|
||||
)
|
||||
service = _service(request)
|
||||
group = new_run_id()
|
||||
|
||||
def submit_all() -> list[Run]:
|
||||
return [
|
||||
service.submit(
|
||||
name,
|
||||
params=entry.params,
|
||||
seed=entry.seed,
|
||||
group_id=group,
|
||||
cause="sweep",
|
||||
actor=user.email,
|
||||
draft=body.draft,
|
||||
)
|
||||
for entry in body.runs
|
||||
]
|
||||
|
||||
try:
|
||||
return await run_in_threadpool(submit_all)
|
||||
except FlowNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except RunRejected as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("", response_model=list[RunRow])
|
||||
def read_runs(
|
||||
session: SessionDep,
|
||||
flow: str | None = None,
|
||||
status: str | None = None,
|
||||
group: str | None = None,
|
||||
digest: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Any:
|
||||
"""Runs, newest first. The queryable table an experiment log needs."""
|
||||
statement = select(Run).order_by(col(Run.created_at).desc())
|
||||
if flow:
|
||||
statement = statement.where(col(Run.flow) == flow)
|
||||
if status:
|
||||
statement = statement.where(col(Run.status) == status)
|
||||
if group:
|
||||
statement = statement.where(col(Run.group_id) == group)
|
||||
if digest:
|
||||
statement = statement.where(col(Run.params_digest) == digest)
|
||||
return list(session.exec(statement.limit(min(limit, 500))))
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunDetail)
|
||||
def read_run(run_id: str, session: SessionDep) -> Any:
|
||||
"""One run in full: what it was asked, what each node did, what it made."""
|
||||
run = session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="No such run")
|
||||
nodes = session.exec(select(RunNode).where(col(RunNode.run_id) == run_id)).all()
|
||||
artifacts = session.exec(
|
||||
select(RunArtifact).where(col(RunArtifact.run_id) == run_id)
|
||||
).all()
|
||||
detail = RunDetail.model_validate(run, from_attributes=True)
|
||||
detail.nodes = [RunNodeRow.model_validate(n, from_attributes=True) for n in nodes]
|
||||
detail.artifacts = [
|
||||
ArtifactRow.model_validate(a, from_attributes=True) for a in artifacts
|
||||
]
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel", response_model=RunRow)
|
||||
async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any:
|
||||
"""Stop a run. One already past its last node is left as it finished."""
|
||||
run = session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="No such run")
|
||||
service = _service(request)
|
||||
await run_in_threadpool(service.cancel, run_id)
|
||||
session.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/{run_id}/metrics", response_model=list[MetricPoint])
|
||||
def read_metrics(run_id: str, session: SessionDep, name: str, stride: int = 1) -> Any:
|
||||
"""One metric's series, in step order.
|
||||
|
||||
``stride`` thins a long curve down: 3000 steps drawn on a 400-pixel chart
|
||||
is 3000 points nobody can see.
|
||||
"""
|
||||
statement = (
|
||||
select(RunMetric)
|
||||
.where(col(RunMetric.run_id) == run_id, col(RunMetric.name) == name)
|
||||
.order_by(col(RunMetric.step))
|
||||
)
|
||||
rows = list(session.exec(statement))
|
||||
if stride > 1:
|
||||
rows = rows[:: max(1, stride)]
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/series/compare", response_model=SeriesAnswer)
|
||||
def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
|
||||
"""One metric across several runs, as the chart widget's series shape.
|
||||
|
||||
This is the comparison view: it answers in the same shape a flow answers a
|
||||
chart's query with, so putting three training curves beside each other is
|
||||
a widget binding rather than a screen of its own.
|
||||
"""
|
||||
run_ids = [part for part in ids.split(",") if part]
|
||||
if not run_ids:
|
||||
raise HTTPException(status_code=422, detail="Name at least one run")
|
||||
runs = {
|
||||
run.id: run
|
||||
for run in session.exec(select(Run).where(col(Run.id).in_(run_ids))).all()
|
||||
}
|
||||
lines: list[MetricSeries] = []
|
||||
for run_id in run_ids:
|
||||
run = runs.get(run_id)
|
||||
if run is None:
|
||||
continue
|
||||
rows = session.exec(
|
||||
select(RunMetric)
|
||||
.where(col(RunMetric.run_id) == run_id, col(RunMetric.name) == metric)
|
||||
.order_by(col(RunMetric.step))
|
||||
).all()
|
||||
label = run_id
|
||||
if run.seed is not None:
|
||||
label = f"{run_id} (seed {run.seed})"
|
||||
lines.append(
|
||||
MetricSeries(
|
||||
label=label, points=[[float(row.step), row.value] for row in rows]
|
||||
)
|
||||
)
|
||||
return SeriesAnswer(metric=metric, lines=lines)
|
||||
Reference in New Issue
Block a user