Skip to content

Commit e2fc422

Browse files
igennovaigennovaPGijsbers
authored
Feat: Implement GET /setup/{id} endpoint (#280)
FIxes: #61 # Description - Implemented the new GET /setup/{id} endpoint in Python, bridging database input settings into structured setup JSON, identical to the old PHP API. - Introduced strongly-typed Pydantic schemas `(SetupResponseand SetupParameter) `under `src/schemas/setups.py `strictly representing the returned topology. # Checklist 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] Changes are already covered under existing tests - [x] I have added tests that cover the changes (only required if not already under coverage) If applicable: - [x] 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. Screenshot: <img width="1383" height="1267" alt="Screenshot 2026-03-18 at 1 21 02 AM" src="https://github.com/user-attachments/assets/175d1b68-39e4-4818-85dc-df27da932860" /> --------- Co-authored-by: igennova <luckynegi025@gmail.com> Co-authored-by: PGijsbers <p.gijsbers@tue.nl>
1 parent c68a836 commit e2fc422

9 files changed

Lines changed: 175 additions & 12 deletions

File tree

docs/migration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ For example, after tagging dataset 21 with the tag `"foo"`:
109109

110110
## Setups
111111

112+
### `GET /{id}`
113+
The endpoint behaves almost identically to the PHP implementation. Note that fields representing integers like `setup_id` and `flow_id` are returned as integers instead of strings to align with typed JSON. Also, if a setup has no parameters, the `parameter` field is omitted entirely from the response.
114+
112115
### `POST /setup/tag` and `POST /setup/untag`
113116
When successful, the "tag" property in the returned response is now always a list, even if only one tag exists for the entity. When removing the last tag, the "tag" property will be an empty list `[]` instead of being omitted from the response.
114117

src/core/conversions.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
from collections.abc import Iterable, Mapping, Sequence
23
from typing import Any
34

@@ -7,9 +8,13 @@ def _str_to_num(string: str) -> int | float | str:
78
if string.isdigit():
89
return int(string)
910
try:
10-
return float(string)
11+
f = float(string)
12+
if math.isnan(f) or math.isinf(f):
13+
return string
1114
except ValueError:
1215
return string
16+
else:
17+
return f
1318

1419

1520
def nested_str_to_num(obj: Any) -> Any:
@@ -42,17 +47,21 @@ def nested_num_to_str(obj: Any) -> Any:
4247
return obj
4348

4449

45-
def nested_remove_nones(obj: Any) -> Any:
50+
def nested_remove_values(obj: Any, *, values: list[Any]) -> Any:
4651
if isinstance(obj, str):
4752
return obj
4853
if isinstance(obj, Mapping):
4954
return {
50-
key: nested_remove_nones(val)
55+
key: nested_remove_values(val, values=values)
5156
for key, val in obj.items()
52-
if val is not None and nested_remove_nones(val) is not None
57+
if nested_remove_values(val, values=values) not in values
5358
}
5459
if isinstance(obj, Iterable):
55-
return [nested_remove_nones(val) for val in obj if nested_remove_nones(val) is not None]
60+
return [
61+
nested_remove_values(val, values=values)
62+
for val in obj
63+
if nested_remove_values(val, values=values) not in values
64+
]
5665
return obj
5766

5867

src/database/setups.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""All database operations that directly operate on setups."""
22

33
from sqlalchemy import text
4-
from sqlalchemy.engine import Row
4+
from sqlalchemy.engine import Row, RowMapping
55
from sqlalchemy.ext.asyncio import AsyncConnection
66

77

@@ -20,6 +20,33 @@ async def get(setup_id: int, connection: AsyncConnection) -> Row | None:
2020
return row.first()
2121

2222

23+
async def get_parameters(setup_id: int, connection: AsyncConnection) -> list[RowMapping]:
24+
"""Get all parameters for setup with `setup_id` from the database."""
25+
rows = await connection.execute(
26+
text(
27+
"""
28+
SELECT
29+
t_input.id as id,
30+
t_input.implementation_id as flow_id,
31+
t_impl.name AS flow_name,
32+
CONCAT(t_impl.fullName, '_', t_input.name) AS full_name,
33+
t_input.name AS parameter_name,
34+
t_input.name AS name,
35+
t_input.dataType AS data_type,
36+
t_input.defaultValue AS default_value,
37+
t_setting.value AS value
38+
FROM input_setting t_setting
39+
JOIN input t_input ON t_setting.input_id = t_input.id
40+
JOIN implementation t_impl ON t_input.implementation_id = t_impl.id
41+
WHERE t_setting.setup = :setup_id
42+
ORDER BY t_impl.id, t_input.id
43+
""",
44+
),
45+
parameters={"setup_id": setup_id},
46+
)
47+
return list(rows.mappings().all())
48+
49+
2350
async def get_tags(setup_id: int, connection: AsyncConnection) -> list[Row]:
2451
"""Get all tags for setup with `setup_id` from the database."""
2552
rows = await connection.execute(

src/routers/openml/setups.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
from typing import Annotated
55

6-
from fastapi import APIRouter, Body, Depends
6+
from fastapi import APIRouter, Body, Depends, Path
77
from sqlalchemy.ext.asyncio import AsyncConnection
88

99
import database.setups
@@ -16,10 +16,33 @@
1616
from database.users import User
1717
from routers.dependencies import expdb_connection, fetch_user_or_raise
1818
from routers.types import SystemString64
19+
from schemas.setups import SetupParameters, SetupResponse
1920

2021
router = APIRouter(prefix="/setup", tags=["setup"])
2122

2223

24+
@router.get(path="/{setup_id}", response_model_exclude_none=True)
25+
async def get_setup(
26+
setup_id: Annotated[int, Path()],
27+
expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)],
28+
) -> SetupResponse:
29+
"""Get setup by id."""
30+
setup = await database.setups.get(setup_id, expdb_db)
31+
if not setup:
32+
msg = f"Setup {setup_id} not found."
33+
raise SetupNotFoundError(msg, code=281)
34+
35+
setup_parameters = await database.setups.get_parameters(setup_id, expdb_db)
36+
37+
params_model = SetupParameters(
38+
setup_id=setup_id,
39+
flow_id=setup.implementation_id,
40+
parameter=setup_parameters or None,
41+
)
42+
43+
return SetupResponse(setup_parameters=params_model)
44+
45+
2346
@router.post(path="/tag")
2447
async def tag_setup(
2548
setup_id: Annotated[int, Body()],

src/schemas/setups.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Pydantic schemas for the setup API endpoints."""
2+
3+
from pydantic import BaseModel, ConfigDict
4+
5+
6+
class SetupParameter(BaseModel):
7+
"""Schema representing an individual parameter within a setup."""
8+
9+
id: int
10+
flow_id: int
11+
flow_name: str
12+
full_name: str
13+
parameter_name: str
14+
name: str
15+
data_type: str | None = None
16+
default_value: str | None = None
17+
value: str | None = None
18+
19+
model_config = ConfigDict(from_attributes=True)
20+
21+
22+
class SetupParameters(BaseModel):
23+
"""Schema representing the grouped properties of a setup and its parameters."""
24+
25+
setup_id: int
26+
flow_id: int
27+
parameter: list[SetupParameter] | None = None
28+
29+
model_config = ConfigDict(from_attributes=True)
30+
31+
32+
class SetupResponse(BaseModel):
33+
"""Schema for the complete response of the GET /setup/{id} endpoint."""
34+
35+
setup_parameters: SetupParameters
36+
37+
model_config = ConfigDict(from_attributes=True)

tests/routers/openml/migration/setups_migration_test.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy import text
1111
from sqlalchemy.ext.asyncio import AsyncConnection
1212

13+
from core.conversions import nested_remove_values, nested_str_to_num
1314
from tests.conftest import temporary_records
1415
from tests.users import OWNER_USER, ApiKey
1516

@@ -274,6 +275,55 @@ async def test_setup_tag_response_is_identical_tag_already_exists(
274275

275276
assert original.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
276277
assert new.status_code == HTTPStatus.CONFLICT
277-
assert original.json()["error"]["code"] == new.json()["code"]
278278
assert original.json()["error"]["message"] == "Entity already tagged by this tag."
279279
assert new.json()["detail"] == f"Setup {setup_id} already has tag {tag!r}."
280+
281+
282+
async def test_get_setup_response_is_identical_setup_doesnt_exist(
283+
py_api: httpx.AsyncClient,
284+
php_api: httpx.AsyncClient,
285+
) -> None:
286+
setup_id = 999999
287+
288+
original, new = await asyncio.gather(
289+
php_api.get(f"/setup/{setup_id}"),
290+
py_api.get(f"/setup/{setup_id}"),
291+
)
292+
293+
assert original.status_code == HTTPStatus.PRECONDITION_FAILED
294+
assert new.status_code == HTTPStatus.NOT_FOUND
295+
assert original.json()["error"]["message"] == "Unknown setup"
296+
assert original.json()["error"]["code"] == new.json()["code"]
297+
assert new.json()["detail"] == f"Setup {setup_id} not found."
298+
299+
300+
@pytest.mark.parametrize("setup_id", range(1, 125))
301+
async def test_get_setup_response_is_identical(
302+
setup_id: int,
303+
py_api: httpx.AsyncClient,
304+
php_api: httpx.AsyncClient,
305+
) -> None:
306+
original, new = await asyncio.gather(
307+
php_api.get(f"/setup/{setup_id}"),
308+
py_api.get(f"/setup/{setup_id}"),
309+
)
310+
311+
if original.status_code == HTTPStatus.PRECONDITION_FAILED:
312+
assert new.status_code == HTTPStatus.NOT_FOUND
313+
return
314+
315+
assert original.status_code == HTTPStatus.OK
316+
assert new.status_code == HTTPStatus.OK
317+
318+
original_json = original.json()
319+
320+
# PHP returns integer fields as strings. To compare, we recursively convert string digits
321+
# to integers.
322+
# PHP also returns `[]` instead of null for empty string optional fields, which Python omits.
323+
original_json = nested_str_to_num(original_json)
324+
original_json = nested_remove_values(original_json, values=[[], None])
325+
326+
new_json = nested_str_to_num(new.json())
327+
new_json = nested_remove_values(new_json, values=[[], None])
328+
329+
assert original_json == new_json

tests/routers/openml/migration/studies_migration_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import deepdiff
44
import httpx
55

6-
from core.conversions import nested_num_to_str, nested_remove_nones
6+
from core.conversions import nested_num_to_str, nested_remove_values
77

88

99
async def test_get_study_equal(py_api: httpx.AsyncClient, php_api: httpx.AsyncClient) -> None:
@@ -17,7 +17,7 @@ async def test_get_study_equal(py_api: httpx.AsyncClient, php_api: httpx.AsyncCl
1717
# New implementation is typed
1818
new_json = nested_num_to_str(new_json)
1919
# New implementation has same fields even if empty
20-
new_json = nested_remove_nones(new_json)
20+
new_json = nested_remove_values(new_json, values=[None])
2121
new_json["tasks"] = {"task_id": new_json.pop("task_ids")}
2222
new_json["data"] = {"data_id": new_json.pop("data_ids")}
2323
if runs := new_json.pop("run_ids", None):

tests/routers/openml/migration/tasks_migration_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
from core.conversions import (
99
nested_num_to_str,
10-
nested_remove_nones,
1110
nested_remove_single_element_list,
11+
nested_remove_values,
1212
)
1313

1414

@@ -32,7 +32,7 @@ async def test_get_task_equal(
3232
new_json["task_id"] = new_json.pop("id")
3333
new_json["task_name"] = new_json.pop("name")
3434
# PHP is not typed *and* automatically removes None values
35-
new_json = nested_remove_nones(new_json)
35+
new_json = nested_remove_values(new_json, values=[None])
3636
new_json = nested_num_to_str(new_json)
3737
# It also removes "value" entries for parameters if the list is empty,
3838
# it does not remove *all* empty lists, e.g., for cost_matrix input they are kept

tests/routers/openml/setups_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,17 @@ async def test_setup_tag_success(py_api: httpx.AsyncClient, expdb_test: AsyncCon
130130
text("SELECT * FROM setup_tag WHERE id = 1 AND tag = 'my_new_success_tag'")
131131
)
132132
assert len(rows.all()) == 1
133+
134+
135+
async def test_get_setup_unknown(py_api: httpx.AsyncClient) -> None:
136+
response = await py_api.get("/setup/999999")
137+
assert response.status_code == HTTPStatus.NOT_FOUND
138+
assert re.match(r"Setup \d+ not found.", response.json()["detail"])
139+
140+
141+
async def test_get_setup_success(py_api: httpx.AsyncClient) -> None:
142+
response = await py_api.get("/setup/1")
143+
assert response.status_code == HTTPStatus.OK
144+
data = response.json()["setup_parameters"]
145+
assert data["setup_id"] == 1
146+
assert "parameter" in data

0 commit comments

Comments
 (0)