diff --git a/changelog.d/20230717_105415_sirosen_add_search_index_lifecycle.rst b/changelog.d/20230717_105415_sirosen_add_search_index_lifecycle.rst new file mode 100644 index 000000000..65d3ca27b --- /dev/null +++ b/changelog.d/20230717_105415_sirosen_add_search_index_lifecycle.rst @@ -0,0 +1,5 @@ +Added +~~~~~ + +- Add ``SearchClient`` methods for managing search index lifecycle: + ``create_index``, ``delete_index``, and ``reopen_index`` (:pr:`NUMBER`) diff --git a/src/globus_sdk/_testing/data/search/create_index.py b/src/globus_sdk/_testing/data/search/create_index.py new file mode 100644 index 000000000..428677bb0 --- /dev/null +++ b/src/globus_sdk/_testing/data/search/create_index.py @@ -0,0 +1,55 @@ +import uuid + +from globus_sdk._testing.models import RegisteredResponse, ResponseSet + +INDEX_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="search", + method="POST", + path="/v1/index", + json={ + "@datatype": "GSearchIndex", + "@version": "2017-09-01", + "creation_date": "2021-04-05 15:05:18", + "display_name": "Awesome Index of Awesomeness", + "description": "An index so awesome that it simply cannot be described", + "id": INDEX_ID, + "is_trial": True, + "subscription_id": None, + "max_size_in_mb": 1, + "num_entries": 0, + "num_subjects": 0, + "size_in_mb": 0, + "status": "open", + }, + metadata={"index_id": INDEX_ID}, + ), + trial_limit=RegisteredResponse( + service="search", + method="POST", + path="/v1/index", + status=409, + json={ + "@datatype": "GError", + "request_id": "38186e960f3a64c9d530d48ba2271285", + "status": 409, + "error_data": { + "cause": ( + "When creating an index, an 'owner' role is created " + "automatically. If this would exceed ownership limits, this error " + "is raised instead." + ), + "constraint": ( + "Cannot create more ownership roles on trial indices " + "than the limit (3)" + ), + }, + "@version": "2017-09-01", + "message": "Role limit exceeded", + "code": "Conflict.LimitExceeded", + }, + ), +) diff --git a/src/globus_sdk/_testing/data/search/delete_index.py b/src/globus_sdk/_testing/data/search/delete_index.py new file mode 100644 index 000000000..762fe3d53 --- /dev/null +++ b/src/globus_sdk/_testing/data/search/delete_index.py @@ -0,0 +1,37 @@ +import uuid + +from globus_sdk._testing.models import RegisteredResponse, ResponseSet + +INDEX_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="search", + method="DELETE", + path=f"/v1/index/{INDEX_ID}", + json={ + "index_id": INDEX_ID, + "acknowledged": True, + }, + metadata={"index_id": INDEX_ID}, + ), + delete_pending=RegisteredResponse( + service="search", + method="DELETE", + path=f"/v1/index/{INDEX_ID}", + status=409, + json={ + "@datatype": "GError", + "request_id": "3430ce9a5f9d929ef7682e4c58363dee", + "status": 409, + "@version": "2017-09-01", + "message": ( + "Index status (delete_pending) did not match required status " + "for this operation: open" + ), + "code": "Conflict.IncompatibleIndexStatus", + }, + metadata={"index_id": INDEX_ID}, + ), +) diff --git a/src/globus_sdk/_testing/data/search/reopen_index.py b/src/globus_sdk/_testing/data/search/reopen_index.py new file mode 100644 index 000000000..069d67806 --- /dev/null +++ b/src/globus_sdk/_testing/data/search/reopen_index.py @@ -0,0 +1,37 @@ +import uuid + +from globus_sdk._testing.models import RegisteredResponse, ResponseSet + +INDEX_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="search", + method="POST", + path=f"/v1/index/{INDEX_ID}/reopen", + json={ + "index_id": INDEX_ID, + "acknowledged": True, + }, + metadata={"index_id": INDEX_ID}, + ), + already_open=RegisteredResponse( + service="search", + method="POST", + path=f"/v1/index/{INDEX_ID}/reopen", + status=409, + json={ + "code": "Conflict.IncompatibleIndexStatus", + "request_id": "e1ad6822156dea372027eee48c16e150", + "@datatype": "GError", + "message": ( + "Index status (open) did not match required status for " + "this operation: delete_pending" + ), + "@version": "2017-09-01", + "status": 409, + }, + metadata={"index_id": INDEX_ID}, + ), +) diff --git a/src/globus_sdk/services/search/client.py b/src/globus_sdk/services/search/client.py index bd5bfb5b9..3467ed771 100644 --- a/src/globus_sdk/services/search/client.py +++ b/src/globus_sdk/services/search/client.py @@ -39,6 +39,119 @@ class SearchClient(client.BaseClient): # Index Management # + def create_index( + self, display_name: str, description: str + ) -> response.GlobusHTTPResponse: + """ + Create a new index. + + :param display_name: the name of the index + :type display_name: str + :param description: a description of the index + :type description: str + + New indices default to trial status. For subscribers with a subscription ID, + indices can be converted to non-trial by sending a request to support@globus.org + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + sc = globus_sdk.SearchClient(...) + r = sc.create_index( + "History and Witchcraft", + "Searchable information about history and witchcraft", + ) + print(f"index ID: {r['id']}") + + .. tab-item:: Example Response Data + + .. expandtestfixture:: search.create_index + + .. tab-item:: API Info + + ``POST /v1/index`` + + .. extdoclink:: Index Create + :ref: search/reference/index_create/ + """ + log.info(f"SearchClient.create_index({display_name!r}, ...)") + return self.post( + "/v1/index", data={"display_name": display_name, "description": description} + ) + + def delete_index(self, index_id: UUIDLike) -> response.GlobusHTTPResponse: + """ + Mark an index for deletion. + + Globus Search does not immediately delete indices. Instead, this API sets the + index status to ``"delete-pending"``. + Search will move pending tasks on the index to the ``CANCELLED`` state and will + eventually delete the index. + + If the index is a trial index, it will be deleted a few minutes after being + marked for deletion. + If the index is non-trial, it will be kept for 30 days and will be eligible for + use with the ``reopen`` API (see :meth:`~.reopen_index`) during that time. + + :param index_id: the ID of the index + :type index_id: str or UUID + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + sc = globus_sdk.SearchClient(...) + sc.delete_index(index_id) + + .. tab-item:: Example Response Data + + .. expandtestfixture:: search.delete_index + + .. tab-item:: API Info + + ``DELETE /v1/index/`` + + .. extdoclink:: Index Delete + :ref: search/reference/index_delete/ + """ + log.info(f"SearchClient.delete_index({index_id!r}, ...)") + return self.delete(f"/v1/index/{index_id}") + + def reopen_index(self, index_id: UUIDLike) -> response.GlobusHTTPResponse: + """ + Reopen an index that has been marked for deletion, cancelling the deletion. + + :param index_id: the ID of the index + :type index_id: str or UUID + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + sc = globus_sdk.SearchClient(...) + sc.reopen_index(index_id) + + .. tab-item:: Example Response Data + + .. expandtestfixture:: search.reopen_index + + .. tab-item:: API Info + + ``POST /v1/index//reopen`` + + .. extdoclink:: Index Reopen + :ref: search/reference/index_reopen/ + """ + log.info(f"SearchClient.reopen_index({index_id!r}, ...)") + return self.post(f"/v1/index/{index_id}/reopen") + def get_index( self, index_id: UUIDLike, diff --git a/tests/functional/services/search/test_create_index.py b/tests/functional/services/search/test_create_index.py new file mode 100644 index 000000000..e2310d6d7 --- /dev/null +++ b/tests/functional/services/search/test_create_index.py @@ -0,0 +1,24 @@ +import pytest + +import globus_sdk +from globus_sdk._testing import load_response + + +def test_create_index(client): + meta = load_response(client.create_index).metadata + + res = client.create_index("Foo Title", "bar description") + assert res.http_status == 200 + assert res["id"] == meta["index_id"] + + +def test_create_index_limit_exceeded(client): + load_response(client.create_index, case="trial_limit") + + with pytest.raises(globus_sdk.SearchAPIError) as excinfo: + client.create_index("Foo Title", "bar description") + + err = excinfo.value + + assert err.http_status == 409 + assert err.code == "Conflict.LimitExceeded" diff --git a/tests/functional/services/search/test_delete_index.py b/tests/functional/services/search/test_delete_index.py new file mode 100644 index 000000000..835c62b63 --- /dev/null +++ b/tests/functional/services/search/test_delete_index.py @@ -0,0 +1,24 @@ +import pytest + +import globus_sdk +from globus_sdk._testing import load_response + + +def test_delete_index(client): + meta = load_response(client.delete_index).metadata + + res = client.delete_index(meta["index_id"]) + assert res.http_status == 200 + assert res["acknowledged"] is True + + +def test_delete_index_delete_already_pending(client): + meta = load_response(client.delete_index, case="delete_pending").metadata + + with pytest.raises(globus_sdk.SearchAPIError) as excinfo: + client.delete_index(meta["index_id"]) + + err = excinfo.value + + assert err.http_status == 409 + assert err.code == "Conflict.IncompatibleIndexStatus" diff --git a/tests/functional/services/search/test_reopen_index.py b/tests/functional/services/search/test_reopen_index.py new file mode 100644 index 000000000..b5ef9a05a --- /dev/null +++ b/tests/functional/services/search/test_reopen_index.py @@ -0,0 +1,24 @@ +import pytest + +import globus_sdk +from globus_sdk._testing import load_response + + +def test_reopen_index(client): + meta = load_response(client.reopen_index).metadata + + res = client.reopen_index(meta["index_id"]) + assert res.http_status == 200 + assert res["acknowledged"] is True + + +def test_reopen_index_already_open(client): + meta = load_response(client.reopen_index, case="already_open").metadata + + with pytest.raises(globus_sdk.SearchAPIError) as excinfo: + client.reopen_index(meta["index_id"]) + + err = excinfo.value + + assert err.http_status == 409 + assert err.code == "Conflict.IncompatibleIndexStatus"