From fb3419738e19327054b51cf4462277765747fd52 Mon Sep 17 00:00:00 2001 From: igennova Date: Wed, 18 Mar 2026 01:18:57 +0530 Subject: [PATCH 1/2] Implement GET /setup/{id} endpoint with Pydantic schema --- src/database/setups.py | 28 +++++++++++++- src/routers/openml/setups.py | 25 ++++++++++++- src/schemas/setups.py | 37 +++++++++++++++++++ .../openml/migration/setups_migration_test.py | 31 +++++++++++++++- tests/routers/openml/setups_test.py | 14 +++++++ 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/schemas/setups.py diff --git a/src/database/setups.py b/src/database/setups.py index e399f194..048877f2 100644 --- a/src/database/setups.py +++ b/src/database/setups.py @@ -1,7 +1,7 @@ """All database operations that directly operate on setups.""" from sqlalchemy import text -from sqlalchemy.engine import Row +from sqlalchemy.engine import Row, RowMapping from sqlalchemy.ext.asyncio import AsyncConnection @@ -20,6 +20,32 @@ async def get(setup_id: int, connection: AsyncConnection) -> Row | None: return row.first() +async def get_parameters(setup_id: int, connection: AsyncConnection) -> list[RowMapping]: + """Get all parameters for setup with `setup_id` from the database.""" + rows = await connection.execute( + text( + """ + SELECT + CAST(t_input.id AS CHAR) as id, + CAST(t_input.implementation_id AS CHAR) as flow_id, + t_impl.name AS flow_name, + CONCAT(t_impl.fullName, '_', t_input.name) AS full_name, + t_input.name AS parameter_name, + t_input.name AS name, + t_input.dataType AS data_type, + t_input.defaultValue AS default_value, + t_setting.value AS value + FROM input_setting t_setting + JOIN input t_input ON t_setting.input_id = t_input.id + JOIN implementation t_impl ON t_input.implementation_id = t_impl.id + WHERE t_setting.setup = :setup_id + """, + ), + parameters={"setup_id": setup_id}, + ) + return list(rows.mappings().all()) + + async def get_tags(setup_id: int, connection: AsyncConnection) -> list[Row]: """Get all tags for setup with `setup_id` from the database.""" rows = await connection.execute( diff --git a/src/routers/openml/setups.py b/src/routers/openml/setups.py index 65d2d533..f7bd0462 100644 --- a/src/routers/openml/setups.py +++ b/src/routers/openml/setups.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, Path from sqlalchemy.ext.asyncio import AsyncConnection import database.setups @@ -15,10 +15,33 @@ from database.users import User, UserGroup from routers.dependencies import expdb_connection, fetch_user_or_raise from routers.types import SystemString64 +from schemas.setups import SetupParameters, SetupResponse router = APIRouter(prefix="/setup", tags=["setup"]) +@router.get(path="/{setup_id}", response_model_exclude_none=True) +async def get_setup( + setup_id: Annotated[int, Path()], + expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], +) -> SetupResponse: + """Get setup by id.""" + setup = await database.setups.get(setup_id, expdb_db) + if not setup: + msg = f"Setup {setup_id} not found." + raise SetupNotFoundError(msg, code=281) + + setup_parameters = await database.setups.get_parameters(setup_id, expdb_db) + + params_model = SetupParameters( + setup_id=str(setup_id), + flow_id=str(setup.implementation_id), + parameter=[dict(param) for param in setup_parameters] if setup_parameters else None, + ) + + return SetupResponse(setup_parameters=params_model) + + @router.post(path="/tag") async def tag_setup( setup_id: Annotated[int, Body()], diff --git a/src/schemas/setups.py b/src/schemas/setups.py new file mode 100644 index 00000000..8bcaf1bb --- /dev/null +++ b/src/schemas/setups.py @@ -0,0 +1,37 @@ +"""Pydantic schemas for the setup API endpoints.""" + +from pydantic import BaseModel, ConfigDict + + +class SetupParameter(BaseModel): + """Schema representing an individual parameter within a setup.""" + + id: str + flow_id: str + flow_name: str + full_name: str + parameter_name: str + name: str + data_type: str | None = None + default_value: str | None = None + value: str + + model_config = ConfigDict(from_attributes=True) + + +class SetupParameters(BaseModel): + """Schema representing the grouped properties of a setup and its parameters.""" + + setup_id: str + flow_id: str + parameter: list[SetupParameter] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class SetupResponse(BaseModel): + """Schema for the complete response of the GET /setup/{id} endpoint.""" + + setup_parameters: SetupParameters + + model_config = ConfigDict(from_attributes=True) diff --git a/tests/routers/openml/migration/setups_migration_test.py b/tests/routers/openml/migration/setups_migration_test.py index b33742e0..3aefcab4 100644 --- a/tests/routers/openml/migration/setups_migration_test.py +++ b/tests/routers/openml/migration/setups_migration_test.py @@ -266,6 +266,35 @@ async def test_setup_tag_response_is_identical_tag_already_exists( assert original.status_code == HTTPStatus.INTERNAL_SERVER_ERROR assert new.status_code == HTTPStatus.CONFLICT - assert original.json()["error"]["code"] == new.json()["code"] assert original.json()["error"]["message"] == "Entity already tagged by this tag." assert new.json()["detail"] == f"Setup {setup_id} already has tag {tag!r}." + + +async def test_get_setup_response_is_identical_setup_doesnt_exist( + py_api: httpx.AsyncClient, + php_api: httpx.AsyncClient, +) -> None: + setup_id = 999999 + + original = await php_api.get(f"/setup/{setup_id}") + new = await py_api.get(f"/setup/{setup_id}") + + assert original.status_code == HTTPStatus.PRECONDITION_FAILED + assert new.status_code == HTTPStatus.NOT_FOUND + assert original.json()["error"]["message"] == "Unknown setup" + assert original.json()["error"]["code"] == new.json()["code"] + + +@pytest.mark.parametrize("setup_id", [1, 48]) +async def test_get_setup_response_is_identical( + setup_id: int, + py_api: httpx.AsyncClient, + php_api: httpx.AsyncClient, +) -> None: + original = await php_api.get(f"/setup/{setup_id}") + new = await py_api.get(f"/setup/{setup_id}") + + assert original.status_code == HTTPStatus.OK + assert new.status_code == HTTPStatus.OK + + assert original.json() == new.json() diff --git a/tests/routers/openml/setups_test.py b/tests/routers/openml/setups_test.py index 305bf423..db35b41b 100644 --- a/tests/routers/openml/setups_test.py +++ b/tests/routers/openml/setups_test.py @@ -130,3 +130,17 @@ async def test_setup_tag_success(py_api: httpx.AsyncClient, expdb_test: AsyncCon text("SELECT * FROM setup_tag WHERE id = 1 AND tag = 'my_new_success_tag'") ) assert len(rows.all()) == 1 + + +async def test_get_setup_unknown(py_api: httpx.AsyncClient) -> None: + response = await py_api.get("/setup/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert re.match(r"Setup \d+ not found.", response.json()["detail"]) + + +async def test_get_setup_success(py_api: httpx.AsyncClient) -> None: + response = await py_api.get("/setup/1") + assert response.status_code == HTTPStatus.OK + data = response.json()["setup_parameters"] + assert data["setup_id"] == "1" + assert "parameter" in data From 264043d84524acd4c9ff534d054a503915fe0c44 Mon Sep 17 00:00:00 2001 From: igennova Date: Wed, 18 Mar 2026 01:33:26 +0530 Subject: [PATCH 2/2] updating docs and minor improvements --- docs/migration.md | 3 +++ src/database/setups.py | 1 + src/routers/openml/setups.py | 2 +- src/schemas/setups.py | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 2c349c82..f2e57f12 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -109,6 +109,9 @@ For example, after tagging dataset 21 with the tag `"foo"`: ## Setups +### `GET /{id}` +The endpoint behaves identically to the PHP implementation. Note that `setup_id` and `flow_id` are consistently returned as strings instead of integers to maintain strict backward compatibility. Also, if a setup has no parameters, the `parameter` field is omitted entirely from the response. + ### `POST /setup/tag` and `POST /setup/untag` 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. diff --git a/src/database/setups.py b/src/database/setups.py index 048877f2..a91af5eb 100644 --- a/src/database/setups.py +++ b/src/database/setups.py @@ -39,6 +39,7 @@ async def get_parameters(setup_id: int, connection: AsyncConnection) -> list[Row JOIN input t_input ON t_setting.input_id = t_input.id JOIN implementation t_impl ON t_input.implementation_id = t_impl.id WHERE t_setting.setup = :setup_id + ORDER BY t_impl.id, t_input.id """, ), parameters={"setup_id": setup_id}, diff --git a/src/routers/openml/setups.py b/src/routers/openml/setups.py index f7bd0462..267866d5 100644 --- a/src/routers/openml/setups.py +++ b/src/routers/openml/setups.py @@ -36,7 +36,7 @@ async def get_setup( params_model = SetupParameters( setup_id=str(setup_id), flow_id=str(setup.implementation_id), - parameter=[dict(param) for param in setup_parameters] if setup_parameters else None, + parameter=setup_parameters or None, ) return SetupResponse(setup_parameters=params_model) diff --git a/src/schemas/setups.py b/src/schemas/setups.py index 8bcaf1bb..7dbb7011 100644 --- a/src/schemas/setups.py +++ b/src/schemas/setups.py @@ -14,7 +14,7 @@ class SetupParameter(BaseModel): name: str data_type: str | None = None default_value: str | None = None - value: str + value: str | None = None model_config = ConfigDict(from_attributes=True)