From 3994a783b64cf3dec020c3655073f7a62cb8d0f8 Mon Sep 17 00:00:00 2001 From: BartoszCki Date: Mon, 20 Jan 2020 13:14:52 +0100 Subject: [PATCH] Add deployments details command --- gradient/api_sdk/clients/deployment_client.py | 12 ++ gradient/api_sdk/repositories/__init__.py | 2 +- gradient/api_sdk/repositories/deployments.py | 27 +++- gradient/api_sdk/sdk_exceptions.py | 4 + gradient/cli/deployments.py | 27 ++-- gradient/commands/common.py | 5 +- gradient/commands/deployments.py | 38 +++-- tests/config_files/deployments_details.yaml | 3 + tests/conftest.py | 7 + tests/example_responses.py | 68 +++++++++ tests/functional/test_deployments.py | 133 ++++++++++++++++++ 11 files changed, 303 insertions(+), 23 deletions(-) create mode 100644 tests/config_files/deployments_details.yaml diff --git a/gradient/api_sdk/clients/deployment_client.py b/gradient/api_sdk/clients/deployment_client.py index 06e11d21..775d1f69 100644 --- a/gradient/api_sdk/clients/deployment_client.py +++ b/gradient/api_sdk/clients/deployment_client.py @@ -121,6 +121,18 @@ def create( deployment_id = repository.create(deployment, use_vpc=use_vpc) return deployment_id + def get(self, deployment_id): + """Get deployment instance + + :param str deployment_id: Deployment ID + + :return: Deployment instance + :rtype: models.Deployment + """ + repository = repositories.GetDeployment(self.api_key, logger=self.logger) + deployment = repository.get(deployment_id=deployment_id) + return deployment + def start(self, deployment_id, use_vpc=False): """ Start deployment diff --git a/gradient/api_sdk/repositories/__init__.py b/gradient/api_sdk/repositories/__init__.py index 9bd257c4..970dbc8a 100644 --- a/gradient/api_sdk/repositories/__init__.py +++ b/gradient/api_sdk/repositories/__init__.py @@ -1,5 +1,5 @@ from .deployments import ListDeployments, CreateDeployment, StartDeployment, StopDeployment, DeleteDeployment, \ - UpdateDeployment + UpdateDeployment, GetDeployment from .experiments import ListExperiments, GetExperiment, ListExperimentLogs, StartExperiment, StopExperiment, \ CreateSingleNodeExperiment, CreateMultiNodeExperiment, RunSingleNodeExperiment, RunMultiNodeExperiment, \ CreateMpiMultiNodeExperiment, RunMpiMultiNodeExperiment, DeleteExperiment diff --git a/gradient/api_sdk/repositories/deployments.py b/gradient/api_sdk/repositories/deployments.py index 343bd034..9527bf27 100644 --- a/gradient/api_sdk/repositories/deployments.py +++ b/gradient/api_sdk/repositories/deployments.py @@ -1,5 +1,7 @@ -from .common import ListResources, CreateResource, StartResource, StopResource, DeleteResource, AlterResource +from .common import ListResources, CreateResource, StartResource, StopResource, DeleteResource, AlterResource, \ + GetResource from .. import serializers, config +from ..sdk_exceptions import ResourceFetchingError, MalformedResponseError class GetBaseDeploymentApiUrlMixin(object): @@ -141,3 +143,26 @@ def _get_request_json(self, kwargs): "upd": kwargs, } return j + + +class GetDeployment(GetBaseDeploymentApiUrlMixin, GetResource): + SERIALIZER_CLS = serializers.DeploymentSchema + + def get_request_url(self, **kwargs): + return "/deployments/getDeploymentList/" + + def _get_request_json(self, kwargs): + deployment_id = kwargs["deployment_id"] + filter_ = {"where": {"and": [{"id": deployment_id}]}} + json_ = {"filter": filter_} + return json_ + + def _parse_object(self, instance_dict, **kwargs): + try: + instance_dict = instance_dict["deploymentList"][0] + except KeyError: + raise MalformedResponseError("Malformed response from API") + except IndexError: + raise ResourceFetchingError("Deployment not found") + + return super(GetDeployment, self)._parse_object(instance_dict, **kwargs) diff --git a/gradient/api_sdk/sdk_exceptions.py b/gradient/api_sdk/sdk_exceptions.py index 3a241538..da07f10b 100644 --- a/gradient/api_sdk/sdk_exceptions.py +++ b/gradient/api_sdk/sdk_exceptions.py @@ -10,6 +10,10 @@ class ResourceCreatingError(GradientSdkError): pass +class MalformedResponseError(GradientSdkError): + pass + + class ResourceCreatingDataError(ResourceCreatingError): pass diff --git a/gradient/cli/deployments.py b/gradient/cli/deployments.py index 0f5aec09..7f021d3e 100644 --- a/gradient/cli/deployments.py +++ b/gradient/cli/deployments.py @@ -4,7 +4,7 @@ from gradient import exceptions, logger, DEPLOYMENT_TYPES_MAP from gradient import utils -from gradient.api_sdk import DeploymentsClient, constants +from gradient.api_sdk import DeploymentsClient from gradient.cli import common from gradient.cli.cli import cli from gradient.cli.cli_types import ChoiceType, json_string @@ -17,15 +17,6 @@ def deployments(): pass -DEPLOYMENT_MACHINE_TYPES = ( - "G1", "G6", "G12", - "K80", "P100", "GV100", - # VPC machine types - "c5.xlarge", "c5.4xlarge", "c5.12xlarge", - "p2.xlarge", "p3.2xlarge", "p3.16xlarge", -) - - def get_deployment_client(api_key): deployment_client = DeploymentsClient(api_key=api_key, logger=logger.Logger()) return deployment_client @@ -447,3 +438,19 @@ def update_deployment(deployment_id, api_key, use_vpc, options_file, **kwargs): deployment_client = get_deployment_client(api_key) command = deployments_commands.UpdateDeploymentCommand(deployment_client=deployment_client) command.execute(deployment_id, use_vpc=use_vpc, **kwargs) + + +@deployments.command("details", help="Get details of model deployment") +@click.option( + "--id", + "deployment_id", + required=True, + help="Deployment ID", + cls=common.GradientOption, +) +@api_key_option +@common.options_file +def get_deployment(deployment_id, api_key, options_file): + deployment_client = get_deployment_client(api_key) + command = deployments_commands.GetDeploymentDetails(deployment_client=deployment_client) + command.execute(deployment_id) diff --git a/gradient/commands/common.py b/gradient/commands/common.py index 8bdeea06..b98504a9 100644 --- a/gradient/commands/common.py +++ b/gradient/commands/common.py @@ -3,11 +3,10 @@ import six import terminaltables -from gradient import exceptions -from gradient.api_sdk import sdk_exceptions +from halo import halo + from gradient.logger import Logger from gradient.utils import get_terminal_lines -from halo import halo @six.add_metaclass(abc.ABCMeta) diff --git a/gradient/commands/deployments.py b/gradient/commands/deployments.py index f62b5924..635cc6a2 100644 --- a/gradient/commands/deployments.py +++ b/gradient/commands/deployments.py @@ -6,10 +6,11 @@ from halo import halo from gradient import version, logger as gradient_logger, exceptions -from gradient.api_sdk import sdk_exceptions, utils +from gradient.api_sdk import sdk_exceptions, utils, models from gradient.api_sdk.clients import http_client from gradient.api_sdk.config import config from gradient.api_sdk.utils import urljoin +from gradient.commands.common import DetailsCommandMixin from gradient.utils import get_terminal_lines default_headers = {"X-API-Key": config.PAPERSPACE_API_KEY, @@ -21,7 +22,7 @@ @six.add_metaclass(abc.ABCMeta) class _DeploymentCommand(object): def __init__(self, deployment_client, logger_=gradient_logger.Logger()): - self.deployment_client = deployment_client + self.client = deployment_client self.logger = logger_ @abc.abstractmethod @@ -34,7 +35,7 @@ def execute(self, use_vpc=False, **kwargs): self._handle_auth(kwargs) with halo.Halo(text="Creating new deployment", spinner="dots"): - deployment_id = self.deployment_client.create(use_vpc=use_vpc, **kwargs) + deployment_id = self.client.create(use_vpc=use_vpc, **kwargs) self.logger.log("New deployment created with id: {}".format(deployment_id)) self.logger.log(self.get_instance_url(deployment_id)) @@ -62,7 +63,7 @@ def execute(self, use_vpc=False, **kwargs): def _get_instances(self, use_vpc=False, **kwargs): try: - instances = self.deployment_client.list(use_vpc=use_vpc, **kwargs) + instances = self.client.list(use_vpc=use_vpc, **kwargs) except sdk_exceptions.GradientSdkError as e: raise exceptions.ReceivingDataFailedError(e) @@ -103,25 +104,46 @@ def _make_table(table_data): class StartDeploymentCommand(_DeploymentCommand): def execute(self, use_vpc=False, **kwargs): - self.deployment_client.start(use_vpc=use_vpc, **kwargs) + self.client.start(use_vpc=use_vpc, **kwargs) self.logger.log("Deployment started") class StopDeploymentCommand(_DeploymentCommand): def execute(self, use_vpc=False, **kwargs): - self.deployment_client.stop(use_vpc=use_vpc, **kwargs) + self.client.stop(use_vpc=use_vpc, **kwargs) self.logger.log("Deployment stopped") class DeleteDeploymentCommand(_DeploymentCommand): def execute(self, **kwargs): - self.deployment_client.delete(**kwargs) + self.client.delete(**kwargs) self.logger.log("Deployment deleted") class UpdateDeploymentCommand(_DeploymentCommand): def execute(self, deployment_id, use_vpc=False, **kwargs): with halo.Halo(text="Updating deployment data", spinner="dots"): - self.deployment_client.update(deployment_id, use_vpc=use_vpc, **kwargs) + self.client.update(deployment_id, use_vpc=use_vpc, **kwargs) self.logger.log("Deployment data updated") + + +class GetDeploymentDetails(DetailsCommandMixin, _DeploymentCommand): + def _get_table_data(self, instance): + """ + :param models.Deployment instance: + """ + data = ( + ("ID", instance.id), + ("Name", instance.name), + ("State", instance.state), + ("Machine type", instance.machine_type), + ("Instance count", instance.instance_count), + ("Deployment type", instance.deployment_type), + ("Model ID", instance.model_id), + ("Project ID", instance.project_id), + ("Endpoint", instance.endpoint), + ("API type", instance.api_type), + ("Cluster ID", instance.cluster_id), + ) + return data diff --git a/tests/config_files/deployments_details.yaml b/tests/config_files/deployments_details.yaml new file mode 100644 index 00000000..d818d8e9 --- /dev/null +++ b/tests/config_files/deployments_details.yaml @@ -0,0 +1,3 @@ +apiKey: some_key +id: some_id +vpc: true diff --git a/tests/conftest.py b/tests/conftest.py index 94707eb4..ec10d205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,6 +97,13 @@ def deployments_delete_config_path(): return str(fixture_dir.resolve()) +@pytest.fixture +def deployments_details_config_path(): + p = Path(__file__) + fixture_dir = p.parent / "config_files" / "deployments_details.yaml" + return str(fixture_dir.resolve()) + + @pytest.fixture def hyperparameters_create_config_path(): p = Path(__file__) diff --git a/tests/example_responses.py b/tests/example_responses.py index bbb936cc..994a871f 100644 --- a/tests/example_responses.py +++ b/tests/example_responses.py @@ -5596,3 +5596,71 @@ "url": "https://ps-projects.s3.amazonaws.com/some/path/model/keton/elo.txt?AWSAccessKeyId=some_aws_access_key_id&Expires=713274132&Signature=tHriojGx03S%2FKkVGQGVI5CQRFTo%3D&response-content-disposition=attachment%3Bfilename%3D%22elo.txt%22&x-amz-security-token=some_amz_security_token" } ] + +GET_DEPLOYMENT_DETAILS_JSON_RESPONSE = { + "deploymentList": [ + { + "id": "some_id", + "ownerId": "some_owner_id", + "projectId": "some_project_id", + "experimentId": "some_experiment_id", + "state": "Stopped", + "startedByUserId": "some_user_id", + "startedByUserEmail": "some@address.com", + "deploymentType": "TFServing", + "deploymentTypeDescription": "Tensorflow Serving on K8s", + "modelId": "some_model_id", + "modelUrl": "s3://ps-projects/asdf/some/model/url", + "name": "some_name", + "tag": None, + "params": None, + "code": None, + "cluster": "KPS Jobs", + "clusterId": "some_cluster_id", + "machineType": "p3.2xlarge", + "imageUrl": "tensorflow/serving", + "imageUsername": None, + "imagePassword": None, + "imageServer": None, + "workspaceUrl": None, + "workspaceUsername": None, + "workspacePassword": None, + "instanceCount": 1, + "runningCount": 0, + "error": None, + "authToken": None, + "annotations": {}, + "oauthKey": None, + "oauthSecret": None, + "serviceType": "model", + "apiType": "REST", + "endpoint": "https://paperspace.io/model-serving/some_id:predict", + "ports": None, + "dtCreated": "2020-01-13T22:21:28.210Z", + "dtModified": "2020-01-14T00:32:27.577Z", + "dtBuildStarted": "2020-01-13T22:21:28.482Z", + "dtBuildFinished": None, + "dtProvisioningStarted": None, + "dtProvisioningFinished": None, + "dtStarted": None, + "dtTeardownStarted": None, + "dtTeardownFinished": None, + "dtStopped": "2020-01-13T22:48:40.107Z", + "dtDeleted": None, + "isDeleted": False, + "modelType": "Tensorflow", + "modelPath": None, + "command": None, + "args": None, + "env": None, + "containerModelPath": None, + "containerUrlPath": None, + "endpointUrlPath": None, + "method": None, + "tags": None + } + ], + "total": 129, + "displayTotal": 129, + "runningTotal": 0 +} diff --git a/tests/functional/test_deployments.py b/tests/functional/test_deployments.py index 14c8dd4d..33013b4c 100644 --- a/tests/functional/test_deployments.py +++ b/tests/functional/test_deployments.py @@ -813,3 +813,136 @@ def test_should_send_proper_data_and_print_message_when_create_wrong_model_id_wa data=None) assert result.output == self.EXPECTED_STDOUT_MODEL_NOT_FOUND assert result.exit_code == 0 + + +class TestDeploymentDetails(object): + URL = "https://api.paperspace.io/deployments/getDeploymentList/" + + COMMAND = ["deployments", "details", "--id", "some_id"] + COMMAND_WITH_OPTIONS_FILE = ["deployments", "details", "--optionsFile", ] # path added in test + LIST_JSON = example_responses.GET_DEPLOYMENT_DETAILS_JSON_RESPONSE + + COMMAND_WITH_API_KEY = ["deployments", "details", "--id", "some_id", "--apiKey", "some_key"] + EXPECTED_HEADERS_WITH_CHANGED_API_KEY = http_client.default_headers.copy() + EXPECTED_HEADERS_WITH_CHANGED_API_KEY["X-API-Key"] = "some_key" + + LIST_WITH_FILTER_REQUEST_JSON = {"filter": {"where": {"and": [{"id": "some_id"}]}}} + LIST_WITH_FILTER_RESPONSE_JSON_WHEN_NO_DEPLOYMENTS_FOUND = {"deploymentList": [], "total": 17, "displayTotal": 0, + "runningTotal": 0} + + RESPONSE_WITH_ERROR_MESSAGE = {"error": { + "name": "Error", + "status": 404, + "message": "Some error message", + "statusCode": 404, + }} + + DETAILS_STDOUT = """+-----------------+-----------------------------------------------------+ +| ID | some_id | ++-----------------+-----------------------------------------------------+ +| Name | some_name | +| State | Stopped | +| Machine type | p3.2xlarge | +| Instance count | 1 | +| Deployment type | TFServing | +| Model ID | some_model_id | +| Project ID | some_project_id | +| Endpoint | https://paperspace.io/model-serving/some_id:predict | +| API type | REST | +| Cluster ID | KPS Jobs | ++-----------------+-----------------------------------------------------+ +""" + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_send_get_request_and_print_details_of_deployment(self, get_patched): + get_patched.return_value = MockResponse(self.LIST_JSON) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND) + + assert result.output == self.DETAILS_STDOUT, result.exc_info + get_patched.assert_called_once_with(self.URL, + headers=EXPECTED_HEADERS, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_send_get_request_with_custom_api_key_when_api_key_parameter_was_provided(self, get_patched): + get_patched.return_value = MockResponse(self.LIST_JSON) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND_WITH_API_KEY) + + get_patched.assert_called_once_with(self.URL, + headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == self.DETAILS_STDOUT + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_send_get_request_and_print_details_of_deployment_when_using_config_file( + self, get_patched, deployments_details_config_path): + + get_patched.return_value = MockResponse(self.LIST_JSON) + command = self.COMMAND_WITH_OPTIONS_FILE[:] + [deployments_details_config_path] + + runner = CliRunner() + result = runner.invoke(cli.cli, command) + + get_patched.assert_called_once_with(self.URL, + headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == self.DETAILS_STDOUT + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_print_proper_message_when_wrong_api_key_was_used(self, get_patched): + get_patched.return_value = MockResponse({"status": 400, "message": "Invalid API token"}, 400) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND) + + get_patched.assert_called_once_with(self.URL, + headers=EXPECTED_HEADERS, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == "Failed to fetch data: Invalid API token\n", result.exc_info + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_print_proper_message_when_wrong_deployment_id_was_used(self, get_patched): + get_patched.return_value = MockResponse(self.LIST_WITH_FILTER_RESPONSE_JSON_WHEN_NO_DEPLOYMENTS_FOUND) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND) + + get_patched.assert_called_once_with(self.URL, + headers=EXPECTED_HEADERS, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == "Deployment not found\n", result.exc_info + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_print_proper_message_when_error_status_was_returned_by_api_without_message(self, get_patched): + get_patched.return_value = MockResponse(status_code=400) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND) + + get_patched.assert_called_once_with(self.URL, + headers=EXPECTED_HEADERS, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == "Failed to fetch data\n", result.exc_info + + @mock.patch("gradient.cli.deployments.deployments_commands.http_client.requests.get") + def test_should_print_proper_message_when_error_message_was_returned_by_api(self, get_patched): + get_patched.return_value = MockResponse(self.RESPONSE_WITH_ERROR_MESSAGE, status_code=404) + + runner = CliRunner() + result = runner.invoke(cli.cli, self.COMMAND) + + get_patched.assert_called_once_with(self.URL, + headers=EXPECTED_HEADERS, + json=self.LIST_WITH_FILTER_REQUEST_JSON, + params=None) + assert result.output == "Failed to fetch data: Some error message\n", result.exc_info