diff --git a/.gitignore b/.gitignore index c06e715ef..060db33be 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ target/ # IDE .idea *.swp +.vscode # MYPY .mypy_cache diff --git a/doc/api.rst b/doc/api.rst index 86bfd121e..288bf66fb 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -38,6 +38,7 @@ Dataset Functions attributes_arff_from_df check_datasets_active create_dataset + delete_dataset get_dataset get_datasets list_datasets @@ -103,6 +104,7 @@ Flow Functions :template: function.rst assert_flows_equal + delete_flow flow_exists get_flow list_flows @@ -133,6 +135,7 @@ Run Functions :toctree: generated/ :template: function.rst + delete_run get_run get_runs get_run_trace @@ -251,6 +254,7 @@ Task Functions :template: function.rst create_task + delete_task get_task get_tasks list_tasks diff --git a/doc/progress.rst b/doc/progress.rst index 48dc2a1a3..d981c09c0 100644 --- a/doc/progress.rst +++ b/doc/progress.rst @@ -10,10 +10,10 @@ Changelog ~~~~~~ * Add new contributions here. - * ADD#1144: Add locally computed results to the ``OpenMLRun`` object's representation. + * ADD#1028: Add functions to delete runs, flows, datasets, and tasks (e.g., ``openml.datasets.delete_dataset``). + * ADD#1144: Add locally computed results to the ``OpenMLRun`` object's representation if the run was created locally and not downloaded from the server. * FIX #1197 #559 #1131: Fix the order of ground truth and predictions in the ``OpenMLRun`` object and in ``format_prediction``. * FIX #1198: Support numpy 1.24 and higher. - * ADD#1144: Add locally computed results to the ``OpenMLRun`` object's representation if the run was created locally and not downloaded from the server. 0.13.0 ~~~~~~ diff --git a/openml/_api_calls.py b/openml/_api_calls.py index 5140a3470..f7b2a34c5 100644 --- a/openml/_api_calls.py +++ b/openml/_api_calls.py @@ -351,10 +351,12 @@ def _send_request(request_method, url, data, files=None, md5_checksum=None): xml.parsers.expat.ExpatError, OpenMLHashException, ) as e: - if isinstance(e, OpenMLServerException): - if e.code not in [107]: - # 107: database connection error - raise + if isinstance(e, OpenMLServerException) and e.code != 107: + # Propagate all server errors to the calling functions, except + # for 107 which represents a database connection error. + # These are typically caused by high server load, + # which means trying again might resolve the issue. + raise elif isinstance(e, xml.parsers.expat.ExpatError): if request_method != "get" or retry_counter >= n_retries: raise OpenMLServerError( diff --git a/openml/datasets/__init__.py b/openml/datasets/__init__.py index abde85c06..efa5a5d5b 100644 --- a/openml/datasets/__init__.py +++ b/openml/datasets/__init__.py @@ -11,6 +11,7 @@ list_qualities, edit_dataset, fork_dataset, + delete_dataset, ) from .dataset import OpenMLDataset from .data_feature import OpenMLDataFeature @@ -28,4 +29,5 @@ "list_qualities", "edit_dataset", "fork_dataset", + "delete_dataset", ] diff --git a/openml/datasets/functions.py b/openml/datasets/functions.py index 770413a23..4307c8008 100644 --- a/openml/datasets/functions.py +++ b/openml/datasets/functions.py @@ -1271,3 +1271,22 @@ def _get_online_dataset_format(dataset_id): dataset_xml = openml._api_calls._perform_api_call("data/%d" % dataset_id, "get") # build a dict from the xml and get the format from the dataset description return xmltodict.parse(dataset_xml)["oml:data_set_description"]["oml:format"].lower() + + +def delete_dataset(dataset_id: int) -> bool: + """Delete dataset with id `dataset_id` from the OpenML server. + + This can only be done if you are the owner of the dataset and + no tasks are attached to the dataset. + + Parameters + ---------- + dataset_id : int + OpenML id of the dataset + + Returns + ------- + bool + True if the deletion was successful. False otherwise. + """ + return openml.utils._delete_entity("data", dataset_id) diff --git a/openml/exceptions.py b/openml/exceptions.py index a5f132128..fe2138e76 100644 --- a/openml/exceptions.py +++ b/openml/exceptions.py @@ -11,15 +11,14 @@ class OpenMLServerError(PyOpenMLError): """class for when something is really wrong on the server (result did not parse to dict), contains unparsed error.""" - def __init__(self, message: str): - super().__init__(message) + pass class OpenMLServerException(OpenMLServerError): """exception for when the result of the server was not 200 (e.g., listing call w/o results).""" - # Code needs to be optional to allow the exceptino to be picklable: + # Code needs to be optional to allow the exception to be picklable: # https://stackoverflow.com/questions/16244923/how-to-make-a-custom-exception-class-with-multiple-init-args-pickleable # noqa: E501 def __init__(self, message: str, code: int = None, url: str = None): self.message = message @@ -28,15 +27,11 @@ def __init__(self, message: str, code: int = None, url: str = None): super().__init__(message) def __str__(self): - return "%s returned code %s: %s" % ( - self.url, - self.code, - self.message, - ) + return f"{self.url} returned code {self.code}: {self.message}" class OpenMLServerNoResult(OpenMLServerException): - """exception for when the result of the server is empty.""" + """Exception for when the result of the server is empty.""" pass @@ -44,8 +39,7 @@ class OpenMLServerNoResult(OpenMLServerException): class OpenMLCacheException(PyOpenMLError): """Dataset / task etc not found in cache""" - def __init__(self, message: str): - super().__init__(message) + pass class OpenMLHashException(PyOpenMLError): @@ -57,8 +51,7 @@ class OpenMLHashException(PyOpenMLError): class OpenMLPrivateDatasetError(PyOpenMLError): """Exception thrown when the user has no rights to access the dataset.""" - def __init__(self, message: str): - super().__init__(message) + pass class OpenMLRunsExistError(PyOpenMLError): @@ -69,3 +62,9 @@ def __init__(self, run_ids: set, message: str): raise ValueError("Set of run ids must be non-empty.") self.run_ids = run_ids super().__init__(message) + + +class OpenMLNotAuthorizedError(OpenMLServerError): + """Indicates an authenticated user is not authorized to execute the requested action.""" + + pass diff --git a/openml/flows/__init__.py b/openml/flows/__init__.py index 3642b9c56..f8d35c3f5 100644 --- a/openml/flows/__init__.py +++ b/openml/flows/__init__.py @@ -2,7 +2,14 @@ from .flow import OpenMLFlow -from .functions import get_flow, list_flows, flow_exists, get_flow_id, assert_flows_equal +from .functions import ( + get_flow, + list_flows, + flow_exists, + get_flow_id, + assert_flows_equal, + delete_flow, +) __all__ = [ "OpenMLFlow", @@ -11,4 +18,5 @@ "get_flow_id", "flow_exists", "assert_flows_equal", + "delete_flow", ] diff --git a/openml/flows/functions.py b/openml/flows/functions.py index 99525c3e4..aea5cae6d 100644 --- a/openml/flows/functions.py +++ b/openml/flows/functions.py @@ -544,3 +544,22 @@ def _create_flow_from_xml(flow_xml: str) -> OpenMLFlow: """ return OpenMLFlow._from_dict(xmltodict.parse(flow_xml)) + + +def delete_flow(flow_id: int) -> bool: + """Delete flow with id `flow_id` from the OpenML server. + + You can only delete flows which you uploaded and which + which are not linked to runs. + + Parameters + ---------- + flow_id : int + OpenML id of the flow + + Returns + ------- + bool + True if the deletion was successful. False otherwise. + """ + return openml.utils._delete_entity("flow", flow_id) diff --git a/openml/runs/__init__.py b/openml/runs/__init__.py index e917a57a5..2abbd8f29 100644 --- a/openml/runs/__init__.py +++ b/openml/runs/__init__.py @@ -12,6 +12,7 @@ run_exists, initialize_model_from_run, initialize_model_from_trace, + delete_run, ) __all__ = [ @@ -27,4 +28,5 @@ "run_exists", "initialize_model_from_run", "initialize_model_from_trace", + "delete_run", ] diff --git a/openml/runs/functions.py b/openml/runs/functions.py index ff1f07c06..d52b43add 100644 --- a/openml/runs/functions.py +++ b/openml/runs/functions.py @@ -1209,3 +1209,21 @@ def format_prediction( return [repeat, fold, index, prediction, truth] else: raise NotImplementedError(f"Formatting for {type(task)} is not supported.") + + +def delete_run(run_id: int) -> bool: + """Delete run with id `run_id` from the OpenML server. + + You can only delete runs which you uploaded. + + Parameters + ---------- + run_id : int + OpenML id of the run + + Returns + ------- + bool + True if the deletion was successful. False otherwise. + """ + return openml.utils._delete_entity("run", run_id) diff --git a/openml/tasks/__init__.py b/openml/tasks/__init__.py index cba0aa14f..a5d578d2d 100644 --- a/openml/tasks/__init__.py +++ b/openml/tasks/__init__.py @@ -15,6 +15,7 @@ get_task, get_tasks, list_tasks, + delete_task, ) __all__ = [ @@ -30,4 +31,5 @@ "list_tasks", "OpenMLSplit", "TaskType", + "delete_task", ] diff --git a/openml/tasks/functions.py b/openml/tasks/functions.py index c44d55ea7..964277760 100644 --- a/openml/tasks/functions.py +++ b/openml/tasks/functions.py @@ -545,3 +545,22 @@ def create_task( evaluation_measure=evaluation_measure, **kwargs, ) + + +def delete_task(task_id: int) -> bool: + """Delete task with id `task_id` from the OpenML server. + + You can only delete tasks which you created and have + no runs associated with them. + + Parameters + ---------- + task_id : int + OpenML id of the task + + Returns + ------- + bool + True if the deletion was successful. False otherwise. + """ + return openml.utils._delete_entity("task", task_id) diff --git a/openml/testing.py b/openml/testing.py index 56445a253..4e2f0c006 100644 --- a/openml/testing.py +++ b/openml/testing.py @@ -3,12 +3,14 @@ import hashlib import inspect import os +import pathlib import shutil import sys import time from typing import Dict, Union, cast import unittest import pandas as pd +import requests import openml from openml.tasks import TaskType @@ -306,4 +308,22 @@ class CustomImputer(SimpleImputer): pass -__all__ = ["TestBase", "SimpleImputer", "CustomImputer", "check_task_existence"] +def create_request_response( + *, status_code: int, content_filepath: pathlib.Path +) -> requests.Response: + with open(content_filepath, "r") as xml_response: + response_body = xml_response.read() + + response = requests.Response() + response.status_code = status_code + response._content = response_body.encode() + return response + + +__all__ = [ + "TestBase", + "SimpleImputer", + "CustomImputer", + "check_task_existence", + "create_request_response", +] diff --git a/openml/utils.py b/openml/utils.py index 0f60f2bb8..3c2fa876f 100644 --- a/openml/utils.py +++ b/openml/utils.py @@ -172,9 +172,42 @@ def _delete_entity(entity_type, entity_id): raise ValueError("Can't delete a %s" % entity_type) url_suffix = "%s/%d" % (entity_type, entity_id) - result_xml = openml._api_calls._perform_api_call(url_suffix, "delete") - result = xmltodict.parse(result_xml) - return "oml:%s_delete" % entity_type in result + try: + result_xml = openml._api_calls._perform_api_call(url_suffix, "delete") + result = xmltodict.parse(result_xml) + return f"oml:{entity_type}_delete" in result + except openml.exceptions.OpenMLServerException as e: + # https://github.com/openml/OpenML/blob/21f6188d08ac24fcd2df06ab94cf421c946971b0/openml_OS/views/pages/api_new/v1/xml/pre.php + # Most exceptions are descriptive enough to be raised as their standard + # OpenMLServerException, however there are two cases where we add information: + # - a generic "failed" message, we direct them to the right issue board + # - when the user successfully authenticates with the server, + # but user is not allowed to take the requested action, + # in which case we specify a OpenMLNotAuthorizedError. + by_other_user = [323, 353, 393, 453, 594] + has_dependent_entities = [324, 326, 327, 328, 354, 454, 464, 595] + unknown_reason = [325, 355, 394, 455, 593] + if e.code in by_other_user: + raise openml.exceptions.OpenMLNotAuthorizedError( + message=( + f"The {entity_type} can not be deleted because it was not uploaded by you." + ), + ) from e + if e.code in has_dependent_entities: + raise openml.exceptions.OpenMLNotAuthorizedError( + message=( + f"The {entity_type} can not be deleted because " + f"it still has associated entities: {e.message}" + ) + ) from e + if e.code in unknown_reason: + raise openml.exceptions.OpenMLServerError( + message=( + f"The {entity_type} can not be deleted for unknown reason," + " please open an issue at: https://github.com/openml/openml/issues/new" + ), + ) from e + raise def _list_all(listing_call, output_format="dict", *args, **filters): diff --git a/tests/conftest.py b/tests/conftest.py index d727bb537..43e2cc3ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -185,3 +185,13 @@ def pytest_addoption(parser): @pytest.fixture(scope="class") def long_version(request): request.cls.long_version = request.config.getoption("--long") + + +@pytest.fixture +def test_files_directory() -> pathlib.Path: + return pathlib.Path(__file__).parent / "files" + + +@pytest.fixture() +def test_api_key() -> str: + return "c0c42819af31e706efe1f4b88c23c6c1" diff --git a/tests/files/mock_responses/datasets/data_delete_has_tasks.xml b/tests/files/mock_responses/datasets/data_delete_has_tasks.xml new file mode 100644 index 000000000..fc866047c --- /dev/null +++ b/tests/files/mock_responses/datasets/data_delete_has_tasks.xml @@ -0,0 +1,4 @@ + + 354 + Dataset is in use by other content. Can not be deleted + diff --git a/tests/files/mock_responses/datasets/data_delete_not_exist.xml b/tests/files/mock_responses/datasets/data_delete_not_exist.xml new file mode 100644 index 000000000..b3b212fbe --- /dev/null +++ b/tests/files/mock_responses/datasets/data_delete_not_exist.xml @@ -0,0 +1,4 @@ + + 352 + Dataset does not exist + diff --git a/tests/files/mock_responses/datasets/data_delete_not_owned.xml b/tests/files/mock_responses/datasets/data_delete_not_owned.xml new file mode 100644 index 000000000..7d412d48e --- /dev/null +++ b/tests/files/mock_responses/datasets/data_delete_not_owned.xml @@ -0,0 +1,4 @@ + + 353 + Dataset is not owned by you + \ No newline at end of file diff --git a/tests/files/mock_responses/datasets/data_delete_successful.xml b/tests/files/mock_responses/datasets/data_delete_successful.xml new file mode 100644 index 000000000..9df47c1a2 --- /dev/null +++ b/tests/files/mock_responses/datasets/data_delete_successful.xml @@ -0,0 +1,3 @@ + + 40000 + diff --git a/tests/files/mock_responses/flows/flow_delete_has_runs.xml b/tests/files/mock_responses/flows/flow_delete_has_runs.xml new file mode 100644 index 000000000..5c8530e75 --- /dev/null +++ b/tests/files/mock_responses/flows/flow_delete_has_runs.xml @@ -0,0 +1,5 @@ + + 324 + flow is in use by other content (runs). Can not be deleted + {10716, 10707} () + diff --git a/tests/files/mock_responses/flows/flow_delete_is_subflow.xml b/tests/files/mock_responses/flows/flow_delete_is_subflow.xml new file mode 100644 index 000000000..ddc314ae4 --- /dev/null +++ b/tests/files/mock_responses/flows/flow_delete_is_subflow.xml @@ -0,0 +1,5 @@ + + 328 + flow is in use by other content (it is a subflow). Can not be deleted + {37661} + diff --git a/tests/files/mock_responses/flows/flow_delete_not_exist.xml b/tests/files/mock_responses/flows/flow_delete_not_exist.xml new file mode 100644 index 000000000..4df49149f --- /dev/null +++ b/tests/files/mock_responses/flows/flow_delete_not_exist.xml @@ -0,0 +1,4 @@ + + 322 + flow does not exist + diff --git a/tests/files/mock_responses/flows/flow_delete_not_owned.xml b/tests/files/mock_responses/flows/flow_delete_not_owned.xml new file mode 100644 index 000000000..3aa9a9ef2 --- /dev/null +++ b/tests/files/mock_responses/flows/flow_delete_not_owned.xml @@ -0,0 +1,4 @@ + + 323 + flow is not owned by you + diff --git a/tests/files/mock_responses/flows/flow_delete_successful.xml b/tests/files/mock_responses/flows/flow_delete_successful.xml new file mode 100644 index 000000000..7638e942d --- /dev/null +++ b/tests/files/mock_responses/flows/flow_delete_successful.xml @@ -0,0 +1,3 @@ + + 33364 + diff --git a/tests/files/mock_responses/runs/run_delete_not_exist.xml b/tests/files/mock_responses/runs/run_delete_not_exist.xml new file mode 100644 index 000000000..855c223fa --- /dev/null +++ b/tests/files/mock_responses/runs/run_delete_not_exist.xml @@ -0,0 +1,4 @@ + + 392 + Run does not exist + diff --git a/tests/files/mock_responses/runs/run_delete_not_owned.xml b/tests/files/mock_responses/runs/run_delete_not_owned.xml new file mode 100644 index 000000000..551252e22 --- /dev/null +++ b/tests/files/mock_responses/runs/run_delete_not_owned.xml @@ -0,0 +1,4 @@ + + 393 + Run is not owned by you + diff --git a/tests/files/mock_responses/runs/run_delete_successful.xml b/tests/files/mock_responses/runs/run_delete_successful.xml new file mode 100644 index 000000000..fe4233afa --- /dev/null +++ b/tests/files/mock_responses/runs/run_delete_successful.xml @@ -0,0 +1,3 @@ + + 10591880 + diff --git a/tests/files/mock_responses/tasks/task_delete_has_runs.xml b/tests/files/mock_responses/tasks/task_delete_has_runs.xml new file mode 100644 index 000000000..87a92540d --- /dev/null +++ b/tests/files/mock_responses/tasks/task_delete_has_runs.xml @@ -0,0 +1,4 @@ + + 454 + Task is executed in some runs. Delete these first + diff --git a/tests/files/mock_responses/tasks/task_delete_not_exist.xml b/tests/files/mock_responses/tasks/task_delete_not_exist.xml new file mode 100644 index 000000000..8a262af29 --- /dev/null +++ b/tests/files/mock_responses/tasks/task_delete_not_exist.xml @@ -0,0 +1,4 @@ + + 452 + Task does not exist + diff --git a/tests/files/mock_responses/tasks/task_delete_not_owned.xml b/tests/files/mock_responses/tasks/task_delete_not_owned.xml new file mode 100644 index 000000000..3d504772b --- /dev/null +++ b/tests/files/mock_responses/tasks/task_delete_not_owned.xml @@ -0,0 +1,4 @@ + + 453 + Task is not owned by you + diff --git a/tests/files/mock_responses/tasks/task_delete_successful.xml b/tests/files/mock_responses/tasks/task_delete_successful.xml new file mode 100644 index 000000000..594b6e992 --- /dev/null +++ b/tests/files/mock_responses/tasks/task_delete_successful.xml @@ -0,0 +1,3 @@ + + 361323 + diff --git a/tests/test_datasets/test_dataset_functions.py b/tests/test_datasets/test_dataset_functions.py index e6c4fe3ec..45a64ab8a 100644 --- a/tests/test_datasets/test_dataset_functions.py +++ b/tests/test_datasets/test_dataset_functions.py @@ -13,6 +13,7 @@ import pytest import numpy as np import pandas as pd +import requests import scipy.sparse from oslo_concurrency import lockutils @@ -23,8 +24,9 @@ OpenMLHashException, OpenMLPrivateDatasetError, OpenMLServerException, + OpenMLNotAuthorizedError, ) -from openml.testing import TestBase +from openml.testing import TestBase, create_request_response from openml.utils import _tag_entity, _create_cache_directory_for_id from openml.datasets.functions import ( create_dataset, @@ -1672,3 +1674,138 @@ def test_valid_attribute_validations(default_target_attribute, row_id_attribute, original_data_url=original_data_url, paper_url=paper_url, ) + + def test_delete_dataset(self): + data = [ + ["a", "sunny", 85.0, 85.0, "FALSE", "no"], + ["b", "sunny", 80.0, 90.0, "TRUE", "no"], + ["c", "overcast", 83.0, 86.0, "FALSE", "yes"], + ["d", "rainy", 70.0, 96.0, "FALSE", "yes"], + ["e", "rainy", 68.0, 80.0, "FALSE", "yes"], + ] + column_names = ["rnd_str", "outlook", "temperature", "humidity", "windy", "play"] + df = pd.DataFrame(data, columns=column_names) + # enforce the type of each column + df["outlook"] = df["outlook"].astype("category") + df["windy"] = df["windy"].astype("bool") + df["play"] = df["play"].astype("category") + # meta-information + name = "%s-pandas_testing_dataset" % self._get_sentinel() + description = "Synthetic dataset created from a Pandas DataFrame" + creator = "OpenML tester" + collection_date = "01-01-2018" + language = "English" + licence = "MIT" + citation = "None" + original_data_url = "http://openml.github.io/openml-python" + paper_url = "http://openml.github.io/openml-python" + dataset = openml.datasets.functions.create_dataset( + name=name, + description=description, + creator=creator, + contributor=None, + collection_date=collection_date, + language=language, + licence=licence, + default_target_attribute="play", + row_id_attribute=None, + ignore_attribute=None, + citation=citation, + attributes="auto", + data=df, + version_label="test", + original_data_url=original_data_url, + paper_url=paper_url, + ) + dataset.publish() + _dataset_id = dataset.id + self.assertTrue(openml.datasets.delete_dataset(_dataset_id)) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_dataset_not_owned(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = ( + test_files_directory / "mock_responses" / "datasets" / "data_delete_not_owned.xml" + ) + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The data can not be deleted because it was not uploaded by you.", + ): + openml.datasets.delete_dataset(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/data/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_dataset_with_run(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = ( + test_files_directory / "mock_responses" / "datasets" / "data_delete_has_tasks.xml" + ) + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The data can not be deleted because it still has associated entities:", + ): + openml.datasets.delete_dataset(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/data/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_dataset_success(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = ( + test_files_directory / "mock_responses" / "datasets" / "data_delete_successful.xml" + ) + mock_delete.return_value = create_request_response( + status_code=200, content_filepath=content_file + ) + + success = openml.datasets.delete_dataset(40000) + assert success + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/data/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_unknown_dataset(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = ( + test_files_directory / "mock_responses" / "datasets" / "data_delete_not_exist.xml" + ) + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLServerException, + match="Dataset does not exist", + ): + openml.datasets.delete_dataset(9_999_999) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/data/9999999",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) diff --git a/tests/test_flows/test_flow_functions.py b/tests/test_flows/test_flow_functions.py index 532fb1d1b..f2520cb36 100644 --- a/tests/test_flows/test_flow_functions.py +++ b/tests/test_flows/test_flow_functions.py @@ -4,16 +4,20 @@ import copy import functools import unittest +from unittest import mock from unittest.mock import patch from distutils.version import LooseVersion + +import requests import sklearn from sklearn import ensemble import pandas as pd import pytest import openml -from openml.testing import TestBase +from openml.exceptions import OpenMLNotAuthorizedError, OpenMLServerException +from openml.testing import TestBase, create_request_response import openml.extensions.sklearn @@ -410,3 +414,126 @@ def test_get_flow_id(self): ) self.assertEqual(flow_ids_exact_version_True, flow_ids_exact_version_False) self.assertIn(flow.flow_id, flow_ids_exact_version_True) + + def test_delete_flow(self): + flow = openml.OpenMLFlow( + name="sklearn.dummy.DummyClassifier", + class_name="sklearn.dummy.DummyClassifier", + description="test description", + model=sklearn.dummy.DummyClassifier(), + components=OrderedDict(), + parameters=OrderedDict(), + parameters_meta_info=OrderedDict(), + external_version="1", + tags=[], + language="English", + dependencies=None, + ) + + flow, _ = self._add_sentinel_to_flow_name(flow, None) + + flow.publish() + _flow_id = flow.flow_id + self.assertTrue(openml.flows.delete_flow(_flow_id)) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_flow_not_owned(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_not_owned.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The flow can not be deleted because it was not uploaded by you.", + ): + openml.flows.delete_flow(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/flow/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_flow_with_run(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_has_runs.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The flow can not be deleted because it still has associated entities:", + ): + openml.flows.delete_flow(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/flow/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_subflow(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_is_subflow.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The flow can not be deleted because it still has associated entities:", + ): + openml.flows.delete_flow(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/flow/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_flow_success(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_successful.xml" + mock_delete.return_value = create_request_response( + status_code=200, content_filepath=content_file + ) + + success = openml.flows.delete_flow(33364) + assert success + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/flow/33364",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_unknown_flow(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_not_exist.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLServerException, + match="flow does not exist", + ): + openml.flows.delete_flow(9_999_999) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/flow/9999999",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) diff --git a/tests/test_runs/test_run_functions.py b/tests/test_runs/test_run_functions.py index 520b7c0bc..91dd4ce5e 100644 --- a/tests/test_runs/test_run_functions.py +++ b/tests/test_runs/test_run_functions.py @@ -1,5 +1,4 @@ # License: BSD 3-Clause - import arff from distutils.version import LooseVersion import os @@ -7,10 +6,11 @@ import time import sys import ast -import unittest.mock +from unittest import mock import numpy as np import joblib +import requests from joblib import parallel_backend import openml @@ -23,13 +23,21 @@ import pytest import openml.extensions.sklearn -from openml.testing import TestBase, SimpleImputer, CustomImputer +from openml.testing import TestBase, SimpleImputer, CustomImputer, create_request_response from openml.extensions.sklearn import cat, cont -from openml.runs.functions import _run_task_get_arffcontent, run_exists, format_prediction +from openml.runs.functions import ( + _run_task_get_arffcontent, + run_exists, + format_prediction, + delete_run, +) from openml.runs.trace import OpenMLRunTrace from openml.tasks import TaskType from openml.testing import check_task_existence -from openml.exceptions import OpenMLServerException +from openml.exceptions import ( + OpenMLServerException, + OpenMLNotAuthorizedError, +) from sklearn.naive_bayes import GaussianNB from sklearn.model_selection._search import BaseSearchCV @@ -708,7 +716,7 @@ def get_ct_cf(nominal_indices, numeric_indices): LooseVersion(sklearn.__version__) < "0.20", reason="columntransformer introduction in 0.20.0", ) - @unittest.mock.patch("warnings.warn") + @mock.patch("warnings.warn") def test_run_and_upload_knn_pipeline(self, warnings_mock): cat_imp = make_pipeline( @@ -1672,7 +1680,7 @@ def test_format_prediction_task_regression(self): LooseVersion(sklearn.__version__) < "0.21", reason="couldn't perform local tests successfully w/o bloating RAM", ) - @unittest.mock.patch("openml.extensions.sklearn.SklearnExtension._prevent_optimize_n_jobs") + @mock.patch("openml.extensions.sklearn.SklearnExtension._prevent_optimize_n_jobs") def test__run_task_get_arffcontent_2(self, parallel_mock): """Tests if a run executed in parallel is collated correctly.""" task = openml.tasks.get_task(7) # Supervised Classification on kr-vs-kp @@ -1726,7 +1734,7 @@ def test__run_task_get_arffcontent_2(self, parallel_mock): LooseVersion(sklearn.__version__) < "0.21", reason="couldn't perform local tests successfully w/o bloating RAM", ) - @unittest.mock.patch("openml.extensions.sklearn.SklearnExtension._prevent_optimize_n_jobs") + @mock.patch("openml.extensions.sklearn.SklearnExtension._prevent_optimize_n_jobs") def test_joblib_backends(self, parallel_mock): """Tests evaluation of a run using various joblib backends and n_jobs.""" task = openml.tasks.get_task(7) # Supervised Classification on kr-vs-kp @@ -1777,3 +1785,82 @@ def test_joblib_backends(self, parallel_mock): self.assertEqual(len(res[2]["predictive_accuracy"][0]), 10) self.assertEqual(len(res[3]["predictive_accuracy"][0]), 10) self.assertEqual(parallel_mock.call_count, call_count) + + @unittest.skipIf( + LooseVersion(sklearn.__version__) < "0.20", + reason="SimpleImputer doesn't handle mixed type DataFrame as input", + ) + def test_delete_run(self): + rs = 1 + clf = sklearn.pipeline.Pipeline( + steps=[("imputer", SimpleImputer()), ("estimator", DecisionTreeClassifier())] + ) + task = openml.tasks.get_task(32) # diabetes; crossvalidation + + run = openml.runs.run_model_on_task(model=clf, task=task, seed=rs) + run.publish() + TestBase._mark_entity_for_removal("run", run.run_id) + TestBase.logger.info("collected from test_run_functions: {}".format(run.run_id)) + + _run_id = run.run_id + self.assertTrue(delete_run(_run_id)) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_run_not_owned(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_not_owned.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The run can not be deleted because it was not uploaded by you.", + ): + openml.runs.delete_run(40_000) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/run/40000",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_run_success(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_successful.xml" + mock_delete.return_value = create_request_response( + status_code=200, content_filepath=content_file + ) + + success = openml.runs.delete_run(10591880) + assert success + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/run/10591880",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_unknown_run(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_not_exist.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLServerException, + match="Run does not exist", + ): + openml.runs.delete_run(9_999_999) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/run/9999999",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) diff --git a/tests/test_tasks/test_task_functions.py b/tests/test_tasks/test_task_functions.py index be5b0c9bd..dde3561f4 100644 --- a/tests/test_tasks/test_task_functions.py +++ b/tests/test_tasks/test_task_functions.py @@ -3,10 +3,13 @@ import os from unittest import mock +import pytest +import requests + from openml.tasks import TaskType -from openml.testing import TestBase +from openml.testing import TestBase, create_request_response from openml import OpenMLSplit, OpenMLTask -from openml.exceptions import OpenMLCacheException +from openml.exceptions import OpenMLCacheException, OpenMLNotAuthorizedError, OpenMLServerException import openml import unittest import pandas as pd @@ -253,3 +256,84 @@ def test_deletion_of_cache_dir(self): self.assertTrue(os.path.exists(tid_cache_dir)) openml.utils._remove_cache_dir_for_id("tasks", tid_cache_dir) self.assertFalse(os.path.exists(tid_cache_dir)) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_task_not_owned(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_owned.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The task can not be deleted because it was not uploaded by you.", + ): + openml.tasks.delete_task(1) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/task/1",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_task_with_run(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_has_runs.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLNotAuthorizedError, + match="The task can not be deleted because it still has associated entities:", + ): + openml.tasks.delete_task(3496) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/task/3496",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_success(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_successful.xml" + mock_delete.return_value = create_request_response( + status_code=200, content_filepath=content_file + ) + + success = openml.tasks.delete_task(361323) + assert success + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/task/361323",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args) + + +@mock.patch.object(requests.Session, "delete") +def test_delete_unknown_task(mock_delete, test_files_directory, test_api_key): + openml.config.start_using_configuration_for_example() + content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_exist.xml" + mock_delete.return_value = create_request_response( + status_code=412, content_filepath=content_file + ) + + with pytest.raises( + OpenMLServerException, + match="Task does not exist", + ): + openml.tasks.delete_task(9_999_999) + + expected_call_args = [ + ("https://test.openml.org/api/v1/xml/task/9999999",), + {"params": {"api_key": test_api_key}}, + ] + assert expected_call_args == list(mock_delete.call_args)