Skip to content

Commit 5c30ef7

Browse files
Add GET /run/trace/{run_id} endpoint (#272)
# Description Implements `GET /run/trace/{run_id}` as part of the run endpoints. Fixes: #40 Related: #36 Matches PHP API behavior for error codes (571/572) and response shape. Returns 412 with code 571 if run does not exist, 412 with code 572 if run exists but has no trace, and trace rows on success. # Checklist _Please check all that apply. You can mark items as N/A if they don't apply to your change._ Always: - [x] I have performed a self-review of my own pull request to ensure it contains all relevant information, and the proposed changes are minimal but sufficient to accomplish their task. Required for code changes: - [x] Tests pass locally - [x] I have commented my code in hard-to-understand areas, and provided or updated docstrings as needed - [x] I have added tests that cover the changes (only required if not already under coverage) If applicable: - [N/A] I have made corresponding changes to the documentation pages (`/docs`) Extra context: - [ ] This PR and the commits have been created autonomously by a bot/agent. --------- Co-authored-by: PGijsbers <p.gijsbers@tue.nl>
1 parent ffb11fa commit 5c30ef7

7 files changed

Lines changed: 259 additions & 0 deletions

File tree

src/core/errors.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,26 @@ class InternalError(ProblemDetailError):
399399
uri = "https://openml.org/problems/internal-error"
400400
title = "Internal Server Error"
401401
_default_status_code = HTTPStatus.INTERNAL_SERVER_ERROR
402+
403+
404+
# =============================================================================
405+
# Run Errors
406+
# =============================================================================
407+
408+
409+
class RunNotFoundError(ProblemDetailError):
410+
"""Raised when a run cannot be found."""
411+
412+
uri = "https://openml.org/problems/run-not-found"
413+
title = "Run Not Found"
414+
_default_status_code = HTTPStatus.NOT_FOUND
415+
_default_code = 571
416+
417+
418+
class RunTraceNotFoundError(ProblemDetailError):
419+
"""Raised when trace data for a run cannot be found."""
420+
421+
uri = "https://openml.org/problems/run-trace-not-found"
422+
title = "Run Trace Not Found"
423+
_default_status_code = HTTPStatus.NOT_FOUND
424+
_default_code = 572

src/database/runs.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Database queries for run-related data."""
2+
3+
from collections.abc import Sequence
4+
from typing import cast
5+
6+
from sqlalchemy import Row, text
7+
from sqlalchemy.ext.asyncio import AsyncConnection
8+
9+
10+
async def exist(id_: int, expdb: AsyncConnection) -> bool:
11+
"""Check if a run exists by ID."""
12+
row = await expdb.execute(
13+
text(
14+
"""
15+
SELECT 1
16+
FROM `run`
17+
WHERE `rid` = :run_id
18+
""",
19+
),
20+
parameters={"run_id": id_},
21+
)
22+
return bool(row.one_or_none())
23+
24+
25+
async def get_trace(run_id: int, expdb: AsyncConnection) -> Sequence[Row]:
26+
"""Get trace rows for a run from the trace table."""
27+
rows = await expdb.execute(
28+
text(
29+
"""
30+
SELECT `repeat`, `fold`, `iteration`, `setup_string`, `evaluation`, `selected`
31+
FROM `trace`
32+
WHERE `run_id` = :run_id
33+
""",
34+
),
35+
parameters={"run_id": run_id},
36+
)
37+
return cast(
38+
"Sequence[Row]",
39+
rows.all(),
40+
)

src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from routers.openml.evaluations import router as evaluationmeasures_router
1616
from routers.openml.flows import router as flows_router
1717
from routers.openml.qualities import router as qualities_router
18+
from routers.openml.runs import router as run_router
1819
from routers.openml.setups import router as setup_router
1920
from routers.openml.study import router as study_router
2021
from routers.openml.tasks import router as task_router
@@ -70,6 +71,7 @@ def create_api() -> FastAPI:
7071
app.include_router(flows_router)
7172
app.include_router(study_router)
7273
app.include_router(setup_router)
74+
app.include_router(run_router)
7375
return app
7476

7577

src/routers/openml/runs.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Endpoints for run-related data."""
2+
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Depends
6+
from sqlalchemy.ext.asyncio import AsyncConnection
7+
8+
import database.runs
9+
from core.errors import RunNotFoundError, RunTraceNotFoundError
10+
from routers.dependencies import expdb_connection
11+
from schemas.runs import RunTrace, TraceIteration
12+
13+
router = APIRouter(prefix="/run", tags=["run"])
14+
15+
16+
@router.get("/trace/{run_id}")
17+
async def get_run_trace(
18+
run_id: int,
19+
expdb: Annotated[AsyncConnection, Depends(expdb_connection)],
20+
) -> RunTrace:
21+
"""Get trace data for a run by run ID."""
22+
if not await database.runs.exist(run_id, expdb):
23+
msg = f"Run {run_id} not found."
24+
raise RunNotFoundError(msg)
25+
26+
trace_rows = await database.runs.get_trace(run_id, expdb)
27+
if not trace_rows:
28+
msg = f"No trace found for run {run_id}."
29+
raise RunTraceNotFoundError(msg)
30+
31+
return RunTrace(
32+
run_id=run_id,
33+
trace=[
34+
TraceIteration(
35+
repeat=row.repeat,
36+
fold=row.fold,
37+
iteration=row.iteration,
38+
setup_string=row.setup_string,
39+
evaluation=row.evaluation,
40+
selected=row.selected,
41+
)
42+
for row in trace_rows
43+
],
44+
)

src/schemas/runs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Pydantic schemas for run-related endpoints."""
2+
3+
from pydantic import BaseModel
4+
5+
6+
class TraceIteration(BaseModel):
7+
"""A single trace iteration for a run."""
8+
9+
repeat: int
10+
fold: int
11+
iteration: int
12+
setup_string: str | None
13+
evaluation: float | None
14+
selected: str
15+
16+
17+
class RunTrace(BaseModel):
18+
"""Trace data for a run."""
19+
20+
run_id: int
21+
trace: list[TraceIteration]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Migration tests comparing PHP and Python API responses for run trace endpoints."""
2+
3+
import asyncio
4+
from http import HTTPStatus
5+
from typing import Any
6+
7+
import deepdiff
8+
import httpx
9+
import pytest
10+
11+
from core.conversions import nested_num_to_str
12+
13+
_SERVER_RUNS = [*range(24, 40), *range(134, 140), 999_999_999]
14+
15+
16+
@pytest.mark.parametrize("run_id", _SERVER_RUNS)
17+
async def test_get_run_trace_equal(
18+
run_id: int,
19+
py_api: httpx.AsyncClient,
20+
php_api: httpx.AsyncClient,
21+
) -> None:
22+
"""Test that Python and PHP run trace responses are equivalent after normalization."""
23+
py_response, php_response = await asyncio.gather(
24+
py_api.get(f"/run/trace/{run_id}"),
25+
php_api.get(f"/run/trace/{run_id}"),
26+
)
27+
if php_response.status_code == HTTPStatus.OK:
28+
_assert_trace_response_success(py_response, php_response)
29+
return
30+
31+
assert php_response.status_code == HTTPStatus.PRECONDITION_FAILED
32+
assert py_response.status_code == HTTPStatus.NOT_FOUND
33+
34+
php_error = php_response.json()["error"]
35+
py_error = py_response.json()
36+
assert php_error["code"] == py_error["code"]
37+
if php_error["code"] == "571":
38+
assert php_error["message"] == "Run not found."
39+
assert py_error["detail"] == f"Run {run_id} not found."
40+
elif php_error["code"] == "572":
41+
assert php_error["message"] == "No successful trace associated with this run."
42+
assert py_error["detail"] == f"No trace found for run {run_id}."
43+
else:
44+
msg = f"Unknown error code {php_error['code']} for run {run_id}."
45+
raise AssertionError(msg)
46+
47+
48+
def _assert_trace_response_success(
49+
py_response: httpx.Response, php_response: httpx.Response
50+
) -> None:
51+
assert py_response.status_code == HTTPStatus.OK
52+
assert php_response.status_code == HTTPStatus.OK
53+
54+
new_json = py_response.json()
55+
56+
# PHP nests response under "trace" key — match that structure
57+
new_json = {"trace": new_json}
58+
59+
# PHP uses "trace_iteration" key, Python uses "trace"
60+
new_json["trace"]["trace_iteration"] = new_json["trace"].pop("trace")
61+
62+
# PHP returns all numeric values as strings — normalize Python response
63+
new_json = nested_num_to_str(new_json)
64+
65+
def _sort_trace(payload: dict[str, Any]) -> dict[str, Any]:
66+
"""Sort trace iterations by (repeat, fold, iteration) for order-sensitive comparison."""
67+
copied = payload.copy()
68+
copied["trace"] = copied["trace"].copy()
69+
copied["trace"]["trace_iteration"] = sorted(
70+
copied["trace"]["trace_iteration"],
71+
key=lambda row: (int(row["repeat"]), int(row["fold"]), int(row["iteration"])),
72+
)
73+
return copied
74+
75+
differences = deepdiff.diff.DeepDiff(
76+
_sort_trace(new_json),
77+
_sort_trace(php_response.json()),
78+
ignore_order=False,
79+
)
80+
assert not differences

tests/routers/openml/runs_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Tests for the GET /run/trace/{run_id} endpoint."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx
6+
import pytest
7+
8+
from core.errors import RunNotFoundError, RunTraceNotFoundError
9+
10+
11+
@pytest.mark.parametrize("run_id", [34])
12+
async def test_get_run_trace_success(run_id: int, py_api: httpx.AsyncClient) -> None:
13+
"""Test that trace data is returned for a run that has trace entries."""
14+
response = await py_api.get(f"/run/trace/{run_id}")
15+
assert response.status_code == HTTPStatus.OK
16+
body = response.json()
17+
assert body["run_id"] == run_id
18+
assert isinstance(body["trace"], list)
19+
assert len(body["trace"]) > 0
20+
first = body["trace"][0]
21+
assert isinstance(first["repeat"], int)
22+
assert isinstance(first["fold"], int)
23+
assert isinstance(first["iteration"], int)
24+
assert first["selected"] in ("true", "false")
25+
assert first["evaluation"] is None or isinstance(first["evaluation"], float)
26+
27+
28+
@pytest.mark.parametrize("run_id", [24])
29+
async def test_get_run_trace_no_trace(run_id: int, py_api: httpx.AsyncClient) -> None:
30+
"""Test that 412 is returned for a run that exists but has no trace."""
31+
response = await py_api.get(f"/run/trace/{run_id}")
32+
assert response.status_code == HTTPStatus.NOT_FOUND
33+
body = response.json()
34+
assert body["code"] == "572" # RunTraceNotFoundError code
35+
assert body["type"] == RunTraceNotFoundError.uri
36+
assert body["title"] == RunTraceNotFoundError.title
37+
assert body["status"] == HTTPStatus.NOT_FOUND
38+
39+
40+
@pytest.mark.parametrize("run_id", [999999])
41+
async def test_get_run_trace_run_not_found(run_id: int, py_api: httpx.AsyncClient) -> None:
42+
"""Test that 412 is returned when the run does not exist."""
43+
response = await py_api.get(f"/run/trace/{run_id}")
44+
assert response.status_code == HTTPStatus.NOT_FOUND
45+
body = response.json()
46+
assert body["code"] == "571" # RunNotFoundError code
47+
assert body["type"] == RunNotFoundError.uri
48+
assert body["title"] == RunNotFoundError.title
49+
assert body["status"] == HTTPStatus.NOT_FOUND

0 commit comments

Comments
 (0)