Skip to content

Commit 215508a

Browse files
authored
Feat/deploy models (#43)
* dev: adding required packages for dev * dev: added gcloud requirement * dev: added model deployment method to Models class * chore: cleaning up code and adding error handling * chore: cleaning up method output
1 parent f99a26a commit 215508a

File tree

7 files changed

+283
-14
lines changed

7 files changed

+283
-14
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ target/
8484
# Jupyter Notebook
8585
.ipynb_checkpoints
8686

87+
# files
88+
deploy-test.py
89+
8790
# pyenv
8891
.python-version
8992

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ Currently we support the following API routes:
170170

171171
| Feature | Code |Api route
172172
| --- | --- | ---
173+
|Deploy new model|client.models.deploy()|[api/models](https://docs.modzy.com/reference/model-deployment)
173174
|Get all models|client.models.get_all()|[api/models](https://docs.modzy.com/reference/get-all-models)|
174175
|List models|client.models.get_models()|[api/models](https://docs.modzy.com/reference/list-models)|
175176
|Get model details|client.models.get()|[api/models/:model-id](https://docs.modzy.com/reference/list-model-details)|

modzy/_util.py

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# -*- coding: utf-8 -*-
2-
2+
import json
33
import pathlib
4+
import time
5+
from .error import NetworkError
6+
from requests.adapters import HTTPAdapter
7+
from requests.packages.urllib3.util.retry import Retry
48
from base64 import b64encode
59

610
def encode_data_uri(bytes_like, mimetype='application/octet-stream'):
711
encoded = b64encode(bytes_like).decode('ascii')
812
data_uri = 'data:{};base64,{}'.format(mimetype, encoded)
913
return data_uri
1014

11-
1215
def file_to_bytes(file_like):
1316
if hasattr(file_like, 'read'): # File-like object
1417
if hasattr(file_like, 'seekable') and file_like.seekable():
@@ -32,7 +35,6 @@ def file_to_bytes(file_like):
3235
with open(path, 'rb') as file:
3336
return file.read()
3437

35-
3638
def file_to_chunks(file_like, chunk_size):
3739
file = None
3840
should_close = False
@@ -65,13 +67,128 @@ def file_to_chunks(file_like, chunk_size):
6567
if should_close:
6668
file.close()
6769

68-
6970
def bytes_to_chunks(byte_array, chunk_size):
7071
for i in range(0, len(byte_array), chunk_size):
7172
yield byte_array[i:i + chunk_size]
7273

73-
7474
def depth(d):
7575
if d and isinstance(d, dict):
7676
return max(depth(v) for k, v in d.items()) + 1
7777
return 0
78+
79+
'''
80+
Model Deployment (models.deploy()) specific utilities
81+
'''
82+
def load_model(client, logger, identifier, version):
83+
84+
start = time.time()
85+
# Before loading the model we need to ensure that it has been pulled.
86+
percentage = -1
87+
while percentage < 100:
88+
try:
89+
res = client.http.get(f"/models/{identifier}/versions/{version}/container-image")
90+
new_percentage = res.get("percentage")
91+
except NetworkError:
92+
continue
93+
94+
if new_percentage != percentage:
95+
logger.info(f'Loading model at {new_percentage}%')
96+
print(f'Loading model at {new_percentage}%')
97+
percentage = new_percentage
98+
99+
time.sleep(1)
100+
101+
retry_strategy = Retry(
102+
total=10,
103+
backoff_factor=0.3,
104+
status_forcelist=[400],
105+
allowed_methods=frozenset(['POST']),
106+
)
107+
adapter = HTTPAdapter(max_retries=retry_strategy)
108+
client.http.session.mount('https://', adapter)
109+
110+
res = client.http.post(f"/models/{identifier}/versions/{version}/load-process")
111+
112+
logger.info(f'Loading container image took [{1000*(time.time()-start)} ms]')
113+
114+
def upload_input_example(client, logger, identifier, version, model_data_metadata, input_sample_path):
115+
116+
start = time.time()
117+
118+
input_filename = model_data_metadata['inputs'][0]['name']
119+
files = {'file': open(input_sample_path, 'rb')}
120+
params = {'name': input_filename}
121+
res = client.http.post(f"/models/{identifier}/versions/{version}/testInput", params=params, file_data=files)
122+
123+
logger.info(f'Uploading sample input took [{1000*(time.time()-start)} ms]')
124+
125+
def run_model(client, logger, identifier, version):
126+
127+
start = time.time()
128+
res = client.http.post(f"/models/{identifier}/versions/{version}/run-process")
129+
130+
percentage = -1
131+
while percentage < 100:
132+
try:
133+
res = client.http.get(f"/models/{identifier}/versions/{version}/run-process")
134+
new_percentage = res.get('percentage')
135+
except NetworkError:
136+
continue
137+
138+
if new_percentage != percentage:
139+
logger.info(f'Running model at {new_percentage}%')
140+
print(f'Running model at {new_percentage}%')
141+
percentage = new_percentage
142+
143+
time.sleep(1)
144+
145+
test_output = res['result']
146+
147+
sample_input = {'input': {'accessKeyID': '<accessKeyID>',
148+
'region': '<region>',
149+
'secretAccessKey': '<secretAccessKey>',
150+
'sources': {'0001': {'input': {'bucket': '<bucket>',
151+
'key': '/path/to/s3/input'}}},
152+
'type': 'aws-s3'},
153+
'model': {'identifier': identifier, 'version':version}
154+
}
155+
156+
formatted_sample_output = {'jobIdentifier': '<uuid>',
157+
'total': '<number of inputs>',
158+
'completed': '<total number of completed inputs>',
159+
'failed': '<number of failed inputs>',
160+
'finished': '<true or false>',
161+
'submittedByKey': '<api key>',
162+
'results': {'<input-id>': {'model': None,
163+
'userIdentifier': None,
164+
'status': test_output['status'],
165+
'engine': test_output['engine'],
166+
'error': test_output['error'],
167+
'startTime': test_output['startTime'],
168+
'endTime': test_output['endTime'],
169+
'updateTime': test_output['updateTime'],
170+
'inputSize': test_output['inputSize'],
171+
'accessKey': None,
172+
'teamIdentifier': None,
173+
'accountIdentifier': None,
174+
'timeMeters': None,
175+
'datasourceCompletedTime': None,
176+
'elapsedTime': test_output['elapsedTime'],
177+
'results.json': test_output['results.json']}
178+
}
179+
}
180+
181+
sample_input_res = client.http.put(f"/models/{identifier}/versions/{version}/sample-input", json_data=sample_input)
182+
sample_output_res = client.http.put(f"/models/{identifier}/versions/{version}/sample-output", json_data=formatted_sample_output)
183+
184+
logger.info(f'Inference test took [{1000*(time.time()-start)} ms]')
185+
186+
def deploy_model(client, logger, identifier, version):
187+
188+
start = time.time()
189+
status = {'status': 'active'}
190+
191+
res = client.http.patch(f"/models/{identifier}/versions/{version}", status)
192+
193+
logger.info(f'Model Deployment took [{1000*(time.time()-start)} ms]')
194+

modzy/http.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def __init__(self, api_client, session=None):
4848
self.session = session if session is not None else requests.Session()
4949
self.logger = logging.getLogger(__name__)
5050

51-
def request(self, method, url, json_data=None, file_data=None):
51+
def request(self, method, url, json_data=None, file_data=None, params=None):
5252
"""Sends an HTTP request.
5353
5454
The client's API key will automatically be used for authentication.
@@ -82,7 +82,7 @@ def request(self, method, url, json_data=None, file_data=None):
8282
self.logger.debug("%s: %s - [%s]", method, url, self._api_client.cert)
8383

8484
try:
85-
response = self.session.request(method, url, data=data, headers=headers, files=file_data, verify=self._api_client.cert)
85+
response = self.session.request(method, url, data=data, headers=headers, files=file_data, verify=self._api_client.cert, params=params)
8686
self.logger.debug("response %s - length %s", response.status_code, len(response.content))
8787
except requests.exceptions.RequestException as ex:
8888
self.logger.exception('unable to make network request')
@@ -126,7 +126,7 @@ def get(self, url):
126126
"""
127127
return self.request('GET', url)
128128

129-
def post(self, url, json_data=None, file_data=None):
129+
def post(self, url, json_data=None, file_data=None, params=None):
130130
"""Sends a POST request.
131131
132132
Args:
@@ -140,7 +140,7 @@ def post(self, url, json_data=None, file_data=None):
140140
ApiError: A subclass of ApiError will be raised if the API returns an error status,
141141
or the client is unable to connect.
142142
"""
143-
return self.request('POST', url, json_data=json_data, file_data=file_data)
143+
return self.request('POST', url, json_data=json_data, file_data=file_data, params=params)
144144

145145
def patch(self, url, json_data=None):
146146
"""Sends a PATCH request.

modzy/models.py

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
# -*- coding: utf-8 -*-
22
"""Classes for interacting with models."""
33

4+
import re
5+
import json
46
import logging
57
from datetime import datetime
68
from ._api_object import ApiObject
79
from urllib.parse import urlencode
8-
from .error import NotFoundError, ResponseError
9-
from typing import Union
10+
from .error import NotFoundError, ResponseError, BadRequestError
1011
from time import time as t
1112
from time import sleep
13+
from ._util import load_model, upload_input_example, run_model, deploy_model
1214

1315
class Models:
1416
"""The `Models` object.
@@ -30,6 +32,24 @@ def __init__(self, api_client):
3032
"""
3133
self._api_client = api_client
3234
self.logger = logging.getLogger(__name__)
35+
# model deployment specific instance variables
36+
self.container_registry_regex = "^((?:[A-Za-z0-9-_]+)(?:\.[A-Za-z0-9-_]+)+\/)?([^:]+)(?::(.+))?$"
37+
self.default_inputs = [
38+
{
39+
"name": "input",
40+
"acceptedMediaTypes": "application/json",
41+
"maximumSize": 1000000,
42+
"description": "Default input data"
43+
}
44+
]
45+
self.default_outputs = [
46+
{
47+
"name": "results.json",
48+
"mediaType": "application/json",
49+
"maximumSize": 1000000,
50+
"description": "Default output data"
51+
}
52+
]
3353

3454
def get_model_processing_details(self, model, version):
3555
"""
@@ -47,7 +67,6 @@ def get_model_processing_details(self, model, version):
4767
"""
4868
model_id = Model._coerce_identifier(model)
4969

50-
# TODO: this was moved from the models api to the resources api, perhaps it should go in a different module?
5170
endpoint = "/resources/processing/models"
5271

5372
result = self._api_client.http.get(endpoint)
@@ -363,6 +382,127 @@ def get_models(self, model_id=None, author=None, created_by_email=None, name=Non
363382
json_list = self._api_client.http.get('{}?{}'.format(self._base_route, urlencode(body)))
364383
return list(Model(json_obj, self._api_client) for json_obj in json_list)
365384

385+
def deploy(
386+
self, container_image, model_name, model_version, sample_input_file, credentials=None,
387+
model_id=None, run_timeout=None, status_timeout=None, short_description=None, tags=[],
388+
gpu=False, long_description=None, technical_details=None, performance_summary=None,
389+
performance_metrics=None, input_details=None, output_details=None
390+
):
391+
"""Deploys a new `Model` instance.
392+
393+
Args:
394+
container_image (str): Docker container image to be deployed. This string should represent what follows a `docker pull` command
395+
model_name (str): Name of model to be deployed
396+
model_version (str): Version of model to be deployed
397+
sample_input_file (str): Path to local file to be used for sample inference
398+
credentials (dict): Dictionary containing credentials if the container image is private. The keys in this dictionary must be `["user", "pass"]`
399+
model_id (str): Model identifier if deploying a new version to a model that already exists
400+
run_timeout (str): Timeout threshold for container `run` route
401+
status_timeout (str): Timeout threshold for container `status` route
402+
short_description (str): Short description to appear on model biography page
403+
tags (list): List of tags to make model more discoverable in model library
404+
gpu (bool): Flag for whether or not model requires GPU to run
405+
long_description (str): Description to appear on model biography page
406+
technical_details (str): Technical details to appear on model biography page. Markdown is accepted
407+
performance_summary (str): Description providing model performance to appear on model biography page
408+
performance_metrics (List): List of arrays describing model performance statistics
409+
input_details (List): List of dictionaries describing details of model inputs
410+
output_details (List): List of dictionaries describing details of model outputs
411+
412+
Returns:
413+
dict: Newly deployed model information including formatted URL to newly deployed model page.
414+
Raises:
415+
ApiError: A subclass of ApiError will be raised if the API returns an error status,
416+
or the client is unable to connect.
417+
"""
418+
# generate model identifier and version to create new model
419+
if model_id:
420+
identifier, version = model_id, model_version
421+
# create new version of existing model
422+
data = {"version": version}
423+
try:
424+
response = self._api_client.http.post(f"{self._base_route}/{identifier}/versions", data)
425+
except BadRequestError as e:
426+
raise e
427+
else:
428+
# create new model object
429+
data = {'name': model_name, 'version': model_version}
430+
response = self._api_client.http.post(self._base_route, data)
431+
identifier, version = response.get('identifier'), model_version
432+
433+
self.logger.info(f"Created Model Version: {identifier}, {version}")
434+
435+
# add tags and description
436+
tags_and_description = {
437+
'description': short_description or ''
438+
}
439+
if len(tags) > 0:
440+
tags_and_description['tags'] = tags
441+
response = self._api_client.http.patch(f"{self._base_route}/{identifier}", tags_and_description)
442+
443+
# upload container image
444+
m = re.search(self.container_registry_regex, container_image)
445+
domain = m.group(1) or "registry.hub.docker.com/"
446+
repository = m.group(2)
447+
tag = m.group(3) or "latest"
448+
image_url = "https://{}v2/{}/manifests/{}".format(domain, repository, tag)
449+
registry = {'registry': {'url': image_url, 'username': credentials['user'], 'password': credentials['pass']}} if credentials else {'registry': {'url': image_url}}
450+
response = self._api_client.http.post(f"{self._base_route}/{identifier}/versions/{version}/container-image", registry)
451+
self.logger.info("Uploaded Container Image")
452+
453+
# add model metadata
454+
run_timeout_body = int(run_timeout)*1000 if run_timeout else 60000
455+
status_timeout_body = int(status_timeout)*1000 if status_timeout else 60000
456+
457+
model_metadata = {
458+
"requirement": {"requirementId": -6 if gpu else 1},
459+
"timeout": {
460+
"run": run_timeout_body,
461+
"status": status_timeout_body
462+
},
463+
"inputs": input_details or self.default_inputs,
464+
"outputs": output_details or self.default_outputs,
465+
"statistics": performance_metrics or [],
466+
"processing": {
467+
"minimumParallelCapacity": 0,
468+
"maximumParallelCapacity": 1
469+
},
470+
"longDescription": long_description or "",
471+
"technicalDetails": technical_details or "",
472+
"performanceSummary": performance_summary or ""
473+
}
474+
model_data = self._api_client.http.patch(f"{self._base_route}/{identifier}/versions/{version}", model_metadata)
475+
self.logger.info(f"Model Data: {json.dumps(model_data)}")
476+
477+
# load model container
478+
try:
479+
load_model(self._api_client, self.logger, identifier, version)
480+
except Exception as e:
481+
raise ValueError("Loading model container failed. Make sure you passed through a valid Docker registry container image. \n\nSee full error below:\n{}".format(e))
482+
# upload sample data for inference test
483+
try:
484+
upload_input_example(self._api_client, self.logger, identifier, version, model_data, sample_input_file)
485+
except Exception as e:
486+
raise ValueError("Uploading sample input failed. \n\nSee full error below:\n{}".format(e))
487+
# run sample inference
488+
try:
489+
run_model(self._api_client, self.logger, identifier, version)
490+
except Exception as e:
491+
raise ValueError("Inference test failed. Make sure the provided input sample is valid and your model can process it for inference. \n\nSee full error below:\n{}".format(e))
492+
# deploy model pending all tests have passed
493+
try:
494+
deploy_model(self._api_client, self.logger, identifier, version)
495+
except Exception as e:
496+
raise ValueError("Deployment failed. Check to make sure all of your parameters and assets are valid and try again. \n\nSee full error below:\n{}".format(e))
497+
498+
# get new model URL and return model data
499+
base_url = self._api_client.base_url.split("api")[0][:-1]
500+
container_data = {
501+
'model_data': json.dumps(model_data),
502+
'container_url': f"{base_url}{self._base_route}/{identifier}/{version}"
503+
}
504+
return container_data
505+
366506

367507
class Model(ApiObject):
368508
"""A model object.

0 commit comments

Comments
 (0)