Skip to content

Commit c17598d

Browse files
authored
Cortex python client (#488)
1 parent 5878bff commit c17598d

File tree

10 files changed

+271
-19
lines changed

10 files changed

+271
-19
lines changed

dev/versions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Note: it's ok if example training notebooks aren't upgraded, as long as the expo
103103
* [flask](https://pypi.org/project/flask/)
104104
* [flask-api](https://pypi.org/project/flask-api/)
105105
* [waitress](https://pypi.org/project/waitress/)
106+
* [dill](https://pypi.org/project/dill/)
106107
1. Update the versions listed in "Pre-installed Packages" in `request-handlers.py`
107108

108109
## Istio

docs/cluster/python-client.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Python Client
2+
3+
The Python client can be used to programmatically deploy models to a Cortex Cluster.
4+
5+
<!-- CORTEX_VERSION_MINOR -->
6+
```
7+
pip install git+https://github.com/cortexlabs/cortex.git@master#egg=cortex\&subdirectory=pkg/workloads/cortex/client
8+
```
9+
10+
The Python client needs to be initialized with AWS credentials and an operator URL for your Cortex cluster. You can find the operator URL by running `./cortex.sh endpoints`.
11+
12+
```python
13+
from cortex import Client
14+
15+
cortex = Client(
16+
aws_access_key_id="<string>", # AWS access key associated with the account that the cluster is running on
17+
aws_secret_access_key="<string>", # AWS secret key associated with the AWS access key
18+
operator_url="<string>" # operator URL of your cluster
19+
)
20+
21+
api_url = cortex.deploy(
22+
deployment_name="<string>", # deployment name (required)
23+
api_name="<string>", # API name (required)
24+
model_path="<string>", # S3 path to an exported model (required)
25+
pre_inference=callable, # function used to prepare requests for model input
26+
post_inference=callable, # function used to prepare model output for response
27+
model_format="<string>", # model format, must be "tensorflow" or "onnx" (default: "onnx" if model path ends with .onnx, "tensorflow" if model path ends with .zip or is a directory)
28+
tf_serving_key="<string>" # name of the signature def to use for prediction (required if your model has more than one signature def)
29+
)
30+
```
31+
32+
`api_url` contains the URL of the deployed API. The API accepts JSON POST requests.
33+
34+
```python
35+
import requests
36+
37+
sample = {
38+
"feature_1": 'a',
39+
"feature_2": 'b',
40+
"feature_3": 'c'
41+
}
42+
43+
resp = requests.post(api_url, json=sample)
44+
resp.json()
45+
```

pkg/operator/api/schema/schema.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import (
2424
)
2525

2626
type DeployResponse struct {
27-
Message string `json:"message"`
27+
Message string `json:"message"`
28+
Context *context.Context `json:"context"`
29+
APIsBaseURL string `json:"apis_base_url"`
2830
}
2931

3032
type DeleteResponse struct {

pkg/operator/endpoints/deploy.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,29 @@ func Deploy(w http.ResponseWriter, r *http.Request) {
117117
return
118118
}
119119

120+
apisBaseURL, err := workloads.APIsBaseURL()
121+
if err != nil {
122+
RespondError(w, err)
123+
return
124+
}
125+
126+
deployResponse := schema.DeployResponse{Context: ctx, APIsBaseURL: apisBaseURL}
120127
switch {
121128
case isUpdating && ignoreCache:
122-
Respond(w, schema.DeployResponse{Message: ResCachedDeletedDeploymentStarted})
129+
deployResponse.Message = ResCachedDeletedDeploymentStarted
123130
case isUpdating && !ignoreCache:
124-
Respond(w, schema.DeployResponse{Message: ResDeploymentUpdated})
131+
deployResponse.Message = ResDeploymentUpdated
125132
case !isUpdating && ignoreCache:
126-
Respond(w, schema.DeployResponse{Message: ResCachedDeletedDeploymentStarted})
133+
deployResponse.Message = ResCachedDeletedDeploymentStarted
127134
case !isUpdating && !ignoreCache && existingCtx == nil:
128-
Respond(w, schema.DeployResponse{Message: ResDeploymentStarted})
135+
deployResponse.Message = ResDeploymentStarted
129136
case !isUpdating && !ignoreCache && existingCtx != nil && !fullCtxMatch:
130-
Respond(w, schema.DeployResponse{Message: ResDeploymentUpdated})
137+
deployResponse.Message = ResDeploymentUpdated
131138
case !isUpdating && !ignoreCache && existingCtx != nil && fullCtxMatch:
132-
Respond(w, schema.DeployResponse{Message: ResDeploymentUpToDate})
139+
deployResponse.Message = ResDeploymentUpToDate
133140
default:
134-
Respond(w, schema.DeployResponse{Message: ResDeploymentUpdated})
141+
deployResponse.Message = ResDeploymentUpdated
135142
}
143+
144+
Respond(w, deployResponse)
136145
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2019 Cortex Labs, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from cortex.client import Client
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright 2019 Cortex Labs, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pathlib
16+
from pathlib import Path
17+
import os
18+
import types
19+
import subprocess
20+
import sys
21+
import shutil
22+
import yaml
23+
import urllib.parse
24+
import base64
25+
26+
import dill
27+
import requests
28+
from requests.exceptions import HTTPError
29+
import msgpack
30+
31+
32+
class Client(object):
33+
def __init__(self, aws_access_key_id, aws_secret_access_key, operator_url):
34+
"""Initialize a Client to a Cortex Operator
35+
36+
Args:
37+
aws_access_key_id (string): AWS access key associated with the account that the cluster is running on
38+
aws_secret_access_key (string): AWS secret key associated with the AWS access key
39+
operator_url (string): operator URL of your cluster
40+
"""
41+
42+
self.operator_url = operator_url
43+
self.workspace = str(Path.home() / ".cortex" / "workspace")
44+
self.aws_access_key_id = aws_access_key_id
45+
self.aws_secret_access_key = aws_secret_access_key
46+
self.headers = {
47+
"CortexAPIVersion": "master",
48+
"Authorization": "CortexAWS {}|{}".format(
49+
self.aws_access_key_id, self.aws_secret_access_key
50+
),
51+
}
52+
53+
pathlib.Path(self.workspace).mkdir(parents=True, exist_ok=True)
54+
55+
def deploy(
56+
self,
57+
deployment_name,
58+
api_name,
59+
model_path,
60+
pre_inference=None,
61+
post_inference=None,
62+
model_format=None,
63+
tf_serving_key=None,
64+
):
65+
"""Deploy an API
66+
67+
Args:
68+
deployment_name (string): deployment name
69+
api_name (string): API name
70+
model_path (string): S3 path to an exported model
71+
pre_inference (function, optional): function used to prepare requests for model input
72+
post_inference (function, optional): function used to prepare model output for response
73+
model_format (string, optional): model format, must be "tensorflow" or "onnx" (default: "onnx" if model path ends with .onnx, "tensorflow" if model path ends with .zip or is a directory)
74+
tf_serving_key (string, optional): name of the signature def to use for prediction (required if your model has more than one signature def)
75+
76+
Returns:
77+
string: url to the deployed API
78+
"""
79+
80+
working_dir = os.path.join(self.workspace, deployment_name)
81+
api_working_dir = os.path.join(working_dir, api_name)
82+
pathlib.Path(api_working_dir).mkdir(parents=True, exist_ok=True)
83+
84+
api_config = {"kind": "api", "model": model_path, "name": api_name}
85+
86+
if tf_serving_key is not None:
87+
api_config["model_format"] = tf_serving_key
88+
89+
if model_format is not None:
90+
api_config["model_format"] = model_format
91+
92+
if pre_inference is not None or post_inference is not None:
93+
reqs = subprocess.check_output([sys.executable, "-m", "pip", "freeze"])
94+
95+
with open(os.path.join(api_working_dir, "requirements.txt"), "w") as f:
96+
f.writelines(reqs.decode())
97+
98+
handlers = {}
99+
100+
if pre_inference is not None:
101+
handlers["pre_inference"] = pre_inference
102+
103+
if post_inference is not None:
104+
handlers["post_inference"] = post_inference
105+
106+
with open(os.path.join(api_working_dir, "request_handler.pickle"), "wb") as f:
107+
dill.dump(handlers, f, recurse=True)
108+
109+
api_config["request_handler"] = "request_handler.pickle"
110+
111+
cortex_config = [{"kind": "deployment", "name": deployment_name}, api_config]
112+
113+
cortex_yaml_path = os.path.join(working_dir, "cortex.yaml")
114+
with open(cortex_yaml_path, "w") as f:
115+
f.write(yaml.dump(cortex_config))
116+
117+
project_zip_path = os.path.join(working_dir, "project")
118+
shutil.make_archive(project_zip_path, "zip", api_working_dir)
119+
project_zip_path += ".zip"
120+
121+
queries = {"force": "false", "ignoreCache": "false"}
122+
123+
with open(cortex_yaml_path, "rb") as config, open(project_zip_path, "rb") as project:
124+
files = {"cortex.yaml": config, "project.zip": project}
125+
try:
126+
resp = requests.post(
127+
urllib.parse.urljoin(self.operator_url, "deploy"),
128+
params=queries,
129+
files=files,
130+
headers=self.headers,
131+
verify=False,
132+
)
133+
resp.raise_for_status()
134+
resources = resp.json()
135+
except HTTPError as err:
136+
resp = err.response
137+
if "error" in resp.json():
138+
raise Exception(resp.json()["error"]) from err
139+
raise
140+
141+
b64_encoded_context = resources["context"]
142+
context_msgpack_bytestring = base64.b64decode(b64_encoded_context)
143+
ctx = msgpack.loads(context_msgpack_bytestring, raw=False)
144+
return urllib.parse.urljoin(resources["apis_base_url"], ctx["apis"][api_name]["path"])

pkg/workloads/cortex/client/setup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2019 Cortex Labs, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from setuptools import setup, find_packages
16+
17+
setup(
18+
name="cortex",
19+
version="0.8.0",
20+
description="",
21+
author="Cortex Labs",
22+
author_email="[email protected]",
23+
install_requires=["dill>=0.3.0", "requests>=2.20.0", "msgpack>=0.6.0"],
24+
setup_requires=["setuptools"],
25+
packages=find_packages(),
26+
)

pkg/workloads/cortex/lib/context.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import os
1616
import imp
1717
import inspect
18+
1819
import boto3
1920
import datadog
21+
import dill
2022

2123
from cortex import consts
2224
from cortex.lib import util
@@ -108,10 +110,22 @@ def download_python_file(self, impl_key, module_name):
108110

109111
def load_module(self, module_prefix, module_name, impl_path):
110112
full_module_name = "{}_{}".format(module_prefix, module_name)
111-
try:
112-
impl = imp.load_source(full_module_name, impl_path)
113-
except Exception as e:
114-
raise UserException("unable to load python file", str(e)) from e
113+
114+
if impl_path.endswith(".pickle"):
115+
try:
116+
impl = imp.new_module(full_module_name)
117+
118+
with open(impl_path, "rb") as pickle_file:
119+
pickled_dict = dill.load(pickle_file)
120+
for key in pickled_dict:
121+
setattr(impl, key, pickled_dict[key])
122+
except Exception as e:
123+
raise UserException("unable to load pickle", str(e)) from e
124+
else:
125+
try:
126+
impl = imp.load_source(full_module_name, impl_path)
127+
except Exception as e:
128+
raise UserException("unable to load python file", str(e)) from e
115129

116130
return impl
117131

pkg/workloads/cortex/lib/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ numpy==1.17.2
44
json_tricks==3.13.2
55
requests==2.22.0
66
datadog==0.30.0
7+
dill==0.3.0

pkg/workloads/cortex/lib/util.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import zipfile
2424
import hashlib
2525
import msgpack
26+
import pathlib
2627
from copy import deepcopy
2728
from datetime import datetime
2829

@@ -62,13 +63,7 @@ def snake_to_camel(input, sep="_", lower=True):
6263

6364

6465
def mkdir_p(dir_path):
65-
try:
66-
os.makedirs(dir_path)
67-
except OSError as e:
68-
if e.errno == errno.EEXIST and os.path.isdir(dir_path):
69-
pass
70-
else:
71-
raise
66+
pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True)
7267

7368

7469
def rm_dir(dir_path):

0 commit comments

Comments
 (0)