From 71a56326887f6e954aead7e674e724adef7e9f18 Mon Sep 17 00:00:00 2001 From: Peyton Murray Date: Mon, 22 Apr 2024 13:51:10 -0700 Subject: [PATCH] Add user journey test for canceling a build (#812) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../test_data/complicated_environment.yaml | 42 +++++++ .../tests/user_journeys/test_user_journeys.py | 32 +++++ .../tests/user_journeys/utils/api_utils.py | 117 ++++++++++++------ 3 files changed, 155 insertions(+), 36 deletions(-) create mode 100644 conda-store-server/tests/user_journeys/test_data/complicated_environment.yaml diff --git a/conda-store-server/tests/user_journeys/test_data/complicated_environment.yaml b/conda-store-server/tests/user_journeys/test_data/complicated_environment.yaml new file mode 100644 index 000000000..d372bfe28 --- /dev/null +++ b/conda-store-server/tests/user_journeys/test_data/complicated_environment.yaml @@ -0,0 +1,42 @@ +name: complicated-environment +channels: + - conda-forge + - bokeh +dependencies: + - python=3.10 + - panel + - ipykernel + - ipywidgets + - ipywidgets_bokeh + - holoviews + - openjdk=17.0.9 + - pyspark + - findspark + - jhsingle-native-proxy>=0.8.2 + - bokeh-root-cmd>=0.1.2 + - nbconvert + - pip: + - nrtk==0.3.0 + - xaitk-saliency==0.7.0 + - maite==0.5.0 + - daml==0.44.5 + - hypothesis >=6.61.0,<7.0.0 + - pytest >=7.2.0,<8.0 + - pytest-cov >=4.0.0,<5.0 + - pytest-mock >= 3.10.0,<4.0 + - pytest-snapshot >= 0.9.0 + - pytest-xdist >=3.3.1,<4.0.0 + - types-python-dateutil >=2.8.19,<3.0.0 + - tox >=4.6.4,<5.0.0 + - virtualenv-pyenv >=0.3.0,<1.0.0 + - jupytext >= 1.14.0 + - numpydoc >= 1.5.0 + - pyright >= 1.1.280 + - loguru + - torch>=2.1 + - torchmetrics + - torchvision + - multiprocess + - keras + - yolov5 + - smqtk-detection[centernet] diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py index 78f3664a5..c0f0206b2 100644 --- a/conda-store-server/tests/user_journeys/test_user_journeys.py +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -175,3 +175,35 @@ def test_failed_build_logs(base_url: str): namespace, build_request["data"]["specification"]["name"], ) + + +@pytest.mark.user_journey +def test_cancel_build(base_url: str): + """Test that a user cancel a build in progress.""" + api = utils.API(base_url=base_url) + namespace = "default" + build_id = api.create_environment( + namespace, + "tests/user_journeys/test_data/complicated_environment.yaml", + wait=False, + ).json()["data"]["build_id"] + + assert api.get_build_status(build_id) in [ + utils.BuildStatus.QUEUED, + utils.BuildStatus.BUILDING, + ] + api.cancel_build(build_id) + + def check_status(): + status = api.get_build_status(build_id) + if status in [utils.BuildStatus.QUEUED, utils.BuildStatus.BUILDING]: + return False + + if status in [utils.BuildStatus.COMPLETED, utils.BuildStatus.FAILED]: + raise ValueError( + f"Build {build_id} {status.value.lower()}, but should have been canceled." + ) + + return status == utils.BuildStatus.CANCELED + + utils.wait_for_condition(check_status, timeout=60, interval=1) diff --git a/conda-store-server/tests/user_journeys/utils/api_utils.py b/conda-store-server/tests/user_journeys/utils/api_utils.py index 38c35a4a7..beb5b3d5e 100644 --- a/conda-store-server/tests/user_journeys/utils/api_utils.py +++ b/conda-store-server/tests/user_journeys/utils/api_utils.py @@ -4,7 +4,7 @@ import uuid from enum import Enum -from typing import Any, Optional, Union +from typing import Any, Callable, Optional, Union import requests import utils.time_utils as time_utils @@ -94,22 +94,13 @@ def _login(self, username: str, password: str) -> None: data = token_response.json() self.token = data["data"]["token"] - def create_namespace( - self, - namespace: Union[str, None] = None, - max_iterations: int = 100, - sleep_time: int = 5, - ) -> requests.Response: + def create_namespace(self, namespace: Union[str, None] = None) -> requests.Response: """Create a namespace. Parameters ---------- namespace : str Name of the namespace to create. If None, use a random namespace name - max_iterations : int - Max number of times to check whether the namespace was created before failing - sleep_time : int - Seconds to wait between each status check Returns ------- @@ -120,18 +111,7 @@ def create_namespace( namespace = self.gen_random_namespace() self._make_request(f"api/v1/namespace/{namespace}", method="POST") - for i in range(max_iterations): - response = self._make_request(f"api/v1/namespace/{namespace}") - status = NamespaceStatus(response.json()["status"]) - if status in [NamespaceStatus.OK, NamespaceStatus.ERROR]: - return response - - time.sleep(sleep_time) - - raise TimeoutError( - f"Timed out waiting to create namespace {namespace}. Current response: " - f"{response.json()}" - ) + return self._make_request(f"api/v1/namespace/{namespace}") def create_token( self, namespace: str, role: str, default_namespace: str = "default" @@ -150,6 +130,7 @@ def create_environment( specification_path: str, max_iterations: int = 100, sleep_time: int = 5, + wait: bool = True, ) -> requests.Response: """Create an environment. @@ -163,6 +144,10 @@ def create_environment( Max number of times to check whether the build completed before failing sleep_time : int Seconds to wait between each status check + wait : bool + If True, wait for the build to complete, fail, or be canceled before + returning a response. If False, return the response from the specification + POST immediately without waiting Returns ------- @@ -178,24 +163,23 @@ def create_environment( method="POST", json_data={"namespace": namespace, "specification": specification_content}, ) + if not wait: + return response + build_id = response.json()["data"]["build_id"] - for i in range(max_iterations): - response = self._make_request(f"api/v1/build/{build_id}/") - status = BuildStatus(response.json()["data"]["status"]) - if status in [ - BuildStatus.COMPLETED, + def check_status(): + status = self.get_build_status(build_id) + if status in [BuildStatus.QUEUED, BuildStatus.BUILDING]: + return False + return status in [ BuildStatus.FAILED, BuildStatus.CANCELED, - ]: - return response - - time.sleep(sleep_time) + BuildStatus.COMPLETED, + ] - raise TimeoutError( - f"Timed out waiting to create namespace {namespace}. Current response: " - f"{response.json()}" - ) + wait_for_condition(check_status, timeout=120, interval=1) + return self._make_request(f"api/v1/build/{build_id}/") def delete_environment( self, namespace: str, environment_name: str @@ -309,3 +293,64 @@ def get_environment(self, namespace: str, environment: str) -> dict[str, Any]: return self._make_request( f"api/v1/environment/{namespace}/{environment}/" ).json()["data"] + + def cancel_build(self, build_id: int) -> requests.Response: + """Cancel a build in progress. + + Parameters + ---------- + build_id : int + ID of the build to cancel + + Returns + ------- + requests.Response + Response from the server + """ + return self._make_request(f"api/v1/build/{build_id}/cancel/", method="PUT") + + def get_build_status(self, build_id: int) -> BuildStatus: + """Get the status of a build as a BuildStatus instance. + + Parameters + ---------- + build_id : int + ID of the build to get the status for + + Returns + ------- + BuildStatus + Build status for the given build ID + """ + response = self._make_request(f"api/v1/build/{build_id}/") + return BuildStatus(response.json()["data"]["status"]) + + +def wait_for_condition( + condition: Callable[[], bool], timeout: int = 60, interval: int = 1 +): + """Call `condition` until it returns `True`. + + `condition` will be called every `interval` seconds up to a maximum of `timeout` + seconds, at which point a ValueError is raised. + + Parameters + ---------- + condition : Callable[[], bool] + Function to call until True is returned + timeout : int + Number of seconds to continue calling `condition` for before timing out + interval : int + Number of seconds between consecutive calls + """ + initial_time = time.time() + while time.time() - initial_time < timeout: + result = condition() + if result: + return + + time.sleep(interval) + + raise ValueError( + f"Timeout after {timeout}s waiting for condition. Last result: {result}" + )