diff --git a/.gitignore b/.gitignore index d512c0ee65..82bf40915d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,8 @@ tests/files/org/openml/test/datasets/1/ tests/files/org/openml/test/datasets/2/features.xml.pkl tests/files/org/openml/test/datasets/2/qualities.xml.pkl tests/files/org/openml/test/locks/ -tests/files/org/openml/test/tasks/1/datasplits.pkl.py3 -tests/files/org/openml/test/tasks/1882/datasplits.pkl.py3 +tests/files/org/openml/test/api_splits/get/1/Task_1_splits.arff/body.pkl.py3 +tests/files/org/openml/test/api_splits/get/1882/Task_1882_splits.arff/body.pkl.py3 # Distribution / packaging diff --git a/openml/_api/resources/base/resources.py b/openml/_api/resources/base/resources.py index 45b3f910b5..bb499434b8 100644 --- a/openml/_api/resources/base/resources.py +++ b/openml/_api/resources/base/resources.py @@ -17,11 +17,11 @@ from openml.datasets.data_feature import OpenMLDataFeature from openml.datasets.dataset import OpenMLDataset from openml.estimation_procedures import OpenMLEstimationProcedure - from openml.evaluations import OpenMLEvaluation + from openml.evaluations.evaluation import OpenMLEvaluation from openml.flows.flow import OpenMLFlow from openml.runs.run import OpenMLRun from openml.setups.setup import OpenMLSetup - from openml.tasks.task import TaskType + from openml.tasks.task import OpenMLTask, TaskType class DatasetAPI(ResourceAPI): @@ -146,6 +146,51 @@ class TaskAPI(ResourceAPI): resource_type: ResourceType = ResourceType.TASK + @abstractmethod + def get( + self, + task_id: int, + ) -> OpenMLTask: + """ + API v1: + GET /task/{task_id} + + API v2: + GET /tasks/{task_id} + """ + ... + + @abstractmethod + def download_split(self, url: str, *, refresh_cache: bool = False) -> Path: + """Download a task's split file and return its path in the cache.""" + ... + + # Task listing (V1 only) + @abstractmethod + def list( + self, + limit: int, + offset: int, + task_type: TaskType | int | None = None, + *, + estimation_procedures: builtins.list[dict[str, Any]], + **kwargs: Any, + ) -> pd.DataFrame: + """ + List tasks with filters. + + API v1: + GET /task/list + + API v2: + Not available. + + Returns + ------- + pandas.DataFrame + """ + ... + class EvaluationMeasureAPI(ResourceAPI): """Abstract API interface for evaluation measure resources.""" diff --git a/openml/_api/resources/task.py b/openml/_api/resources/task.py index 1f62aa3f30..7a3372564d 100644 --- a/openml/_api/resources/task.py +++ b/openml/_api/resources/task.py @@ -1,11 +1,396 @@ from __future__ import annotations +import warnings +from typing import TYPE_CHECKING, Any + +import pandas as pd +import xmltodict + +from openml.tasks.task import ( + OpenMLClassificationTask, + OpenMLClusteringTask, + OpenMLLearningCurveTask, + OpenMLRegressionTask, + OpenMLTask, + TaskType, +) + from .base import ResourceV1API, ResourceV2API, TaskAPI +if TYPE_CHECKING: + import builtins + from pathlib import Path + class TaskV1API(ResourceV1API, TaskAPI): - """Version 1 API implementation for task resources.""" + def get(self, task_id: int) -> OpenMLTask: + """Download OpenML task for a given task ID. + + Downloads the task representation. + + Parameters + ---------- + task_id : int + The OpenML task id of the task to download. + + Returns + ------- + task: OpenMLTask + """ + response = self._http.get(f"task/{task_id}", enable_cache=True) + return self._create_task_from_xml(response.text) + + def download_split(self, url: str, *, refresh_cache: bool = False) -> Path: + """Download a task's split file and return its path in the cache. + + Parameters + ---------- + url : str + URL of the split file. + refresh_cache : bool, optional + Whether to replace the file if it is already cached. + + Returns + ------- + Path + """ + self._http.get(url, enable_cache=True, refresh_cache=refresh_cache) + return self._http.cache_path_from_url(url) + + def list( + self, + limit: int, + offset: int, + task_type: TaskType | int | None = None, + *, + estimation_procedures: builtins.list[dict[str, Any]], + **kwargs: Any, + ) -> pd.DataFrame: + """ + Perform the api call to return a number of tasks having the given filters. + + Parameters + ---------- + Filter task_type is separated from the other filters because + it is used as task_type in the task description, but it is named + type when used as a filter in list tasks call. + limit: int + offset: int + task_type : TaskType, optional + Refers to the type of task. + estimation_procedures : list of dict + All estimation procedures on OpenML, used to resolve the procedure + ids returned by the listing into names. + kwargs: dict, optional + Legal filter operators: tag, task_id (list), data_tag, status, limit, + offset, data_id, data_name, number_instances, number_features, + number_classes, number_missing_values. + + Returns + ------- + dataframe + """ + api_call = self._build_url(limit, offset, task_type, kwargs) + return self._parse_list_xml(api_call, estimation_procedures) + + def _parse_list_xml( # noqa: C901, PLR0912 + self, + api_call: str, + estimation_procedures: builtins.list[dict[str, Any]], + ) -> pd.DataFrame: + """Returns a Pandas DataFrame with information about OpenML tasks. + + Parameters + ---------- + api_call : str + The API call specifying which tasks to return. + estimation_procedures : list of dict + All estimation procedures on OpenML. + + Returns + ------- + A Pandas DataFrame with information about OpenML tasks. + + Raises + ------ + ValueError + If the XML returned by the OpenML API does not contain 'oml:tasks', '@xmlns:oml', + or has an incorrect value for '@xmlns:oml'. + KeyError + If an invalid key is found in the XML for a task. + """ + xml_string = self._http.get(api_call).text + + tasks_dict = xmltodict.parse(xml_string, force_list=("oml:task", "oml:input")) + # Minimalistic check if the XML is useful + if "oml:tasks" not in tasks_dict: + raise ValueError(f'Error in return XML, does not contain "oml:tasks": {tasks_dict}') + + if "@xmlns:oml" not in tasks_dict["oml:tasks"]: + raise ValueError( + f'Error in return XML, does not contain "oml:tasks"/@xmlns:oml: {tasks_dict}' + ) + + if tasks_dict["oml:tasks"]["@xmlns:oml"] != "http://openml.org/openml": + raise ValueError( + "Error in return XML, value of " + '"oml:tasks"/@xmlns:oml is not ' + f'"http://openml.org/openml": {tasks_dict!s}', + ) + + assert isinstance(tasks_dict["oml:tasks"]["oml:task"], list), type(tasks_dict["oml:tasks"]) + + tasks = {} + proc_dict = {x["id"]: x for x in estimation_procedures} + + for task_ in tasks_dict["oml:tasks"]["oml:task"]: + tid = None + try: + tid = int(task_["oml:task_id"]) + task_type_int = int(task_["oml:task_type_id"]) + try: + task_type_id = TaskType(task_type_int) + except ValueError as e: + warnings.warn( + f"Could not create task type id for {task_type_int} due to error {e}", + RuntimeWarning, + stacklevel=2, + ) + continue + + task = { + "tid": tid, + "ttid": task_type_id, + "did": int(task_["oml:did"]), + "name": task_["oml:name"], + "task_type": task_["oml:task_type"], + "status": task_["oml:status"], + } + + # Other task inputs + for _input in task_.get("oml:input", []): + if _input["@name"] == "estimation_procedure": + task[_input["@name"]] = proc_dict[int(_input["#text"])]["name"] + else: + value = _input.get("#text") + task[_input["@name"]] = value + + # The number of qualities can range from 0 to infinity + for quality in task_.get("oml:quality", []): + if "#text" not in quality: + quality_value = 0.0 + else: + quality["#text"] = float(quality["#text"]) + if abs(int(quality["#text"]) - quality["#text"]) < 0.0000001: + quality["#text"] = int(quality["#text"]) + quality_value = quality["#text"] + task[quality["@name"]] = quality_value + tasks[tid] = task + except KeyError as e: + if tid is not None: + warnings.warn( + f"Invalid xml for task {tid}: {e}\nFrom {task_}", + RuntimeWarning, + stacklevel=2, + ) + else: + warnings.warn( + f"Could not find key {e} in {task_}!", RuntimeWarning, stacklevel=2 + ) + + return pd.DataFrame.from_dict(tasks, orient="index") + + @staticmethod + def _create_task_from_xml(xml: str) -> OpenMLTask: + """Create a task given a xml string. + + Parameters + ---------- + xml : string + Task xml representation. + + Returns + ------- + OpenMLTask + """ + dic = xmltodict.parse(xml)["oml:task"] + estimation_parameters = {} + inputs = {} + # Due to the unordered structure we obtain, we first have to extract + # the possible keys of oml:input; dic["oml:input"] is a list of + # OrderedDicts + + # Check if there is a list of inputs + if isinstance(dic["oml:input"], list): + for input_ in dic["oml:input"]: + name = input_["@name"] + inputs[name] = input_ + # Single input case + elif isinstance(dic["oml:input"], dict): + name = dic["oml:input"]["@name"] + inputs[name] = dic["oml:input"] + + evaluation_measures = None + if "evaluation_measures" in inputs: + evaluation_measures = inputs["evaluation_measures"]["oml:evaluation_measures"][ + "oml:evaluation_measure" + ] + + task_type = TaskType(int(dic["oml:task_type_id"])) + common_kwargs = { + "task_id": dic["oml:task_id"], + "task_type": dic["oml:task_type"], + "task_type_id": task_type, + "data_set_id": inputs["source_data"]["oml:data_set"]["oml:data_set_id"], + "evaluation_measure": evaluation_measures, + } + # TODO: add OpenMLClusteringTask? + if task_type in ( + TaskType.SUPERVISED_CLASSIFICATION, + TaskType.SUPERVISED_REGRESSION, + TaskType.LEARNING_CURVE, + ): + # Convert some more parameters + for parameter in inputs["estimation_procedure"]["oml:estimation_procedure"][ + "oml:parameter" + ]: + name = parameter["@name"] + text = parameter.get("#text", "") + estimation_parameters[name] = text + + common_kwargs["estimation_procedure_type"] = inputs["estimation_procedure"][ + "oml:estimation_procedure" + ]["oml:type"] + common_kwargs["estimation_procedure_id"] = int( + inputs["estimation_procedure"]["oml:estimation_procedure"]["oml:id"] + ) + + common_kwargs["estimation_parameters"] = estimation_parameters + common_kwargs["target_name"] = inputs["source_data"]["oml:data_set"][ + "oml:target_feature" + ] + common_kwargs["data_splits_url"] = inputs["estimation_procedure"][ + "oml:estimation_procedure" + ]["oml:data_splits_url"] + + cls = { + TaskType.SUPERVISED_CLASSIFICATION: OpenMLClassificationTask, + TaskType.SUPERVISED_REGRESSION: OpenMLRegressionTask, + TaskType.CLUSTERING: OpenMLClusteringTask, + TaskType.LEARNING_CURVE: OpenMLLearningCurveTask, + }.get(task_type) + if cls is None: + raise NotImplementedError(f"Task type {common_kwargs['task_type']} not supported.") + return cls(**common_kwargs) # type: ignore + + @staticmethod + def _build_url( + limit: int, offset: int, task_type: TaskType | int | None, kwargs: dict[str, Any] + ) -> str: + api_call = "task/list" + if limit is not None: + api_call += f"/limit/{limit}" + if offset is not None: + api_call += f"/offset/{offset}" + if task_type is not None: + tvalue = task_type.value if isinstance(task_type, TaskType) else task_type + api_call += f"/type/{tvalue}" + if kwargs is not None: + for operator, value in kwargs.items(): + if value is not None: + if operator == "task_id": + value = ",".join([str(int(i)) for i in value]) # noqa: PLW2901 + api_call += f"/{operator}/{value}" + return api_call class TaskV2API(ResourceV2API, TaskAPI): - """Version 2 API implementation for task resources.""" + def get(self, task_id: int) -> OpenMLTask: + """Download OpenML task for a given task ID. + + Downloads the task representation. + + Parameters + ---------- + task_id : int + The OpenML task id of the task to download. + + Returns + ------- + task: OpenMLTask + """ + response = self._http.get(f"tasks/{task_id}", enable_cache=True) + return self._create_task_from_json(response.json()) + + def list( + self, + limit: int, # noqa: ARG002 + offset: int, # noqa: ARG002 + task_type: TaskType | int | None = None, # noqa: ARG002 + *, + estimation_procedures: builtins.list[dict[str, Any]], # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> pd.DataFrame: + raise self._not_supported(method="list") + + def download_split(self, url: str, *, refresh_cache: bool = False) -> Path: + """Download a task's split file and return its path in the cache. + + Parameters + ---------- + url : str + URL of the split file. + refresh_cache : bool, optional + Whether to replace the file if it is already cached. + + Returns + ------- + Path + """ + self._http.get(url, enable_cache=True, refresh_cache=refresh_cache) + return self._http.cache_path_from_url(url) + + @staticmethod + def _create_task_from_json(task_json: dict) -> OpenMLTask: + task_type_id = TaskType(int(task_json["task_type_id"])) + + inputs = {i["name"]: i for i in task_json.get("input", [])} + + source = inputs["source_data"]["data_set"] + evaluation_measures = inputs.get("evaluation_measures", {}).get("evaluation_measures", {}) + + common_kwargs = { + "task_id": int(task_json["id"]), + "task_type": task_json["task_type"], + "task_type_id": task_type_id, + "data_set_id": int(source["data_set_id"]), + "evaluation_measure": evaluation_measures.get("evaluation_measure") or None, + } + + if task_type_id in ( + TaskType.SUPERVISED_CLASSIFICATION, + TaskType.SUPERVISED_REGRESSION, + TaskType.LEARNING_CURVE, + ): + est = inputs.get("estimation_procedure", {}).get("estimation_procedure") + + if est: + common_kwargs["estimation_procedure_id"] = int(est["id"]) + common_kwargs["estimation_procedure_type"] = est["type"] + common_kwargs["estimation_parameters"] = { + p["name"]: "" if p.get("value") in (None, []) else str(p["value"]) + for p in est.get("parameter", []) + } + common_kwargs["data_splits_url"] = est["data_splits_url"] + + common_kwargs["target_name"] = source.get("target_feature") + + cls = { + TaskType.SUPERVISED_CLASSIFICATION: OpenMLClassificationTask, + TaskType.SUPERVISED_REGRESSION: OpenMLRegressionTask, + TaskType.CLUSTERING: OpenMLClusteringTask, + TaskType.LEARNING_CURVE: OpenMLLearningCurveTask, + }.get(task_type_id) + if cls is None: + raise NotImplementedError(f"Task type {common_kwargs['task_type']} not supported.") + return cls(**common_kwargs) # type: ignore diff --git a/openml/tasks/functions.py b/openml/tasks/functions.py index 0b60b9d43d..b1609fb556 100644 --- a/openml/tasks/functions.py +++ b/openml/tasks/functions.py @@ -1,19 +1,14 @@ # License: BSD 3-Clause from __future__ import annotations -import os -import re import warnings from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any import pandas as pd -import xmltodict -import openml._api_calls import openml.utils from openml.datasets import get_dataset -from openml.exceptions import OpenMLCacheException from .task import ( OpenMLClassificationTask, @@ -21,53 +16,13 @@ OpenMLLearningCurveTask, OpenMLRegressionTask, OpenMLSupervisedTask, - OpenMLTask, TaskType, ) -TASKS_CACHE_DIR_NAME = "tasks" - - -def _get_cached_tasks() -> dict[int, OpenMLTask]: - """Return a dict of all the tasks which are cached locally. - - Returns - ------- - tasks : OrderedDict - A dict of all the cached tasks. Each task is an instance of - OpenMLTask. - """ - task_cache_dir = openml.utils._create_cache_directory(TASKS_CACHE_DIR_NAME) - directory_content = os.listdir(task_cache_dir) # noqa: PTH208, RUF100 - directory_content.sort() - - # Find all dataset ids for which we have downloaded the dataset - # description - tids = (int(did) for did in directory_content if re.match(r"[0-9]*", did)) - return {tid: _get_cached_task(tid) for tid in tids} - - -def _get_cached_task(tid: int) -> OpenMLTask: - """Return a cached task based on the given id. - - Parameters - ---------- - tid : int - Id of the task. - - Returns - ------- - OpenMLTask - """ - tid_cache_dir = openml.utils._create_cache_directory_for_id(TASKS_CACHE_DIR_NAME, tid) - - task_xml_path = tid_cache_dir / "task.xml" - try: - with task_xml_path.open(encoding="utf8") as fh: - return _create_task_from_xml(fh.read()) - except OSError as e: - openml.utils._remove_cache_dir_for_id(TASKS_CACHE_DIR_NAME, tid_cache_dir) - raise OpenMLCacheException(f"Task file for tid {tid} not cached") from e +if TYPE_CHECKING: + from .task import ( + OpenMLTask, + ) def _get_estimation_procedure_list() -> list[dict[str, Any]]: @@ -133,8 +88,9 @@ def list_tasks( # noqa: PLR0913,PLR0917 calculated for the associated dataset, some of these are also returned. """ listing_call = partial( - _list_tasks, + openml._backend.task.list, task_type=task_type, + estimation_procedures=_get_estimation_procedure_list(), tag=tag, data_tag=data_tag, status=status, @@ -152,151 +108,6 @@ def list_tasks( # noqa: PLR0913,PLR0917 return pd.concat(batches) -def _list_tasks( - limit: int, - offset: int, - task_type: TaskType | int | None = None, - **kwargs: Any, -) -> pd.DataFrame: - """ - Perform the api call to return a number of tasks having the given filters. - - Parameters - ---------- - Filter task_type is separated from the other filters because - it is used as task_type in the task description, but it is named - type when used as a filter in list tasks call. - limit: int - offset: int - task_type : TaskType, optional - Refers to the type of task. - kwargs: dict, optional - Legal filter operators: tag, task_id (list), data_tag, status, limit, - offset, data_id, data_name, number_instances, number_features, - number_classes, number_missing_values. - - Returns - ------- - dataframe - """ - api_call = "task/list" - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - if task_type is not None: - tvalue = task_type.value if isinstance(task_type, TaskType) else task_type - api_call += f"/type/{tvalue}" - if kwargs is not None: - for operator, value in kwargs.items(): - if value is not None: - if operator == "task_id": - value = ",".join([str(int(i)) for i in value]) # noqa: PLW2901 - api_call += f"/{operator}/{value}" - - return __list_tasks(api_call=api_call) - - -def __list_tasks(api_call: str) -> pd.DataFrame: # noqa: C901, PLR0912 - """Returns a Pandas DataFrame with information about OpenML tasks. - - Parameters - ---------- - api_call : str - The API call specifying which tasks to return. - - Returns - ------- - A Pandas DataFrame with information about OpenML tasks. - - Raises - ------ - ValueError - If the XML returned by the OpenML API does not contain 'oml:tasks', '@xmlns:oml', - or has an incorrect value for '@xmlns:oml'. - KeyError - If an invalid key is found in the XML for a task. - """ - xml_string = openml._api_calls._perform_api_call(api_call, "get") - tasks_dict = xmltodict.parse(xml_string, force_list=("oml:task", "oml:input")) - # Minimalistic check if the XML is useful - if "oml:tasks" not in tasks_dict: - raise ValueError(f'Error in return XML, does not contain "oml:runs": {tasks_dict}') - - if "@xmlns:oml" not in tasks_dict["oml:tasks"]: - raise ValueError( - f'Error in return XML, does not contain "oml:runs"/@xmlns:oml: {tasks_dict}' - ) - - if tasks_dict["oml:tasks"]["@xmlns:oml"] != "http://openml.org/openml": - raise ValueError( - "Error in return XML, value of " - '"oml:runs"/@xmlns:oml is not ' - f'"http://openml.org/openml": {tasks_dict!s}', - ) - - assert isinstance(tasks_dict["oml:tasks"]["oml:task"], list), type(tasks_dict["oml:tasks"]) - - tasks = {} - procs = _get_estimation_procedure_list() - proc_dict = {x["id"]: x for x in procs} - - for task_ in tasks_dict["oml:tasks"]["oml:task"]: - tid = None - try: - tid = int(task_["oml:task_id"]) - task_type_int = int(task_["oml:task_type_id"]) - try: - task_type_id = TaskType(task_type_int) - except ValueError as e: - warnings.warn( - f"Could not create task type id for {task_type_int} due to error {e}", - RuntimeWarning, - stacklevel=2, - ) - continue - - task = { - "tid": tid, - "ttid": task_type_id, - "did": int(task_["oml:did"]), - "name": task_["oml:name"], - "task_type": task_["oml:task_type"], - "status": task_["oml:status"], - } - - # Other task inputs - for _input in task_.get("oml:input", []): - if _input["@name"] == "estimation_procedure": - task[_input["@name"]] = proc_dict[int(_input["#text"])]["name"] - else: - value = _input.get("#text") - task[_input["@name"]] = value - - # The number of qualities can range from 0 to infinity - for quality in task_.get("oml:quality", []): - if "#text" not in quality: - quality_value = 0.0 - else: - quality["#text"] = float(quality["#text"]) - if abs(int(quality["#text"]) - quality["#text"]) < 0.0000001: - quality["#text"] = int(quality["#text"]) - quality_value = quality["#text"] - task[quality["@name"]] = quality_value - tasks[tid] = task - except KeyError as e: - if tid is not None: - warnings.warn( - f"Invalid xml for task {tid}: {e}\nFrom {task_}", - RuntimeWarning, - stacklevel=2, - ) - else: - warnings.warn(f"Could not find key {e} in {task_}!", RuntimeWarning, stacklevel=2) - - return pd.DataFrame.from_dict(tasks, orient="index") - - def get_tasks( task_ids: list[int], download_data: bool | None = None, @@ -304,7 +115,7 @@ def get_tasks( ) -> list[OpenMLTask]: """Download tasks. - This function iterates :meth:`openml.tasks.get_task`. + This function iterates :meth:`openml.task.get`. Parameters ---------- @@ -383,133 +194,21 @@ def get_task( if not isinstance(task_id, int): raise TypeError(f"Task id should be integer, is {type(task_id)}") - task_cache_directory = openml.utils._create_cache_directory_for_id( - TASKS_CACHE_DIR_NAME, task_id - ) - task_cache_directory_existed = task_cache_directory.exists() - try: - task = _get_task_description(task_id) - dataset = get_dataset(task.dataset_id, **get_dataset_kwargs) - # List of class labels available in dataset description - # Including class labels as part of task meta data handles - # the case where data download was initially disabled - if isinstance(task, (OpenMLClassificationTask, OpenMLLearningCurveTask)): - assert task.target_name is not None, ( - "Supervised tasks must define a target feature before retrieving class labels." - ) - task.class_labels = dataset.retrieve_class_labels(task.target_name) - # Clustering tasks do not have class labels - # and do not offer download_split - if download_splits and isinstance(task, OpenMLSupervisedTask): - task.download_split() - except Exception as e: - if not task_cache_directory_existed: - openml.utils._remove_cache_dir_for_id(TASKS_CACHE_DIR_NAME, task_cache_directory) - raise e - - return task + task = openml._backend.task.get(task_id) + dataset = get_dataset(task.dataset_id, **get_dataset_kwargs) - -def _get_task_description(task_id: int) -> OpenMLTask: - try: - return _get_cached_task(task_id) - except OpenMLCacheException: - _cache_dir = openml.utils._create_cache_directory_for_id(TASKS_CACHE_DIR_NAME, task_id) - xml_file = _cache_dir / "task.xml" - task_xml = openml._api_calls._perform_api_call(f"task/{task_id}", "get") - - with xml_file.open("w", encoding="utf8") as fh: - fh.write(task_xml) - return _create_task_from_xml(task_xml) - - -def _create_task_from_xml(xml: str) -> OpenMLTask: - """Create a task given a xml string. - - Parameters - ---------- - xml : string - Task xml representation. - - Returns - ------- - OpenMLTask - """ - dic = xmltodict.parse(xml)["oml:task"] - estimation_parameters = {} - inputs = {} - # Due to the unordered structure we obtain, we first have to extract - # the possible keys of oml:input; dic["oml:input"] is a list of - # OrderedDicts - - # Check if there is a list of inputs - if isinstance(dic["oml:input"], list): - for input_ in dic["oml:input"]: - name = input_["@name"] - inputs[name] = input_ - # Single input case - elif isinstance(dic["oml:input"], dict): - name = dic["oml:input"]["@name"] - inputs[name] = dic["oml:input"] - - evaluation_measures = None - if "evaluation_measures" in inputs: - evaluation_measures = inputs["evaluation_measures"]["oml:evaluation_measures"][ - "oml:evaluation_measure" - ] - - task_type = TaskType(int(dic["oml:task_type_id"])) - common_kwargs = { - "task_id": dic["oml:task_id"], - "task_type": dic["oml:task_type"], - "task_type_id": task_type, - "data_set_id": inputs["source_data"]["oml:data_set"]["oml:data_set_id"], - "evaluation_measure": evaluation_measures, - } - # TODO: add OpenMLClusteringTask? - if task_type in ( - TaskType.SUPERVISED_CLASSIFICATION, - TaskType.SUPERVISED_REGRESSION, - TaskType.LEARNING_CURVE, + if ( + isinstance(task, (OpenMLClassificationTask, OpenMLLearningCurveTask)) + and task.target_name is not None ): - # Convert some more parameters - for parameter in inputs["estimation_procedure"]["oml:estimation_procedure"][ - "oml:parameter" - ]: - name = parameter["@name"] - text = parameter.get("#text", "") - estimation_parameters[name] = text - - common_kwargs["estimation_procedure_type"] = inputs["estimation_procedure"][ - "oml:estimation_procedure" - ]["oml:type"] - common_kwargs["estimation_procedure_id"] = int( - inputs["estimation_procedure"]["oml:estimation_procedure"]["oml:id"] - ) + task.class_labels = dataset.retrieve_class_labels(task.target_name) - common_kwargs["estimation_parameters"] = estimation_parameters - common_kwargs["target_name"] = inputs["source_data"]["oml:data_set"]["oml:target_feature"] - common_kwargs["data_splits_url"] = inputs["estimation_procedure"][ - "oml:estimation_procedure" - ]["oml:data_splits_url"] - - cls = { - TaskType.SUPERVISED_CLASSIFICATION: OpenMLClassificationTask, - TaskType.SUPERVISED_REGRESSION: OpenMLRegressionTask, - TaskType.CLUSTERING: OpenMLClusteringTask, - TaskType.LEARNING_CURVE: OpenMLLearningCurveTask, - }.get(task_type) - if cls is None: - raise NotImplementedError( - f"Task type '{common_kwargs['task_type']}' is not supported. " - f"Supported task types: SUPERVISED_CLASSIFICATION," - f"SUPERVISED_REGRESSION, CLUSTERING, LEARNING_CURVE." - f"Please check the OpenML documentation for available task types." - ) - return cls(**common_kwargs) # type: ignore + if download_splits and isinstance(task, OpenMLSupervisedTask): + task.download_split() + + return task -# TODO(eddiebergman): overload on `task_type` def create_task( task_type: TaskType, dataset_id: int, @@ -597,4 +296,4 @@ def delete_task(task_id: int) -> bool: bool True if the deletion was successful. False otherwise. """ - return openml.utils._delete_entity("task", task_id) + return openml._backend.task.delete(task_id) diff --git a/openml/tasks/task.py b/openml/tasks/task.py index 2bccd1af99..6875c21b99 100644 --- a/openml/tasks/task.py +++ b/openml/tasks/task.py @@ -10,10 +10,9 @@ import arff -import openml._api_calls +import openml from openml import datasets from openml.base import OpenMLBase -from openml.utils import _create_cache_directory_for_id from .split import OpenMLSplit @@ -187,31 +186,15 @@ def download_split(self) -> OpenMLSplit: """Download the OpenML split for a given task.""" # TODO(eddiebergman): Can this every be `None`? assert self.task_id is not None - cache_dir = _create_cache_directory_for_id("tasks", self.task_id) - cached_split_file = cache_dir / "datasplits.arff" + split_url = str(self.estimation_procedure["data_splits_url"]) + split_file = openml._backend.task.download_split(split_url) try: - split = OpenMLSplit._from_arff_file(cached_split_file) - logger.debug("Loaded file from cache: %s", str(cached_split_file)) - except (OSError, arff.BadDataFormat): - logger.info("Failed to load file from cache: %s", str(cached_split_file)) - if cached_split_file.exists(): - logger.debug("Cleaning up old file") - cached_split_file.unlink() - # Next, download and cache the associated split file - split_url = self.estimation_procedure["data_splits_url"] - openml._api_calls._download_text_file( - source=str(split_url), - output_path=str(cached_split_file), - ) - if cached_split_file.exists(): - logger.info("New file created of size %d", cached_split_file.stat().st_size) - else: - logger.info("Failed to create new file") - - split = OpenMLSplit._from_arff_file(cached_split_file) - - return split + return OpenMLSplit._from_arff_file(split_file) + except arff.BadDataFormat: + logger.info("Failed to parse cached split file: %s", str(split_file)) + split_file = openml._backend.task.download_split(split_url, refresh_cache=True) + return OpenMLSplit._from_arff_file(split_file) def get_split_dimensions(self) -> tuple[int, int, int]: """Get the (repeats, folds, samples) of the split for a given task.""" @@ -242,6 +225,48 @@ def _parse_publish_response(self, xml_response: dict) -> None: """Parse the id from the xml_response and assign it to self.""" self.task_id = int(xml_response["oml:upload_task"]["oml:id"]) + def publish(self) -> OpenMLTask: + """Publish this task to OpenML server. + + Returns + ------- + self : OpenMLTask + """ + file_elements = self._get_file_elements() + if "description" not in file_elements: + file_elements["description"] = self._to_xml() + task_id = openml._backend.task.publish(path="task", files=file_elements) + self.task_id = task_id + return self + + def push_tag(self, tag: str) -> None: + """Annotates this task with a tag on the server. + + Parameters + ---------- + tag : str + Tag to attach to the task. + """ + if self.task_id is None: + raise openml.exceptions.ObjectNotPublishedError( + "Please publish the task first before being able to tag it." + ) + openml._backend.task.tag(self.task_id, tag) + + def remove_tag(self, tag: str) -> None: + """Removes a tag from this task on the server. + + Parameters + ---------- + tag : str + Tag to remove from the task. + """ + if self.task_id is None: + raise openml.exceptions.ObjectNotPublishedError( + "Please publish the task first before being able to untag it." + ) + openml._backend.task.untag(self.task_id, tag) + class OpenMLSupervisedTask(OpenMLTask, ABC): """OpenML Supervised Classification object. diff --git a/pyproject.toml b/pyproject.toml index 02b31ef2a9..460ae37e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -359,6 +359,7 @@ module = [ "tests.test_api.test_run", "tests.test_api.test_setup", "tests.test_api.test_study", + "tests.test_api.test_task", "tests.test_api.test_versions", "tests.conftest", "tests.test_datasets.test_dataset", diff --git a/tests/conftest.py b/tests/conftest.py index 8f98422c7e..c96cbf0915 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -202,17 +202,6 @@ def _expected_static_cache_state(root_dir: Path) -> list[Path]: _c_root_dir = root_dir / "org" / "openml" / "test" res_paths = [root_dir, _c_root_dir] - res_paths.append(_c_root_dir / "tasks") - - for _id in ["1", "3", "1882"]: - tmp_p = _c_root_dir / "tasks" / _id - res_paths.extend( - [ - tmp_p / "datasplits.arff", - tmp_p / "task.xml", - ] - ) - res_paths.extend( [ _c_root_dir / "api" / "v1" / "xml" / "setup", @@ -239,6 +228,17 @@ def _expected_static_cache_state(root_dir: Path) -> list[Path]: ] ) + res_paths.extend( + [ + _c_root_dir / "api_splits" / "get" / "1882" / "Task_1882_splits.arff" / "body.arff", + _c_root_dir / "api_splits" / "get" / "3" / "Task_3_splits.arff" / "body.arff", + _c_root_dir / "api_splits" / "get" / "1" / "Task_1_splits.arff" / "body.arff", + _c_root_dir / "api" / "v1" / "xml" / "task" / "1882" / "body.xml", + _c_root_dir / "api" / "v1" / "xml" / "task" / "3" / "body.xml", + _c_root_dir / "api" / "v1" / "xml" / "task" / "1" / "body.xml", + ] + ) + return res_paths diff --git a/tests/files/org/openml/test/tasks/1/task.xml b/tests/files/org/openml/test/api/v1/xml/task/1/body.xml similarity index 100% rename from tests/files/org/openml/test/tasks/1/task.xml rename to tests/files/org/openml/test/api/v1/xml/task/1/body.xml diff --git a/tests/files/org/openml/test/tasks/1882/task.xml b/tests/files/org/openml/test/api/v1/xml/task/1882/body.xml similarity index 100% rename from tests/files/org/openml/test/tasks/1882/task.xml rename to tests/files/org/openml/test/api/v1/xml/task/1882/body.xml diff --git a/tests/files/org/openml/test/tasks/3/task.xml b/tests/files/org/openml/test/api/v1/xml/task/3/body.xml similarity index 100% rename from tests/files/org/openml/test/tasks/3/task.xml rename to tests/files/org/openml/test/api/v1/xml/task/3/body.xml diff --git a/tests/files/org/openml/test/tasks/1/datasplits.arff b/tests/files/org/openml/test/api_splits/get/1/Task_1_splits.arff/body.arff similarity index 100% rename from tests/files/org/openml/test/tasks/1/datasplits.arff rename to tests/files/org/openml/test/api_splits/get/1/Task_1_splits.arff/body.arff diff --git a/tests/files/org/openml/test/tasks/1882/datasplits.arff b/tests/files/org/openml/test/api_splits/get/1882/Task_1882_splits.arff/body.arff similarity index 100% rename from tests/files/org/openml/test/tasks/1882/datasplits.arff rename to tests/files/org/openml/test/api_splits/get/1882/Task_1882_splits.arff/body.arff diff --git a/tests/files/org/openml/test/tasks/3/datasplits.arff b/tests/files/org/openml/test/api_splits/get/3/Task_3_splits.arff/body.arff similarity index 100% rename from tests/files/org/openml/test/tasks/3/datasplits.arff rename to tests/files/org/openml/test/api_splits/get/3/Task_3_splits.arff/body.arff diff --git a/tests/test_api/test_task.py b/tests/test_api/test_task.py new file mode 100644 index 0000000000..4d30e27403 --- /dev/null +++ b/tests/test_api/test_task.py @@ -0,0 +1,276 @@ +# License: BSD 3-Clause +from __future__ import annotations + +from unittest.mock import patch + +import pandas as pd +import pytest +from requests import Response, Session + +import openml +from openml._api import TaskV1API, TaskV2API +from openml.exceptions import OpenMLNotSupportedError +from openml.tasks import OpenMLClassificationTask, OpenMLSplit, TaskType +from openml.tasks.functions import _get_estimation_procedure_list + +CLASSIFICATION_TASK_ID = 1 +REGRESSION_TASK_ID = 613 +LEARNING_CURVE_TASK_ID = 763 +DATASTREAM_TASK_ID = 967 + + +@pytest.fixture +def task_v1(http_client_v1, minio_client) -> TaskV1API: + return TaskV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def task_v2(http_client_v2, minio_client) -> TaskV2API: + return TaskV2API(http=http_client_v2, minio=minio_client) + + +def _assert_task_1(task): + assert isinstance(task, OpenMLClassificationTask) + assert task.task_id == CLASSIFICATION_TASK_ID + assert task.task_type_id == TaskType.SUPERVISED_CLASSIFICATION + assert task.dataset_id == 1 + assert task.target_name == "class" + assert task.estimation_procedure_id == 1 + assert task.estimation_procedure["type"] == "crossvalidation" + assert task.estimation_procedure["parameters"]["number_folds"] == "10" + assert task.estimation_procedure["data_splits_url"].endswith("Task_1_splits.arff") + + +@pytest.mark.test_server +def test_v1_get(task_v1): + _assert_task_1(task_v1.get(CLASSIFICATION_TASK_ID)) + + +@pytest.mark.test_server +def test_v1_list(task_v1): + tasks_df = task_v1.list( + limit=5, + offset=0, + task_type=TaskType.SUPERVISED_REGRESSION, + estimation_procedures=_get_estimation_procedure_list(), + ) + assert isinstance(tasks_df, pd.DataFrame) + assert len(tasks_df) == 5 + assert (tasks_df["ttid"] == TaskType.SUPERVISED_REGRESSION).all() + names = {procedure["name"] for procedure in _get_estimation_procedure_list()} + assert set(tasks_df["estimation_procedure"]) <= names + + +@pytest.mark.test_server +def test_v1_download_split(task_v1): + url = task_v1.get(CLASSIFICATION_TASK_ID).estimation_procedure["data_splits_url"] + path = task_v1.download_split(url) + assert path.parts[-4:] == ("get", "1", "Task_1_splits.arff", "body.arff") + assert isinstance(OpenMLSplit._from_arff_file(path), OpenMLSplit) + + +@pytest.mark.test_server +def test_v1_download_split_refresh_cache(task_v1): + url = task_v1.get(CLASSIFICATION_TASK_ID).estimation_procedure["data_splits_url"] + path = task_v1.download_split(url) + original = path.read_bytes() + path.write_bytes(b"@relation stale") + + assert task_v1.download_split(url).read_bytes() == b"@relation stale" + assert task_v1.download_split(url, refresh_cache=True).read_bytes() == original + + +@pytest.mark.test_server +def test_v2_get(task_v2): + _assert_task_1(task_v2.get(CLASSIFICATION_TASK_ID)) + + +def test_v2_list(task_v2): + with pytest.raises(OpenMLNotSupportedError): + task_v2.list(limit=5, offset=0, estimation_procedures=[]) + + +@pytest.mark.test_server +def test_v2_download_split(task_v2): + url = task_v2.get(CLASSIFICATION_TASK_ID).estimation_procedure["data_splits_url"] + path = task_v2.download_split(url) + assert path.parts[-4:] == ("get", "1", "Task_1_splits.arff", "body.arff") + assert isinstance(OpenMLSplit._from_arff_file(path), OpenMLSplit) + + +@pytest.mark.test_server +def test_v2_evaluation_measure(task_v2): + task_json = task_v2._http.get(f"tasks/{CLASSIFICATION_TASK_ID}").json() + inputs = {i["name"]: i for i in task_json["input"]} + measures = inputs["evaluation_measures"]["evaluation_measures"] + assert measures["evaluation_measure"] == [] + assert task_v2._create_task_from_json(task_json).evaluation_measure is None + + measures["evaluation_measure"] = "predictive_accuracy" + task = task_v2._create_task_from_json(task_json) + assert task.evaluation_measure == "predictive_accuracy" + + +@pytest.mark.test_server +@pytest.mark.parametrize( + "task_id", + [CLASSIFICATION_TASK_ID, REGRESSION_TASK_ID, LEARNING_CURVE_TASK_ID], +) +def test_get_matches(task_v1, task_v2, task_id): + assert vars(task_v1.get(task_id)) == vars(task_v2.get(task_id)) + + +@pytest.mark.test_server +def test_get_unsupported_task_type_matches(task_v1, task_v2): + match = "Supervised Data Stream Classification not supported" + with pytest.raises(NotImplementedError, match=match): + task_v1.get(DATASTREAM_TASK_ID) + with pytest.raises(NotImplementedError, match=match): + task_v2.get(DATASTREAM_TASK_ID) + + +@pytest.mark.test_server +def test_download_split_matches(task_v1, task_v2): + url_v1 = task_v1.get(CLASSIFICATION_TASK_ID).estimation_procedure["data_splits_url"] + url_v2 = task_v2.get(CLASSIFICATION_TASK_ID).estimation_procedure["data_splits_url"] + assert url_v1 == url_v2 + assert task_v1.download_split(url_v1) == task_v2.download_split(url_v2) + + +def test_v1_publish(task_v1): + resource_name = task_v1.resource_type.value + resource_files = {"description": "Resource Description File"} + resource_id = 123 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f"\t{resource_id}\n" + f"\n" + ).encode() + + published_resource_id = task_v1.publish( + resource_name, + files=resource_files, + ) + + assert resource_id == published_resource_id + + mock_request.assert_called_once_with( + method="POST", + url=openml.config.server + resource_name, + params={}, + data={"api_key": openml.config.apikey}, + headers=openml.config._HEADERS, + files=resource_files, + ) + + +def test_v1_delete(task_v1): + resource_name = task_v1.resource_type.value + resource_id = 123 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f" {resource_id}\n" + f"\n" + ).encode() + + task_v1.delete(resource_id) + + mock_request.assert_called_once_with( + method="DELETE", + url=(openml.config.server + resource_name + "/" + str(resource_id)), + params={"api_key": openml.config.apikey}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_tag(task_v1): + resource_id = 123 + resource_tag = "TAG" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{resource_id}" + f"{resource_tag}" + f"" + ).encode() + + tags = task_v1.tag(resource_id, resource_tag) + + assert resource_tag in tags + + mock_request.assert_called_once_with( + method="POST", + url=(openml.config.server + task_v1.resource_type.value + "/tag"), + params={}, + data={ + "api_key": openml.config.apikey, + "task_id": resource_id, + "tag": resource_tag, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_untag(task_v1): + resource_id = 123 + resource_tag = "TAG" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{resource_id}" + f"" + ).encode() + + tags = task_v1.untag(resource_id, resource_tag) + + assert resource_tag not in tags + + mock_request.assert_called_once_with( + method="POST", + url=(openml.config.server + task_v1.resource_type.value + "/untag"), + params={}, + data={ + "api_key": openml.config.apikey, + "task_id": resource_id, + "tag": resource_tag, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v2_publish(task_v2): + with pytest.raises(OpenMLNotSupportedError): + task_v2.publish(path=None, files=None) + + +def test_v2_delete(task_v2): + with pytest.raises(OpenMLNotSupportedError): + task_v2.delete(resource_id=None) + + +def test_v2_tag(task_v2): + with pytest.raises(OpenMLNotSupportedError): + task_v2.tag(resource_id=None, tag=None) + + +def test_v2_untag(task_v2): + with pytest.raises(OpenMLNotSupportedError): + task_v2.untag(resource_id=None, tag=None) diff --git a/tests/test_tasks/test_split.py b/tests/test_tasks/test_split.py index 373b479cdc..e2ac15cc05 100644 --- a/tests/test_tasks/test_split.py +++ b/tests/test_tasks/test_split.py @@ -28,9 +28,11 @@ def setUp(self): / "org" / "openml" / "test" - / "tasks" + / "api_splits" + / "get" / "1882" - / "datasplits.arff" + / "Task_1882_splits.arff" + / "body.arff" ) # Use a unique temp directory for each test to avoid race conditions # when running tests in parallel (see issue #1641) diff --git a/tests/test_tasks/test_task_functions.py b/tests/test_tasks/test_task_functions.py index 639ad58cde..62be9f376a 100644 --- a/tests/test_tasks/test_task_functions.py +++ b/tests/test_tasks/test_task_functions.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from pathlib import Path from unittest import mock import pytest @@ -9,11 +10,25 @@ import openml from openml import OpenMLSplit, OpenMLTask -from openml.exceptions import OpenMLCacheException, OpenMLNotAuthorizedError, OpenMLServerException +from openml.exceptions import ( + OpenMLNotAuthorizedError, + OpenMLServerException, +) from openml.tasks import TaskType from openml.testing import TestBase, create_request_response +def _cached_split_path(task_id: int) -> Path: + return Path( + openml.config.get_cache_directory(), + "api_splits", + "get", + str(task_id), + f"Task_{task_id}_splits.arff", + "body.arff", + ) + + class TestTask(TestBase): _multiprocess_can_split_ = True @@ -23,25 +38,6 @@ def setUp(self): def tearDown(self): super().tearDown() - @pytest.mark.test_server - def test__get_cached_tasks(self): - openml.config.set_root_cache_directory(self.static_cache_dir) - tasks = openml.tasks.functions._get_cached_tasks() - assert isinstance(tasks, dict) - assert len(tasks) == 3 - assert isinstance(next(iter(tasks.values())), OpenMLTask) - - @pytest.mark.test_server - def test__get_cached_task(self): - openml.config.set_root_cache_directory(self.static_cache_dir) - task = openml.tasks.functions._get_cached_task(1) - assert isinstance(task, OpenMLTask) - - def test__get_cached_task_not_cached(self): - openml.config.set_root_cache_directory(self.static_cache_dir) - with pytest.raises(OpenMLCacheException, match="Task file for tid 2 not cached"): - openml.tasks.functions._get_cached_task(2) - @pytest.mark.test_server def test__get_estimation_procedure_list(self): estimation_procedures = openml.tasks.functions._get_estimation_procedure_list() @@ -134,7 +130,9 @@ def test_list_tasks_per_type_paginate(self): @pytest.mark.test_server def test__get_task(self): openml.config.set_root_cache_directory(self.static_cache_dir) - openml.tasks.get_task(1882) + with mock.patch("requests.sessions.Session.request") as mock_request: + openml.tasks.get_task(1882) + mock_request.assert_not_called() @pytest.mark.skip( reason="Please await outcome of discussion: https://github.com/openml/OpenML/issues/776", @@ -151,12 +149,6 @@ def test_get_task(self): task = openml.tasks.get_task(1, download_data=True) # anneal; crossvalidation dataset = openml.datasets.get_dataset(task.dataset_id) assert isinstance(task, OpenMLTask) - assert os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "1", "task.xml") # noqa: PTH118 - ) - assert not os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "1", "datasplits.arff") # noqa: PTH118 - ) assert os.path.exists( # noqa: PTH110 openml.config.get_minio_download_path(dataset._parquet_url) ) @@ -164,48 +156,29 @@ def test_get_task(self): @pytest.mark.test_server def test_get_task_lazy(self): task = openml.tasks.get_task(2, download_data=False) # anneal; crossvalidation + assert isinstance(task, OpenMLTask) - assert os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "2", "task.xml") # noqa: PTH118 - ) assert task.class_labels == ["1", "2", "3", "4", "5", "U"] - assert not os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "2", "datasplits.arff") # noqa: PTH118 - ) # Since the download_data=False is propagated to get_dataset assert not os.path.exists( # noqa: PTH110 os.path.join(openml.config.get_cache_directory(), "datasets", "2", "dataset.arff") # noqa: PTH118 ) + assert not _cached_split_path(2).exists() task.download_split() - assert os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "2", "datasplits.arff") # noqa: PTH118 - ) - - @mock.patch("openml.tasks.functions.get_dataset") - @pytest.mark.test_server - def test_removal_upon_download_failure(self, get_dataset): - class WeirdException(Exception): # noqa: N818 - pass - - def assert_and_raise(*args, **kwargs): # noqa: ARG001 - # Make sure that the file was created! - assert os.path.join(os.getcwd(), "tasks", "1", "tasks.xml") # noqa: PTH109, PTH118 - raise WeirdException() - - get_dataset.side_effect = assert_and_raise - try: # noqa: SIM105 - openml.tasks.get_task(1) # anneal; crossvalidation - except WeirdException: - pass - # Now the file should no longer exist - assert not os.path.exists(os.path.join(os.getcwd(), "tasks", "1", "tasks.xml")) # noqa: PTH109, PTH110, PTH118 + assert _cached_split_path(2).exists() @pytest.mark.test_server def test_get_task_with_cache(self): openml.config.set_root_cache_directory(self.static_cache_dir) - task = openml.tasks.get_task(1) + with ( + mock.patch("openml.tasks.functions.get_dataset"), + mock.patch("requests.sessions.Session.request") as mock_request, + ): + task = openml.tasks.get_task(1) + mock_request.assert_not_called() + assert isinstance(task, OpenMLTask) @pytest.mark.production_server @@ -223,29 +196,41 @@ def test_download_split(self): task = openml.tasks.get_task(1) # anneal; crossvalidation split = task.download_split() assert type(split) == OpenMLSplit # noqa: E721 - assert os.path.exists( # noqa: PTH110 - os.path.join(openml.config.get_cache_directory(), "tasks", "1", "datasplits.arff") # noqa: PTH118 - ) + assert _cached_split_path(1).exists() - def test_deletion_of_cache_dir(self): - # Simple removal - tid_cache_dir = openml.utils._create_cache_directory_for_id( - "tasks", - 1, + @pytest.mark.test_server + def test_download_split_replaces_corrupted_file(self): + task = openml.tasks.get_task(1) # anneal; crossvalidation + expected = task.download_split() + + split_file = _cached_split_path(1) + split_file.with_suffix(".pkl.py3").unlink() + split_file.write_text( + "@relation corrupted\n" + "@attribute type {TRAIN,TEST}\n" + "@attribute rowid INTEGER\n" + "@attribute repeat INTEGER\n" + "@attribute fold INTEGER\n" + "@data\n" + "TRAIN,1\n" ) - assert os.path.exists(tid_cache_dir) # noqa: PTH110 - openml.utils._remove_cache_dir_for_id("tasks", tid_cache_dir) - assert not os.path.exists(tid_cache_dir) # noqa: PTH110 + assert task.download_split() == expected + assert "corrupted" not in split_file.read_text() -@mock.patch.object(requests.Session, "delete") -def test_delete_task_not_owned(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): + @pytest.mark.test_server + def test_get_task_with_download_splits(self): + openml.tasks.get_task(1, download_splits=True) + assert _cached_split_path(1).exists() + + +@mock.patch.object(requests.Session, "request") +def test_delete_task_not_owned(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_owned.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) - with pytest.raises( OpenMLNotAuthorizedError, match=r"The task can not be deleted because it was not uploaded by you.", @@ -253,14 +238,14 @@ def test_delete_task_not_owned(mock_delete, test_files_directory, test_server_v1 openml.tasks.delete_task(1) task_url = test_server_v1 + "task/1" - assert task_url == mock_delete.call_args.args[0] - assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert task_url == mock_request.call_args.kwargs.get("url") + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_task_with_run(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): +@mock.patch.object(requests.Session, "request") +def test_delete_task_with_run(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_has_runs.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -272,14 +257,14 @@ def test_delete_task_with_run(mock_delete, test_files_directory, test_server_v1, openml.tasks.delete_task(3496) task_url = test_server_v1 + "task/3496" - assert task_url == mock_delete.call_args.args[0] - assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert task_url == mock_request.call_args.kwargs.get("url") + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_success(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): +@mock.patch.object(requests.Session, "request") +def test_delete_success(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_successful.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=200, content_filepath=content_file, ) @@ -288,14 +273,14 @@ def test_delete_success(mock_delete, test_files_directory, test_server_v1, test_ assert success task_url = test_server_v1 + "task/361323" - assert task_url == mock_delete.call_args.args[0] - assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert task_url == mock_request.call_args.kwargs.get("url") + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_unknown_task(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): +@mock.patch.object(requests.Session, "request") +def test_delete_unknown_task(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_exist.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -307,5 +292,5 @@ def test_delete_unknown_task(mock_delete, test_files_directory, test_server_v1, openml.tasks.delete_task(9_999_999) task_url = test_server_v1 + "task/9999999" - assert task_url == mock_delete.call_args.args[0] - assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert task_url == mock_request.call_args.kwargs.get("url") + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") diff --git a/tests/test_tasks/test_task_methods.py b/tests/test_tasks/test_task_methods.py index 53e0a5e95d..218401c598 100644 --- a/tests/test_tasks/test_task_methods.py +++ b/tests/test_tasks/test_task_methods.py @@ -2,6 +2,7 @@ from __future__ import annotations from time import time +from unittest import mock import pytest @@ -36,8 +37,10 @@ def test_tagging(self): @pytest.mark.test_server def test_get_train_and_test_split_indices(self): openml.config.set_root_cache_directory(self.static_cache_dir) - task = openml.tasks.get_task(1882) - train_indices, test_indices = task.get_train_test_split_indices(0, 0) + with mock.patch("requests.sessions.Session.request") as mock_request: + task = openml.tasks.get_task(1882) + train_indices, test_indices = task.get_train_test_split_indices(0, 0) + mock_request.assert_not_called() assert train_indices[0] == 16 assert train_indices[-1] == 395 assert test_indices[0] == 412 diff --git a/tests/test_utils/test_utils.py b/tests/test_utils/test_utils.py index b252e98d22..ec3a7a0b7e 100644 --- a/tests/test_utils/test_utils.py +++ b/tests/test_utils/test_utils.py @@ -2,6 +2,7 @@ import os import unittest.mock +from functools import partial import pytest import requests @@ -48,7 +49,7 @@ def min_number_evaluations_on_test_server() -> int: @pytest.mark.test_server def test_list_all(): - openml.utils._list_all(listing_call=openml.tasks.functions._list_tasks) + openml.utils._list_all(listing_call=openml.tasks.functions.list_tasks) @pytest.mark.test_server @@ -63,7 +64,10 @@ def test_list_all_with_multiple_batches(min_number_tasks_on_test_server): # batches and at the same time do as few batches (roundtrips) as possible. batch_size = min_number_tasks_on_test_server - 1 batches = openml.utils._list_all( - listing_call=openml.tasks.functions._list_tasks, + listing_call=partial( + openml._backend.task.list, + estimation_procedures=openml.tasks.functions._get_estimation_procedure_list(), + ), batch_size=batch_size, ) assert len(batches) >= 2