diff --git a/src/core/access.py b/src/core/access.py index 871fb48f..920e920a 100644 --- a/src/core/access.py +++ b/src/core/access.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING, Any from database.users import User -from schemas.datasets.openml import Visibility +from schemas.datasets import Visibility if TYPE_CHECKING: from sqlalchemy.engine import Row -async def _user_has_access( +async def user_has_access( dataset: Row[Any], user: User | None = None, ) -> bool: diff --git a/src/core/conversions.py b/src/core/conversions.py index 5ac13c4a..597b76a4 100644 --- a/src/core/conversions.py +++ b/src/core/conversions.py @@ -3,7 +3,7 @@ from typing import Any -def _str_to_num(string: str) -> int | float | str: +def str_to_num(string: str) -> int | float | str: """Try to convert the string to integer, otherwise float, otherwise returns the input.""" if string.isdigit(): return int(string) @@ -23,7 +23,7 @@ def nested_str_to_num(obj: Any) -> Any: For dictionaries, only the values will be converted. """ if isinstance(obj, str): - return _str_to_num(obj) + return str_to_num(obj) if isinstance(obj, Mapping): return {key: nested_str_to_num(val) for key, val in obj.items()} if isinstance(obj, Iterable): diff --git a/src/core/formatting.py b/src/core/formatting.py index a4b09e8a..33146f4a 100644 --- a/src/core/formatting.py +++ b/src/core/formatting.py @@ -1,11 +1,7 @@ -import html +"""Utilities for formatting and converting text data.""" -from config import get_config -from database.schema.base import UntypedRow -from schemas.datasets.openml import DatasetFileFormat - -def _str_to_bool(string: str) -> bool: +def str_to_bool(string: str) -> bool: if string.casefold() in ["true", "1", "yes", "y"]: return True if string.casefold() in ["false", "0", "no", "n"]: @@ -14,30 +10,7 @@ def _str_to_bool(string: str) -> bool: raise ValueError(msg) -def _format_parquet_url(dataset: UntypedRow) -> str | None: - if dataset.format.lower() != DatasetFileFormat.ARFF: - return None - - minio_base_url = get_config().routing.minio_url - ten_thousands_prefix = f"{dataset.did // 10_000:04d}" - padded_id = f"{dataset.did:04d}" - return f"{minio_base_url}datasets/{ten_thousands_prefix}/{padded_id}/dataset_{dataset.did}.pq" - - -def _format_dataset_url(dataset: UntypedRow) -> str: - base_url = get_config().routing.server_url - filename = f"{html.escape(dataset.name)}.{dataset.format.lower()}" - return f"{base_url}data/v1/download/{dataset.file_id}/{filename}" - - -def _safe_unquote(text: str | None) -> str | None: - """Remove any open and closing quotes and return the remainder if non-empty.""" - if not text: - return None - return text.strip("'\"") or None - - -def _csv_as_list(text: str | None, *, unquote_items: bool = True) -> list[str]: +def csv_as_list(text: str | None, *, unquote_items: bool = True) -> list[str]: """Return comma-separated values in `text` as list, optionally remove quotes.""" if not text: return [] diff --git a/src/routers/types.py b/src/core/types.py similarity index 92% rename from src/routers/types.py rename to src/core/types.py index fe945778..6d33ec95 100644 --- a/src/routers/types.py +++ b/src/core/types.py @@ -1,3 +1,5 @@ +"""Common types used in the definition of schemas and models.""" + from typing import Annotated from pydantic import Field diff --git a/src/database/datasets.py b/src/database/datasets.py index 5cb99d03..1d6bf05d 100644 --- a/src/database/datasets.py +++ b/src/database/datasets.py @@ -7,15 +7,15 @@ from sqlalchemy import text from sqlalchemy.exc import IntegrityError +from core.types import Identifier, TagString from database.exceptions import ( _DUPLICATE_ENTRY, _FOREIGN_KEY_CONSTRAINT_FAILED, DuplicatePrimaryKeyError, ForeignKeyConstraintError, ) -from database.schema.base import UntypedRow -from routers.types import Identifier, TagString -from schemas.datasets.openml import DatasetStatus, Feature +from database.models.base import UntypedRow +from schemas.datasets import DatasetStatus, Feature if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection diff --git a/src/database/setup.py b/src/database/engine.py similarity index 88% rename from src/database/setup.py rename to src/database/engine.py index cfd23069..80cf0c14 100644 --- a/src/database/setup.py +++ b/src/database/engine.py @@ -1,3 +1,5 @@ +"""Functions relating to the database engines.""" + import functools from loguru import logger @@ -27,11 +29,13 @@ def _create_engine(db_config: DatabaseConfiguration) -> AsyncEngine: @functools.cache def user_database() -> AsyncEngine: + """Get a database engine connected to the `openml` database.""" return _create_engine(get_config().openml_database) @functools.cache def expdb_database() -> AsyncEngine: + """Get a database engine connected to the `openml_expdb` database.""" return _create_engine(get_config().expdb_database) diff --git a/src/database/evaluations.py b/src/database/evaluations.py index 14bec4d9..ce0d230a 100644 --- a/src/database/evaluations.py +++ b/src/database/evaluations.py @@ -3,9 +3,9 @@ from sqlalchemy import text -from core.formatting import _str_to_bool -from database.schema.base import UntypedRow -from schemas.datasets.openml import EstimationProcedure +from core.formatting import str_to_bool +from database.models.base import UntypedRow +from schemas.tasks import EstimationProcedure if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection @@ -41,7 +41,7 @@ async def get_estimation_procedures(connection: AsyncConnection) -> list[Estimat rows = row.mappings().all() typed_rows = [ { - k: v if k != "stratified_sampling" or v is None else _str_to_bool(v) + k: v if k != "stratified_sampling" or v is None else str_to_bool(v) for k, v in row.items() } for row in rows diff --git a/src/database/flows.py b/src/database/flows.py index 830a386b..d245d66d 100644 --- a/src/database/flows.py +++ b/src/database/flows.py @@ -3,8 +3,8 @@ from sqlalchemy import text -from database.schema.base import UntypedRow -from routers.types import Identifier +from core.types import Identifier +from database.models.base import UntypedRow if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection diff --git a/src/database/schema/__init__.py b/src/database/models/__init__.py similarity index 100% rename from src/database/schema/__init__.py rename to src/database/models/__init__.py diff --git a/src/database/schema/base.py b/src/database/models/base.py similarity index 96% rename from src/database/schema/base.py rename to src/database/models/base.py index 0a750559..9ded3e22 100644 --- a/src/database/schema/base.py +++ b/src/database/models/base.py @@ -18,7 +18,7 @@ class ClassName(ExpDBReflected, Base): from sqlalchemy.ext.declarative import DeferredReflection from sqlalchemy.orm import DeclarativeBase -from database.setup import expdb_database, user_database +from database.engine import expdb_database, user_database UntypedRow = Row[Any] diff --git a/src/database/schema/setups.py b/src/database/models/setups.py similarity index 81% rename from src/database/schema/setups.py rename to src/database/models/setups.py index 36f96694..3ff94d2f 100644 --- a/src/database/schema/setups.py +++ b/src/database/models/setups.py @@ -2,8 +2,8 @@ from sqlalchemy.orm import Mapped, mapped_column -from database.schema.base import Base, ExpDBReflected -from routers.types import Identifier +from core.types import Identifier +from database.models.base import Base, ExpDBReflected class Setup(ExpDBReflected, Base): diff --git a/src/database/schema/tags.py b/src/database/models/tags.py similarity index 91% rename from src/database/schema/tags.py rename to src/database/models/tags.py index 5fd687e0..560b6802 100644 --- a/src/database/schema/tags.py +++ b/src/database/models/tags.py @@ -5,8 +5,8 @@ from sqlalchemy import FetchedValue from sqlalchemy.orm import Mapped, mapped_column -from database.schema.base import Base, ExpDBReflected -from routers.types import Identifier, TagString +from core.types import Identifier, TagString +from database.models.base import Base, ExpDBReflected class Tag: diff --git a/src/database/qualities.py b/src/database/qualities.py index 4899f028..bb239eb3 100644 --- a/src/database/qualities.py +++ b/src/database/qualities.py @@ -4,8 +4,8 @@ from sqlalchemy import text -from routers.types import Identifier -from schemas.datasets.openml import Quality +from core.types import Identifier +from schemas.datasets import Quality if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection diff --git a/src/database/runs.py b/src/database/runs.py index 0c5c93c2..2a4d5470 100644 --- a/src/database/runs.py +++ b/src/database/runs.py @@ -5,8 +5,8 @@ from sqlalchemy import bindparam, text -from database.schema.base import UntypedRow -from routers.types import Identifier +from core.types import Identifier +from database.models.base import UntypedRow if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection diff --git a/src/database/setups.py b/src/database/setups.py index 2bd20f86..2808cd98 100644 --- a/src/database/setups.py +++ b/src/database/setups.py @@ -6,15 +6,15 @@ from sqlalchemy import select, text from sqlalchemy.exc import IntegrityError +from core.types import Identifier, TagString from database.exceptions import ( _DUPLICATE_ENTRY, _FOREIGN_KEY_CONSTRAINT_FAILED, DuplicatePrimaryKeyError, ForeignKeyConstraintError, ) -from database.schema.setups import Setup -from database.schema.tags import SetupTag -from routers.types import Identifier, TagString +from database.models.setups import Setup +from database.models.tags import SetupTag if TYPE_CHECKING: from sqlalchemy.engine import RowMapping diff --git a/src/database/studies.py b/src/database/studies.py index b4a69364..b9617164 100644 --- a/src/database/studies.py +++ b/src/database/studies.py @@ -5,9 +5,9 @@ from sqlalchemy import text -from database.schema.base import UntypedRow +from core.types import Identifier +from database.models.base import UntypedRow from database.users import User -from routers.types import Identifier from schemas.study import CreateStudy, StudyType if TYPE_CHECKING: diff --git a/src/database/tasks.py b/src/database/tasks.py index ae8aaa0e..74c330bb 100644 --- a/src/database/tasks.py +++ b/src/database/tasks.py @@ -4,15 +4,15 @@ from sqlalchemy import select, text from sqlalchemy.exc import IntegrityError +from core.types import Identifier, TagString from database.exceptions import ( _DUPLICATE_ENTRY, _FOREIGN_KEY_CONSTRAINT_FAILED, DuplicatePrimaryKeyError, ForeignKeyConstraintError, ) -from database.schema.base import UntypedRow -from database.schema.tags import TaskTag -from routers.types import Identifier, TagString +from database.models.base import UntypedRow +from database.models.tags import TaskTag if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession diff --git a/src/database/users.py b/src/database/users.py index 745e3113..3e46ab08 100644 --- a/src/database/users.py +++ b/src/database/users.py @@ -8,7 +8,7 @@ from sqlalchemy import text from config import get_config -from routers.types import Identifier +from core.types import Identifier if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection diff --git a/src/main.py b/src/main.py index 6207b15d..59e1cb8f 100644 --- a/src/main.py +++ b/src/main.py @@ -26,19 +26,19 @@ request_response_logger, setup_log_sinks, ) -from database.schema.base import reflect_db_schemas -from database.setup import close_databases -from routers.openml.datasets import router as datasets_router -from routers.openml.estimation_procedure import router as estimationprocedure_router -from routers.openml.evaluations import router as evaluationmeasures_router -from routers.openml.flows import router as flows_router -from routers.openml.qualities import router as qualities_router -from routers.openml.runs import router as run_router -from routers.openml.setups import router as setup_router -from routers.openml.study import router as study_router -from routers.openml.tasks import router as task_router -from routers.openml.tasktype import router as ttype_router -from routers.openml.users import router as users_router +from database.engine import close_databases +from database.models.base import reflect_db_schemas +from routers.datasets import router as datasets_router +from routers.estimation_procedure import router as estimationprocedure_router +from routers.evaluations import router as evaluationmeasures_router +from routers.flows import router as flows_router +from routers.qualities import router as qualities_router +from routers.runs import router as run_router +from routers.setups import router as setup_router +from routers.study import router as study_router +from routers.tasks import router as task_router +from routers.tasktype import router as ttype_router +from routers.users import router as users_router @asynccontextmanager diff --git a/src/routers/__init__.py b/src/routers/__init__.py index 21016d13..5d924fcf 100644 --- a/src/routers/__init__.py +++ b/src/routers/__init__.py @@ -1 +1 @@ -"""API routers for the OpenML server API.""" +"""Routers for the OpenML API.""" diff --git a/src/routers/openml/datasets.py b/src/routers/datasets.py similarity index 87% rename from src/routers/openml/datasets.py rename to src/routers/datasets.py index 72c94de3..2718e7b0 100644 --- a/src/routers/openml/datasets.py +++ b/src/routers/datasets.py @@ -1,9 +1,18 @@ +"""Defines endpoints relating to Datasets. + +A dataset includes both the "data", e.g., the table or parquet file, as well as its metadata. +The metadata is partially provided by the user (for example, the name or description), +'features' that are partially parsed from the data file (for example, column names), +and 'qualities' (or meta-features) that describe the data (for example, number of rows or columns). +""" + import asyncio +import html import re from datetime import datetime from enum import StrEnum from http import HTTPStatus -from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple, NotRequired, TypedDict +from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple from fastapi import APIRouter, Body, Depends, Query from loguru import logger @@ -11,7 +20,8 @@ import database.datasets import database.qualities -from core.access import _user_has_access +from config import get_config +from core.access import user_has_access from core.errors import ( DatasetAdminOnlyError, DatasetNoAccessError, @@ -28,12 +38,16 @@ TagNotFoundError, TagNotOwnedError, ) -from core.formatting import ( - _csv_as_list, - _format_dataset_url, - _format_parquet_url, +from core.formatting import csv_as_list +from core.types import ( + CasualString128, + Identifier, + IntegerRange, + TagString, + integer_range_regex, ) from database.exceptions import DuplicatePrimaryKeyError, ForeignKeyConstraintError +from database.models.base import UntypedRow from database.users import User from routers.dependencies import ( Pagination, @@ -42,14 +56,8 @@ fetch_user_or_raise, userdb_connection, ) -from routers.types import ( - CasualString128, - Identifier, - IntegerRange, - TagString, - integer_range_regex, -) -from schemas.datasets.openml import DatasetMetadata, DatasetStatus, Feature, FeatureType +from schemas.core import TagInfo +from schemas.datasets import DatasetFileFormat, DatasetMetadata, DatasetStatus, Feature, FeatureType if TYPE_CHECKING: from sqlalchemy.engine import Row @@ -58,6 +66,22 @@ router = APIRouter(prefix="/datasets", tags=["datasets"]) +def _format_parquet_url(dataset: UntypedRow) -> str | None: + if dataset.format.lower() != DatasetFileFormat.ARFF: + return None + + minio_base_url = get_config().routing.minio_url + ten_thousands_prefix = f"{dataset.did // 10_000:04d}" + padded_id = f"{dataset.did:04d}" + return f"{minio_base_url}datasets/{ten_thousands_prefix}/{padded_id}/dataset_{dataset.did}.pq" + + +def _format_dataset_url(dataset: UntypedRow) -> str: + base_url = get_config().routing.server_url + filename = f"{html.escape(dataset.name)}.{dataset.format.lower()}" + return f"{base_url}data/v1/download/{dataset.file_id}/{filename}" + + @router.post( path="/tag", ) @@ -67,6 +91,7 @@ async def tag_dataset( user: Annotated[User, Depends(fetch_user_or_raise)], expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[str, dict[str, Any]]: + """Add a tag to the dataset, this tag is publicly visible to all users.""" try: await database.datasets.tag(data_id, tag, user_id=user.user_id, connection=expdb_db) except ForeignKeyConstraintError: @@ -85,11 +110,6 @@ async def tag_dataset( } -class TagInfo(TypedDict): - id: str - tag: NotRequired[TagString | list[TagString]] - - @router.post(path="/untag", deprecated=True) async def untag_dataset_like_php( data_id: Annotated[Identifier, Body()], @@ -97,6 +117,7 @@ async def untag_dataset_like_php( user: Annotated[User, Depends(fetch_user_or_raise)], expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[Literal["data_untag"], TagInfo]: + """Remove a tag from the dataset with a response similar to the old PHP server.""" await untag_dataset(data_id, tag, user, expdb_db) tags = await database.datasets.get_tags_for(id_=data_id, connection=expdb_db) tag_info: TagInfo = {"id": str(data_id)} @@ -114,6 +135,7 @@ async def untag_dataset( user: Annotated[User, Depends(fetch_user_or_raise)], expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> None: + """Remove a tag that you added to a dataset, or from a dataset you uploaded.""" dataset_tag = await database.datasets.get_tag(identifier, tag, expdb_db) if not dataset_tag: try: @@ -130,6 +152,8 @@ async def untag_dataset( class DatasetStatusFilter(StrEnum): + """Legal filter values for the Dataset Status filter.""" + ACTIVE = DatasetStatus.ACTIVE DEACTIVATED = DatasetStatus.DEACTIVATED IN_PREPARATION = DatasetStatus.IN_PREPARATION @@ -182,6 +206,7 @@ async def list_datasets( # noqa: PLR0913, C901 status: Annotated[DatasetStatusFilter, Body()] = DatasetStatusFilter.ACTIVE, user: Annotated[User | None, Depends(fetch_user)] = None, ) -> list[dict[str, Any]]: + """List all datasets that match the filters.""" status_subquery = text( """ SELECT ds1.`did`, ds1.`status` @@ -308,6 +333,8 @@ async def list_datasets( # noqa: PLR0913, C901 class ProcessingInformation(NamedTuple): + """Metadata about an attempt to process a dataset.""" + date: datetime | None warning: str | None error: str | None @@ -345,7 +372,7 @@ async def _get_dataset_raise_otherwise( msg = f"No dataset with id {dataset_id} found." raise DatasetNotFoundError(msg) - if not await _user_has_access(dataset=dataset, user=user): + if not await user_has_access(dataset=dataset, user=user): msg = f"No access granted to dataset {dataset_id}." raise DatasetNoAccessError(msg) @@ -358,6 +385,7 @@ async def get_dataset_features( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], user: Annotated[User | None, Depends(fetch_user)] = None, ) -> list[Feature]: + """Return metadata for each feature (column) in the dataset.""" assert expdb is not None # noqa: S101 await _get_dataset_raise_otherwise(dataset_id, user, expdb) features, ontologies = await asyncio.gather( @@ -402,6 +430,7 @@ async def update_dataset_status( user: Annotated[User, Depends(fetch_user_or_raise)], expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[str, str | int]: + """Update the status of the dataset. Can be used to deactivate a dataset.""" dataset = await _get_dataset_raise_otherwise(dataset_id, user, expdb) can_deactivate = dataset.uploader == user.user_id or await user.is_admin() @@ -456,8 +485,7 @@ async def get_dataset( expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], user: Annotated[User | None, Depends(fetch_user)] = None, ) -> DatasetMetadata: - assert user_db is not None # noqa: S101 - assert expdb_db is not None # noqa: S101 + """Get the user-provided metadata for a dataset.""" dataset = await _get_dataset_raise_otherwise(dataset_id, user, expdb_db) if not ( dataset_file := await database.datasets.get_file( @@ -482,12 +510,12 @@ async def get_dataset( dataset_url = _format_dataset_url(dataset) parquet_url = _format_parquet_url(dataset) - contributors = _csv_as_list(dataset.contributor, unquote_items=True) - creators = _csv_as_list(dataset.creator, unquote_items=True) - ignore_attribute = _csv_as_list(dataset.ignore_attribute, unquote_items=True) - row_id_attribute = _csv_as_list(dataset.row_id_attribute, unquote_items=True) - original_data_url = _csv_as_list(dataset.original_data_url, unquote_items=True) - default_target_attribute = _csv_as_list(dataset.default_target_attribute, unquote_items=True) + contributors = csv_as_list(dataset.contributor, unquote_items=True) + creators = csv_as_list(dataset.creator, unquote_items=True) + ignore_attribute = csv_as_list(dataset.ignore_attribute, unquote_items=True) + row_id_attribute = csv_as_list(dataset.row_id_attribute, unquote_items=True) + original_data_url = csv_as_list(dataset.original_data_url, unquote_items=True) + default_target_attribute = csv_as_list(dataset.default_target_attribute, unquote_items=True) return DatasetMetadata( id=dataset.did, diff --git a/src/routers/dependencies.py b/src/routers/dependencies.py index 87ea5381..f0706ef0 100644 --- a/src/routers/dependencies.py +++ b/src/routers/dependencies.py @@ -1,3 +1,8 @@ +"""Common dependencies for router endpoints. + +See also https://fastapi.tiangolo.com/reference/dependencies/ +""" + from collections.abc import AsyncGenerator, AsyncIterator from typing import TYPE_CHECKING, Annotated @@ -7,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.errors import AuthenticationFailedError, AuthenticationRequiredError -from database.setup import expdb_database, user_database +from database.engine import expdb_database, user_database from database.users import APIKey, User if TYPE_CHECKING: diff --git a/src/routers/openml/estimation_procedure.py b/src/routers/estimation_procedure.py similarity index 68% rename from src/routers/openml/estimation_procedure.py rename to src/routers/estimation_procedure.py index d8532dcb..d7e76d2f 100644 --- a/src/routers/openml/estimation_procedure.py +++ b/src/routers/estimation_procedure.py @@ -1,10 +1,12 @@ +"""Defines endpoints relating to Estimation Procedures.""" + from typing import TYPE_CHECKING, Annotated from fastapi import APIRouter, Depends import database.evaluations from routers.dependencies import expdb_connection -from schemas.datasets.openml import EstimationProcedure +from schemas.tasks import EstimationProcedure if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection @@ -16,5 +18,9 @@ async def get_estimation_procedures( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> list[EstimationProcedure]: + """Return a list with descriptions of estimation procedures. + + Estimation procedures define how to evaluate a model, e.g., 5-repeated 2-fold cross-validation. + """ procedures = await database.evaluations.get_estimation_procedures(expdb) return list(procedures) diff --git a/src/routers/openml/evaluations.py b/src/routers/evaluations.py similarity index 57% rename from src/routers/openml/evaluations.py rename to src/routers/evaluations.py index f2891312..65cb4506 100644 --- a/src/routers/openml/evaluations.py +++ b/src/routers/evaluations.py @@ -1,3 +1,5 @@ +"""Defines endpoints relating to Evaluation Measures.""" + from typing import TYPE_CHECKING, Annotated from fastapi import APIRouter, Depends @@ -15,6 +17,14 @@ async def get_evaluation_measures( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> list[str]: + """Return a list of evaluation measure names. + + Evaluation measures are any function that is computed over either predictions + or may otherwise be provided as run metadata. Examples include "area_under_roc_curve", + "recall", or "usercpu_time_millis". This may be used for filtering or ordering evaluations. + + There are no detailed descriptions available for these measures at this time. + """ functions = await database.evaluations.get_math_functions( function_type="EvaluationFunction", connection=expdb, diff --git a/src/routers/openml/flows.py b/src/routers/flows.py similarity index 91% rename from src/routers/openml/flows.py rename to src/routers/flows.py index b3c3f219..0bb02cb7 100644 --- a/src/routers/openml/flows.py +++ b/src/routers/flows.py @@ -1,13 +1,15 @@ +"""Defines endpoints relating to Flows.""" + import asyncio from typing import TYPE_CHECKING, Annotated, Literal from fastapi import APIRouter, Depends import database.flows -from core.conversions import _str_to_num +from core.conversions import str_to_num from core.errors import FlowNotFoundError +from core.types import Identifier from routers.dependencies import expdb_connection -from routers.types import Identifier from schemas.flows import Flow, Parameter, Subflow if TYPE_CHECKING: @@ -39,6 +41,7 @@ async def get_flow( flow_id: Identifier, expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> Flow: + """Get a Flow by its identifier.""" flow = await database.flows.get(flow_id, expdb) if not flow: msg = f"Flow with id {flow_id} not found." @@ -55,7 +58,7 @@ async def get_flow( # PHP sets the default value to [], not sure where that comes from. # In the modern interface, `None` is used instead for now, but I think it might # make more sense to omit it if there is none. - default_value=_str_to_num(parameter.default_value) if parameter.default_value else None, + default_value=str_to_num(parameter.default_value) if parameter.default_value else None, data_type=parameter.data_type, description=parameter.description, ) diff --git a/src/routers/openml/__init__.py b/src/routers/openml/__init__.py deleted file mode 100644 index 5d924fcf..00000000 --- a/src/routers/openml/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Routers for the OpenML API.""" diff --git a/src/routers/openml/qualities.py b/src/routers/qualities.py similarity index 78% rename from src/routers/openml/qualities.py rename to src/routers/qualities.py index dc7a3f55..58d08e69 100644 --- a/src/routers/openml/qualities.py +++ b/src/routers/qualities.py @@ -1,20 +1,26 @@ +"""Defines endpoints relating to Qualities. + +Qualities are computed meta-features of datasets, such as the number of rows or columns. +Qualities are computed automatically by an evaluation engine, they are not provided by users. +""" + from typing import TYPE_CHECKING, Annotated, Literal from fastapi import APIRouter, Depends import database.datasets import database.qualities -from core.access import _user_has_access +from core.access import user_has_access from core.errors import ( DatasetNotFoundError, DatasetNotProcessedError, DatasetProcessingError, NoQualitiesError, ) +from core.types import Identifier from database.users import User from routers.dependencies import expdb_connection, fetch_user -from routers.types import Identifier -from schemas.datasets.openml import Quality +from schemas.datasets import Quality if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection @@ -26,6 +32,7 @@ async def list_qualities( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[Literal["data_qualities_list"], dict[Literal["quality"], list[str]]]: + """List names of all computed qualities (dataset metafeatures).""" qualities = await database.qualities.list_all_qualities(connection=expdb) return { "data_qualities_list": { @@ -40,8 +47,9 @@ async def get_qualities( user: Annotated[User | None, Depends(fetch_user)], expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> list[Quality]: + """Get computed qualities (metafeatures) for a dataset.""" dataset = await database.datasets.get(dataset_id, expdb) - if not dataset or not await _user_has_access(dataset, user): + if not dataset or not await user_has_access(dataset, user): msg = f"Dataset with id {dataset_id} not found." raise DatasetNotFoundError( msg, diff --git a/src/routers/openml/runs.py b/src/routers/runs.py similarity index 93% rename from src/routers/openml/runs.py rename to src/routers/runs.py index 3869df8d..8484b5e2 100644 --- a/src/routers/openml/runs.py +++ b/src/routers/runs.py @@ -1,4 +1,4 @@ -"""Endpoints for run-related data.""" +"""Endpoints relating to Runs and Traces.""" import asyncio from dataclasses import dataclass @@ -13,10 +13,10 @@ import database.tasks import database.users from core.errors import RunNotFoundError, RunTraceNotFoundError -from database.schema.base import UntypedRow -from database.schema.setups import Setup +from core.types import Identifier +from database.models.base import UntypedRow +from database.models.setups import Setup from routers.dependencies import expdb_connection, expdb_session, userdb_connection -from routers.types import Identifier from schemas.runs import ( EvaluationScore, InputDataset, @@ -149,11 +149,10 @@ async def get_run( userdb: Annotated[AsyncConnection, Depends(userdb_connection)], expdb_session: Annotated[AsyncSession, Depends(expdb_session)], ) -> Run: - """Get full metadata for a run by ID. - - No authentication or visibility check is performed — all runs are - publicly accessible. - """ + """Get full metadata for a run by ID.""" + # Authentication is not required because runs are always public. + # This mimics PHP API behavior, but is unintuitive for runs which have predictions + # made on private datasets. run = await database.runs.get(run_id, expdb) if run is None: msg = f"Run {run_id} not found." diff --git a/src/routers/openml/setups.py b/src/routers/setups.py similarity index 95% rename from src/routers/openml/setups.py rename to src/routers/setups.py index d7d86949..c91f3f62 100644 --- a/src/routers/openml/setups.py +++ b/src/routers/setups.py @@ -1,4 +1,4 @@ -"""All endpoints that relate to setups.""" +"""Defines endpoints relating to Setups.""" from typing import TYPE_CHECKING, Annotated @@ -12,10 +12,10 @@ TagNotFoundError, TagNotOwnedError, ) +from core.types import Identifier, TagString from database.exceptions import DuplicatePrimaryKeyError, ForeignKeyConstraintError from database.users import User from routers.dependencies import expdb_connection, expdb_session, fetch_user_or_raise -from routers.types import Identifier, TagString from schemas.setups import SetupParameters, SetupResponse if TYPE_CHECKING: @@ -54,7 +54,7 @@ async def tag_setup( user: Annotated[User, Depends(fetch_user_or_raise)], expdb_session: Annotated[AsyncSession, Depends(expdb_session)], ) -> dict[str, dict[str, str | list[str]]]: - """Add tag `tag` to setup with id `setup_id`.""" + """Add a tag to the setup, this tag is publicly visible to all users.""" try: await database.setups.tag(setup_id, tag, user.user_id, expdb_session) except ForeignKeyConstraintError: diff --git a/src/routers/openml/study.py b/src/routers/study.py similarity index 93% rename from src/routers/openml/study.py rename to src/routers/study.py index dbb65812..a6869296 100644 --- a/src/routers/openml/study.py +++ b/src/routers/study.py @@ -1,3 +1,5 @@ +"""Defines endpoints relating to studies.""" + from typing import TYPE_CHECKING, Annotated, Literal from fastapi import APIRouter, Body, Depends @@ -15,11 +17,11 @@ StudyNotFoundError, StudyPrivateError, ) -from core.formatting import _str_to_bool -from database.schema.base import UntypedRow +from core.formatting import str_to_bool +from core.types import Identifier +from database.models.base import UntypedRow from database.users import User from routers.dependencies import expdb_connection, fetch_user, fetch_user_or_raise -from routers.types import Identifier from schemas.core import Visibility from schemas.study import CreateStudy, Study, StudyStatus, StudyType @@ -51,13 +53,15 @@ async def _get_study_raise_otherwise( if study.creator != user.user_id and not await user.is_admin(): msg = "Study is private." raise StudyPrivateError(msg) - if _str_to_bool(study.legacy): + if str_to_bool(study.legacy): msg = "Legacy studies are no longer supported." raise StudyLegacyError(msg) return study class AttachDetachResponse(BaseModel): + """Response format for attaching or detaching an entity from a study.""" + study_id: int main_entity_type: StudyType @@ -69,7 +73,7 @@ async def attach_to_study( user: Annotated[User, Depends(fetch_user_or_raise)], expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> AttachDetachResponse: - assert expdb is not None # noqa: S101 + """Add runs or tasks to a study which is in preparation.""" if user is None: msg = "Authentication required." raise AuthenticationRequiredError(msg) @@ -123,7 +127,7 @@ async def create_study( user: Annotated[User, Depends(fetch_user_or_raise)], expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[Literal["study_id"], int]: - assert expdb is not None # noqa: S101 + """Create a new study.""" if study.main_entity_type == StudyType.RUN and study.tasks: msg = "Cannot create a run study with tasks." raise StudyInvalidTypeError(msg) @@ -160,11 +164,11 @@ async def get_study( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], user: Annotated[User | None, Depends(fetch_user)] = None, ) -> Study: - assert expdb is not None # noqa: S101 + """Get a study by id or alias.""" study = await _get_study_raise_otherwise(alias_or_id, user, expdb) study_data = await database.studies.get_study_data(study, expdb) return Study( - _legacy=_str_to_bool(study.legacy), + _legacy=str_to_bool(study.legacy), id_=study.id, name=study.name, alias=study.alias, diff --git a/src/routers/openml/tasks.py b/src/routers/tasks.py similarity index 97% rename from src/routers/openml/tasks.py rename to src/routers/tasks.py index 55df6197..ae01a301 100644 --- a/src/routers/openml/tasks.py +++ b/src/routers/tasks.py @@ -1,3 +1,5 @@ +"""Defines endpoints relating to Tasks.""" + import asyncio import json import re @@ -13,18 +15,18 @@ import database.tasks from config import get_config from core.errors import InternalError, NoResultsError, TagAlreadyExistsError, TaskNotFoundError -from database.exceptions import DuplicatePrimaryKeyError, ForeignKeyConstraintError -from database.schema.base import UntypedRow -from database.users import User -from routers.dependencies import Pagination, expdb_connection, expdb_session, fetch_user_or_raise -from routers.types import ( +from core.types import ( CasualString128, Identifier, IntegerRange, TagString, integer_range_regex, ) -from schemas.datasets.openml import Task +from database.exceptions import DuplicatePrimaryKeyError, ForeignKeyConstraintError +from database.models.base import UntypedRow +from database.users import User +from routers.dependencies import Pagination, expdb_connection, expdb_session, fetch_user_or_raise +from schemas.tasks import Task if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession @@ -41,6 +43,7 @@ async def tag_task( user: Annotated[User, Depends(fetch_user_or_raise)], expdb_session: Annotated[AsyncSession, Depends(expdb_session)], ) -> dict[str, dict[str, Any]]: + """Add a tag to the task, this tag is publicly visible to all users.""" try: await database.tasks.tag(task_id, tag, user_id=user.user_id, session=expdb_session) except ForeignKeyConstraintError: @@ -59,7 +62,7 @@ async def tag_task( } -def convert_template_xml_to_json(xml_template: str) -> dict[str, JSON]: +def _convert_template_xml_to_json(xml_template: str) -> dict[str, JSON]: json_template = xmltodict.parse(xml_template.replace("oml:", "")) json_str = json.dumps(json_template) # To account for the differences between PHP and Python conversions: @@ -122,7 +125,7 @@ async def fill_template( ] } """ - json_template = convert_template_xml_to_json(template) + json_template = _convert_template_xml_to_json(template) return cast( "dict[str, JSON]", await _fill_json_template( @@ -453,6 +456,7 @@ async def get_task( expdb: Annotated[AsyncConnection, Depends(expdb_connection)], expdb_session: Annotated[AsyncSession, Depends(expdb_session)], ) -> Task: + """Get a task by identifier.""" if not (task := await database.tasks.get(task_id, expdb)): msg = f"Task {task_id} not found." raise TaskNotFoundError(msg) @@ -480,7 +484,7 @@ async def get_task( for (name, _), filled in zip(input_templates, filled_templates, strict=True) ] outputs = [ - convert_template_xml_to_json(template) | {"name": name} + _convert_template_xml_to_json(template) | {"name": name} for name, io, required, template in templates if io == "output" ] diff --git a/src/routers/openml/tasktype.py b/src/routers/tasktype.py similarity index 88% rename from src/routers/openml/tasktype.py rename to src/routers/tasktype.py index 7cdba50e..a92348a9 100644 --- a/src/routers/openml/tasktype.py +++ b/src/routers/tasktype.py @@ -1,3 +1,9 @@ +"""Defines endpoints for operations relating to task types. + +A task type is a prototype for a task that defines the expected input and output for it. +Examples include Supervised Classification, Supervised Regression, or Clustering. +""" + import json from typing import TYPE_CHECKING, Annotated, Any, Literal, cast @@ -35,6 +41,7 @@ async def list_task_types( Literal["task_types"], dict[Literal["task_type"], list[dict[str, str | None | list[Any]]]], ]: + """Return a high level description of all task types.""" task_types: list[dict[str, str | None | list[Any]]] = [ _normalize_task_type(ttype) for ttype in await get_task_types(expdb) ] @@ -46,6 +53,7 @@ async def get_task_type( task_type_id: int, expdb: Annotated[AsyncConnection, Depends(expdb_connection)], ) -> dict[Literal["task_type"], dict[str, str | None | list[str] | list[dict[str, str]]]]: + """Return a detailed description for the given task type, including expected inputs.""" task_type_record = await db_get_task_type(task_type_id, expdb) if task_type_record is None: msg = f"Task type {task_type_id} not found." diff --git a/src/routers/openml/users.py b/src/routers/users.py similarity index 98% rename from src/routers/openml/users.py rename to src/routers/users.py index 43ea9bbd..14a900e7 100644 --- a/src/routers/openml/users.py +++ b/src/routers/users.py @@ -1,4 +1,4 @@ -"""User account HTTP endpoints.""" +"""Defines endpoints relating to Users.""" from http import HTTPStatus from typing import Annotated diff --git a/src/schemas/core.py b/src/schemas/core.py index 77287de5..b9bcb5e6 100644 --- a/src/schemas/core.py +++ b/src/schemas/core.py @@ -1,6 +1,16 @@ from enum import StrEnum, auto +from typing import NotRequired, TypedDict + +from core.types import TagString class Visibility(StrEnum): PUBLIC = auto() PRIVATE = auto() + + +class TagInfo(TypedDict): + """Common PHP response format for tag operations.""" + + id: str + tag: NotRequired[TagString | list[TagString]] diff --git a/src/schemas/datasets/openml.py b/src/schemas/datasets.py similarity index 82% rename from src/schemas/datasets/openml.py rename to src/schemas/datasets.py index 767cbb81..be4795e6 100644 --- a/src/schemas/datasets/openml.py +++ b/src/schemas/datasets.py @@ -1,42 +1,59 @@ +"""Defines schemas for API responses relating to Datasets.""" + from __future__ import annotations from datetime import UTC, datetime from enum import StrEnum -from typing import Any from pydantic import BaseModel, Field, HttpUrl class DatasetFileFormat(StrEnum): + """Allowed file formats for data files.""" + ARFF = "arff" SPARSE_ARFF = "sparse_arff" PARQUET = "parquet" class Visibility(StrEnum): + """Possible visibility statuses.""" + PUBLIC = "public" PRIVATE = "private" class DatasetStatus(StrEnum): + """Possible dataset statuses. + + - In Preparation: any uploaded dataset isn't yet successfully processed. + - Active: any uploaded dataset that has successfully been processed. + - Deactivated: use of the dataset is discouraged. + """ + ACTIVE = "active" DEACTIVATED = "deactivated" - IN_PROCESSING = "in processing" IN_PREPARATION = "in_preparation" class Quality(BaseModel): + """A computed quality (meta-feature) of the dataset.""" + name: str value: float | None class FeatureType(StrEnum): + """The type of a feature (column) in the dataset.""" + NUMERIC = "numeric" NOMINAL = "nominal" STRING = "string" class Feature(BaseModel): + """Metadata about a feature (column) in the dataset.""" + index: int name: str data_type: FeatureType @@ -48,18 +65,9 @@ class Feature(BaseModel): nominal_values: list[str] | None -class EstimationProcedure(BaseModel): - id_: int = Field(serialization_alias="id") - task_type_id: int - name: str - type_: str = Field(serialization_alias="type") - percentage: int | None - repeats: int | None - folds: int | None - stratified_sampling: bool | None - - class DatasetMetadata(BaseModel): + """Metadata for a dataset.""" + id_: int = Field(json_schema_extra={"example": 1}, alias="id") visibility: Visibility = Field(json_schema_extra={"example": Visibility.PUBLIC}) status: DatasetStatus = Field(json_schema_extra={"example": DatasetStatus.ACTIVE}) @@ -134,15 +142,3 @@ class DatasetMetadata(BaseModel): json_schema_extra={"example": "https://www.openml.org/d/2"}, ) md5_checksum: str = Field(json_schema_extra={"example": "d01f6ccd68c88b749b20bbe897de3713"}) - - -class Task(BaseModel): - id_: int = Field(serialization_alias="id", json_schema_extra={"example": 59}) - name: str = Field( - json_schema_extra={"example": "Task 59: mfeat-pixel (Supervised Classification)"}, - ) - task_type_id: int = Field(json_schema_extra={"example": 1}) - task_type: str = Field(json_schema_extra={"example": "Supervised Classification"}) - input_: list[dict[str, Any]] = Field(serialization_alias="input") - output: list[dict[str, Any]] - tags: list[str] = Field(default_factory=list) diff --git a/src/schemas/datasets/__init__.py b/src/schemas/datasets/__init__.py deleted file mode 100644 index c34a2d8f..00000000 --- a/src/schemas/datasets/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Dataset schemas for the OpenML server API.""" - -from enum import StrEnum - - -class DatasetSchema(StrEnum): - DCAT_AP = "dcat-ap" - OPENML = "openml" - MLDCAT_AP = "mldcat-ap" diff --git a/src/schemas/tasks.py b/src/schemas/tasks.py new file mode 100644 index 00000000..475f1a4c --- /dev/null +++ b/src/schemas/tasks.py @@ -0,0 +1,34 @@ +"""Defines schemas for API responses relating to Tasks.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from core.types import Identifier + + +class Task(BaseModel): + """Metadata for a task.""" + + id_: Identifier = Field(serialization_alias="id", json_schema_extra={"example": 59}) + name: str = Field( + json_schema_extra={"example": "Task 59: mfeat-pixel (Supervised Classification)"}, + ) + task_type_id: Identifier = Field(json_schema_extra={"example": 1}) + task_type: str = Field(json_schema_extra={"example": "Supervised Classification"}) + input_: list[dict[str, Any]] = Field(serialization_alias="input") + output: list[dict[str, Any]] + tags: list[str] = Field(default_factory=list) + + +class EstimationProcedure(BaseModel): + """Description of an evaluation protocol, e.g., cross-validation.""" + + id_: Identifier = Field(serialization_alias="id") + task_type_id: Identifier + name: str + type_: str = Field(serialization_alias="type") + percentage: int | None + repeats: int | None + folds: int | None + stratified_sampling: bool | None diff --git a/tests/conftest.py b/tests/conftest.py index 68a8e1cb..ded9c543 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,10 +21,10 @@ LoggingConfiguration, RoutingConfiguration, ) -from database.setup import expdb_database, user_database +from core.types import Identifier +from database.engine import expdb_database, user_database from main import create_api from routers.dependencies import expdb_connection, userdb_connection -from routers.types import Identifier from tests.users import OWNER_USER if TYPE_CHECKING: diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/dataset_tag_test.py similarity index 96% rename from tests/routers/openml/dataset_tag_test.py rename to tests/routers/dataset_tag_test.py index dd80966d..6c8201fa 100644 --- a/tests/routers/openml/dataset_tag_test.py +++ b/tests/routers/dataset_tag_test.py @@ -6,10 +6,10 @@ from core.errors import DatasetNotFoundError, TagAlreadyExistsError from database.datasets import get_tags_for from database.users import User -from routers.openml.datasets import tag_dataset +from routers.datasets import tag_dataset from tests import constants from tests.conftest import DatasetFactory -from tests.routers.openml.tag_test_helper import assert_tag_response_is_identical +from tests.routers.tag_test_helper import assert_tag_response_is_identical from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/dataset_untag_test.py b/tests/routers/dataset_untag_test.py similarity index 99% rename from tests/routers/openml/dataset_untag_test.py rename to tests/routers/dataset_untag_test.py index cfaaf249..7eca8893 100644 --- a/tests/routers/openml/dataset_untag_test.py +++ b/tests/routers/dataset_untag_test.py @@ -20,7 +20,7 @@ from sqlalchemy import text from core.errors import DatasetNotFoundError, TagNotFoundError, TagNotOwnedError -from routers.openml.datasets import untag_dataset +from routers.datasets import untag_dataset from tests.users import ADMIN_USER, SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/datasets_features_test.py b/tests/routers/datasets_features_test.py similarity index 99% rename from tests/routers/openml/datasets_features_test.py rename to tests/routers/datasets_features_test.py index 36166596..f4e56d2c 100644 --- a/tests/routers/openml/datasets_features_test.py +++ b/tests/routers/datasets_features_test.py @@ -9,7 +9,7 @@ from core.errors import DatasetNoAccessError, DatasetNotFoundError, DatasetProcessingError from database.users import User -from routers.openml.datasets import get_dataset_features +from routers.datasets import get_dataset_features from tests.users import ADMIN_USER, DATASET_130_OWNER if TYPE_CHECKING: diff --git a/tests/routers/openml/datasets_get_test.py b/tests/routers/datasets_get_test.py similarity index 98% rename from tests/routers/openml/datasets_get_test.py rename to tests/routers/datasets_get_test.py index 3ef034eb..cf38c0dc 100644 --- a/tests/routers/openml/datasets_get_test.py +++ b/tests/routers/datasets_get_test.py @@ -12,8 +12,8 @@ import tests.constants from core.errors import DatasetNoAccessError, DatasetNotFoundError from database.users import User -from routers.openml.datasets import get_dataset -from schemas.datasets.openml import DatasetMetadata +from routers.datasets import get_dataset +from schemas.datasets import DatasetMetadata from tests.users import ADMIN_USER, DATASET_130_OWNER, NO_USER, SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/datasets_list_datasets_test.py b/tests/routers/datasets_list_datasets_test.py similarity index 99% rename from tests/routers/openml/datasets_list_datasets_test.py rename to tests/routers/datasets_list_datasets_test.py index 868e9ee6..89ec474f 100644 --- a/tests/routers/openml/datasets_list_datasets_test.py +++ b/tests/routers/datasets_list_datasets_test.py @@ -10,8 +10,8 @@ from core.errors import NoResultsError from database.users import User +from routers.datasets import DatasetStatusFilter, list_datasets from routers.dependencies import LIMIT_DEFAULT, Pagination -from routers.openml.datasets import DatasetStatusFilter, list_datasets from tests import constants from tests.users import ADMIN_USER, DATASET_130_OWNER, SOME_USER, ApiKey diff --git a/tests/routers/openml/datasets_qualities_test.py b/tests/routers/datasets_qualities_test.py similarity index 100% rename from tests/routers/openml/datasets_qualities_test.py rename to tests/routers/datasets_qualities_test.py diff --git a/tests/routers/openml/datasets_status_test.py b/tests/routers/datasets_status_test.py similarity index 96% rename from tests/routers/openml/datasets_status_test.py rename to tests/routers/datasets_status_test.py index adc5892e..581465a2 100644 --- a/tests/routers/openml/datasets_status_test.py +++ b/tests/routers/datasets_status_test.py @@ -6,8 +6,8 @@ import pytest from core.errors import DatasetAdminOnlyError, DatasetNotOwnedError -from routers.openml.datasets import update_dataset_status -from schemas.datasets.openml import DatasetStatus +from routers.datasets import update_dataset_status +from schemas.datasets import DatasetStatus from tests import constants from tests.users import ADMIN_USER, SOME_USER diff --git a/tests/routers/openml/estimation_procedure_test.py b/tests/routers/estimation_procedure_test.py similarity index 100% rename from tests/routers/openml/estimation_procedure_test.py rename to tests/routers/estimation_procedure_test.py diff --git a/tests/routers/openml/evaluation_measure_test.py b/tests/routers/evaluation_measure_test.py similarity index 100% rename from tests/routers/openml/evaluation_measure_test.py rename to tests/routers/evaluation_measure_test.py diff --git a/tests/routers/openml/flows_exists_test.py b/tests/routers/flows_exists_test.py similarity index 98% rename from tests/routers/openml/flows_exists_test.py rename to tests/routers/flows_exists_test.py index 988402f3..c5889412 100644 --- a/tests/routers/openml/flows_exists_test.py +++ b/tests/routers/flows_exists_test.py @@ -6,7 +6,7 @@ import pytest from core.errors import FlowNotFoundError -from routers.openml.flows import flow_exists +from routers.flows import flow_exists from tests.conftest import Flow if TYPE_CHECKING: diff --git a/tests/routers/openml/flows_get_test.py b/tests/routers/flows_get_test.py similarity index 100% rename from tests/routers/openml/flows_get_test.py rename to tests/routers/flows_get_test.py diff --git a/tests/routers/openml/__init__.py b/tests/routers/openml/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/routers/openml/qualities_list_test.py b/tests/routers/qualities_list_test.py similarity index 100% rename from tests/routers/openml/qualities_list_test.py rename to tests/routers/qualities_list_test.py diff --git a/tests/routers/openml/runs_get_test.py b/tests/routers/runs_get_test.py similarity index 97% rename from tests/routers/openml/runs_get_test.py rename to tests/routers/runs_get_test.py index af841f65..f9d897c5 100644 --- a/tests/routers/openml/runs_get_test.py +++ b/tests/routers/runs_get_test.py @@ -10,7 +10,7 @@ import pytest from core.conversions import nested_num_to_str, nested_remove_single_element_list -from routers.openml.runs import _build_evaluations +from routers.runs import _build_evaluations # ── Fixtures assume run 24 exists in the test DB (confirmed in research) ── _RUN_ID = 24 @@ -149,7 +149,7 @@ class MockRunRow(NamedTuple): error_message="Some error from the backend", ) - with patch("routers.openml.runs.database.runs.get", new_callable=AsyncMock) as mock_get: + with patch("routers.runs.database.runs.get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_row response = await py_api.get(f"/run/{_RUN_ID}") assert response.status_code == HTTPStatus.OK @@ -181,7 +181,7 @@ async def test_task_evaluation_measure_present_when_configured( """task_evaluation_measure is present and matches DB when a measure is configured.""" # Since the test database does not have a run with an evaluation measure, we mock the DB fetch with patch( - "routers.openml.runs.database.tasks.get_task_evaluation_measure", new_callable=AsyncMock + "routers.runs.database.tasks.get_task_evaluation_measure", new_callable=AsyncMock ) as mock_get_measure: mock_get_measure.return_value = "predictive_accuracy" response = await py_api.get(f"/run/{_RUN_ID}") diff --git a/tests/routers/openml/runs_trace_test.py b/tests/routers/runs_trace_test.py similarity index 100% rename from tests/routers/openml/runs_trace_test.py rename to tests/routers/runs_trace_test.py diff --git a/tests/routers/openml/setups_get_test.py b/tests/routers/setups_get_test.py similarity index 100% rename from tests/routers/openml/setups_get_test.py rename to tests/routers/setups_get_test.py diff --git a/tests/routers/openml/setups_tag_test.py b/tests/routers/setups_tag_test.py similarity index 99% rename from tests/routers/openml/setups_tag_test.py rename to tests/routers/setups_tag_test.py index 60488c24..49bad891 100644 --- a/tests/routers/openml/setups_tag_test.py +++ b/tests/routers/setups_tag_test.py @@ -9,7 +9,7 @@ from sqlalchemy import text from core.errors import SetupNotFoundError, TagAlreadyExistsError -from routers.openml.setups import tag_setup +from routers.setups import tag_setup from tests.users import SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/setups_untag_test.py b/tests/routers/setups_untag_test.py similarity index 99% rename from tests/routers/openml/setups_untag_test.py rename to tests/routers/setups_untag_test.py index c4424f16..1259f1e0 100644 --- a/tests/routers/openml/setups_untag_test.py +++ b/tests/routers/setups_untag_test.py @@ -9,7 +9,7 @@ from sqlalchemy import text from core.errors import SetupNotFoundError, TagNotFoundError, TagNotOwnedError -from routers.openml.setups import untag_setup +from routers.setups import untag_setup from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/study_attach_test.py b/tests/routers/study_attach_test.py similarity index 100% rename from tests/routers/openml/study_attach_test.py rename to tests/routers/study_attach_test.py diff --git a/tests/routers/openml/study_get_test.py b/tests/routers/study_get_test.py similarity index 100% rename from tests/routers/openml/study_get_test.py rename to tests/routers/study_get_test.py diff --git a/tests/routers/openml/study_post_test.py b/tests/routers/study_post_test.py similarity index 100% rename from tests/routers/openml/study_post_test.py rename to tests/routers/study_post_test.py diff --git a/tests/routers/openml/tag_test_helper.py b/tests/routers/tag_test_helper.py similarity index 100% rename from tests/routers/openml/tag_test_helper.py rename to tests/routers/tag_test_helper.py diff --git a/tests/routers/openml/task_get_test.py b/tests/routers/task_get_test.py similarity index 100% rename from tests/routers/openml/task_get_test.py rename to tests/routers/task_get_test.py diff --git a/tests/routers/openml/task_list_test.py b/tests/routers/task_list_test.py similarity index 99% rename from tests/routers/openml/task_list_test.py rename to tests/routers/task_list_test.py index 7a3f562f..01443e10 100644 --- a/tests/routers/openml/task_list_test.py +++ b/tests/routers/task_list_test.py @@ -8,7 +8,7 @@ from core.conversions import nested_remove_single_element_list from core.errors import NoResultsError from routers.dependencies import LIMIT_MAX, Pagination -from routers.openml.tasks import TaskStatusFilter, list_tasks +from routers.tasks import TaskStatusFilter, list_tasks if TYPE_CHECKING: import httpx diff --git a/tests/routers/openml/task_tag_test.py b/tests/routers/task_tag_test.py similarity index 96% rename from tests/routers/openml/task_tag_test.py rename to tests/routers/task_tag_test.py index f6e04b10..8ad52b60 100644 --- a/tests/routers/openml/task_tag_test.py +++ b/tests/routers/task_tag_test.py @@ -6,10 +6,10 @@ from core.errors import TASK_NOT_FOUND_DURING_TAG, TagAlreadyExistsError, TaskNotFoundError from database.tasks import get_tags from database.users import User -from routers.openml.tasks import tag_task +from routers.tasks import tag_task from tests import constants from tests.conftest import TaskFactory -from tests.routers.openml.tag_test_helper import assert_tag_response_is_identical +from tests.routers.tag_test_helper import assert_tag_response_is_identical from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey if TYPE_CHECKING: diff --git a/tests/routers/openml/task_type_get_test.py b/tests/routers/task_type_get_test.py similarity index 100% rename from tests/routers/openml/task_type_get_test.py rename to tests/routers/task_type_get_test.py diff --git a/tests/routers/openml/task_type_list_test.py b/tests/routers/task_type_list_test.py similarity index 100% rename from tests/routers/openml/task_type_list_test.py rename to tests/routers/task_type_list_test.py diff --git a/tests/routers/openml/users_delete_test.py b/tests/routers/users_delete_test.py similarity index 97% rename from tests/routers/openml/users_delete_test.py rename to tests/routers/users_delete_test.py index 127e89e7..c872c747 100644 --- a/tests/routers/openml/users_delete_test.py +++ b/tests/routers/users_delete_test.py @@ -13,7 +13,7 @@ from core.errors import AccountHasResourcesError, ForbiddenError, UserNotFoundError from database.users import UserGroup -from routers.openml.users import delete_user_account +from routers.users import delete_user_account from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey @@ -69,7 +69,7 @@ async def test_delete_user_api_success_self_delete( disposable_user: DisposableUser, mocker: pytest_mock.MockerFixture, ) -> None: - log_info = mocker.patch("routers.openml.users.logger.info") + log_info = mocker.patch("routers.users.logger.info") response = await py_api.delete( f"/users/{disposable_user.user_id}", @@ -185,7 +185,7 @@ async def test_delete_user_integrity_error_logs_and_raises_conflict( "DELETE FROM users", {"user_id": disposable_user.user_id}, Exception("fk") ), ) - log_error = mocker.patch("routers.openml.users.logger.error") + log_error = mocker.patch("routers.users.logger.error") with pytest.raises(AccountHasResourcesError, match="Cannot delete this account") as exc_info: await delete_user_account( diff --git a/tests/types_test.py b/tests/types_test.py index 7a4df612..2ded54c1 100644 --- a/tests/types_test.py +++ b/tests/types_test.py @@ -11,7 +11,7 @@ import pytest from pydantic import TypeAdapter, ValidationError -from routers.types import CasualString, Identifier, TagString +from core.types import CasualString, Identifier, TagString _identifier = TypeAdapter(Identifier)