Skip to content

Commit fff1db4

Browse files
authored
Improve python client cross python version compatibility (#1640)
1 parent cdd67e7 commit fff1db4

File tree

5 files changed

+209
-90
lines changed

5 files changed

+209
-90
lines changed

dev/deploy_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2020 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+
# USAGE: python ./dev/deploy_test.py <env_name>
16+
# e.g.: python ./dev/deploy_test.py aws
17+
18+
import os
19+
import cortex
20+
import sys
21+
import requests
22+
23+
cx = cortex.client(sys.argv[1])
24+
api_config = {
25+
"name": "text-generator",
26+
"kind": "RealtimeAPI",
27+
}
28+
29+
30+
class PythonPredictor:
31+
def __init__(self, config):
32+
from transformers import pipeline
33+
34+
self.model = pipeline(task="text-generation")
35+
36+
def predict(self, payload):
37+
return self.model(payload["text"])[0]
38+
39+
40+
api = cx.deploy(
41+
api_config,
42+
predictor=PythonPredictor,
43+
requirements=["torch", "transformers"],
44+
wait=True,
45+
)
46+
47+
response = requests.post(
48+
api["endpoint"],
49+
json={"text": "machine learning is great because"},
50+
)
51+
52+
print(response.status_code)
53+
print(response.text)
54+
55+
cx.delete_api(api_config["name"])

dev/python_version_test.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/bin/bash
2+
3+
# Copyright 2020 Cortex Labs, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
18+
# USAGE: ./dev/python_version_test.sh <python version> <env_name>
19+
# e.g.: ./dev/python_version_test.sh 3.6.9 aws
20+
21+
set -e
22+
23+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. >/dev/null && pwd)"
24+
25+
# create a new conda environment based on the supplied python version
26+
conda create -n env -y
27+
CONDA_BASE=$(conda info --base)
28+
source $CONDA_BASE/etc/profile.d/conda.sh
29+
conda activate env
30+
conda config --append channels conda-forge
31+
conda install python=$1 -y
32+
33+
pip install requests
34+
35+
export CORTEX_CLI_PATH=$ROOT/bin/cortex
36+
37+
# install cortex
38+
cd $ROOT/pkg/workloads/cortex/client
39+
pip install -e .
40+
41+
# run script.py
42+
python $ROOT/dev/deploy_test.py $2
43+
44+
# clean up conda
45+
conda deactivate
46+
conda env remove -n env
47+
rm -rf $ROOT/pkg/workloads/cortex/client/cortex.egg-info

docs/miscellaneous/python-client.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Delete an environment configured on this machine.
107107
<!-- CORTEX_VERSION_MINOR x5 -->
108108

109109
```python
110-
| deploy(api_spec: dict, predictor=None, pip_dependencies=[], conda_dependencies=[], project_dir: Optional[str] = None, force: bool = False, wait: bool = False) -> list
110+
| deploy(api_spec: dict, predictor=None, requirements=[], conda_packages=[], project_dir: Optional[str] = None, force: bool = True, wait: bool = False) -> list
111111
```
112112

113113
Deploy an API.
@@ -121,8 +121,8 @@ Deploy an API.
121121
- `predictor` - A Cortex Predictor class implementation. Not required when deploying a traffic splitter.
122122
→ Realtime API: https://docs.cortex.dev/v/master/deployments/realtime-api/predictors
123123
→ Batch API: https://docs.cortex.dev/v/master/deployments/batch-api/predictors
124-
- `pip_dependencies` - A list of PyPI dependencies that will be installed before the predictor class implementation is invoked.
125-
- `conda_dependencies` - A list of Conda dependencies that will be installed before the predictor class implementation is invoked.
124+
- `requirements` - A list of PyPI dependencies that will be installed before the predictor class implementation is invoked.
125+
- `conda_packages` - A list of Conda dependencies that will be installed before the predictor class implementation is invoked.
126126
- `project_dir` - Path to a python project.
127127
- `force` - Override any in-progress api updates.
128128
- `wait` - Streams logs until the APIs are ready.

pkg/workloads/cortex/client/cortex/client.py

Lines changed: 99 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@
2222
import uuid
2323
import dill
2424
import inspect
25+
import shutil
2526
from pathlib import Path
2627

2728
from typing import List, Dict, Optional, Tuple, Callable, Union
2829
from cortex.binary import run_cli, get_cli_path
2930
from cortex import util
3031

32+
# Change if PYTHONVERSION changes
33+
EXPECTED_PYTHON_VERSION = "3.6.9"
34+
3135

3236
class Client:
3337
def __init__(self, env: str):
@@ -44,10 +48,10 @@ def deploy(
4448
self,
4549
api_spec: dict,
4650
predictor=None,
47-
pip_dependencies=[],
48-
conda_dependencies=[],
51+
requirements=[],
52+
conda_packages=[],
4953
project_dir: Optional[str] = None,
50-
force: bool = False,
54+
force: bool = True,
5155
wait: bool = False,
5256
) -> list:
5357
"""
@@ -61,8 +65,8 @@ def deploy(
6165
predictor: A Cortex Predictor class implementation. Not required when deploying a traffic splitter.
6266
→ Realtime API: https://docs.cortex.dev/v/master/deployments/realtime-api/predictors
6367
→ Batch API: https://docs.cortex.dev/v/master/deployments/batch-api/predictors
64-
pip_dependencies: A list of PyPI dependencies that will be installed before the predictor class implementation is invoked.
65-
conda_dependencies: A list of Conda dependencies that will be installed before the predictor class implementation is invoked.
68+
requirements: A list of PyPI dependencies that will be installed before the predictor class implementation is invoked.
69+
conda_packages: A list of Conda dependencies that will be installed before the predictor class implementation is invoked.
6670
project_dir: Path to a python project.
6771
force: Override any in-progress api updates.
6872
wait: Streams logs until the APIs are ready.
@@ -83,62 +87,67 @@ def deploy(
8387
yaml.dump([api_spec], f) # write a list
8488
return self._deploy(cortex_yaml_path, force, wait)
8589

86-
project_dir = Path.home() / ".cortex" / "deployments" / str(uuid.uuid4())
87-
with util.open_tempdir(str(project_dir)):
88-
cortex_yaml_path = os.path.join(project_dir, "cortex.yaml")
89-
90-
if predictor is None:
91-
# for deploying a traffic splitter
92-
with open(cortex_yaml_path, "w") as f:
93-
yaml.dump([api_spec], f) # write a list
94-
return self._deploy(cortex_yaml_path, force=force, wait=wait)
95-
96-
# Change if PYTHONVERSION changes
97-
expected_version = "3.6"
98-
actual_version = f"{sys.version_info.major}.{sys.version_info.minor}"
99-
if actual_version < expected_version:
100-
raise Exception("cortex is only supported for python versions >= 3.6") # unexpected
101-
if actual_version > expected_version:
102-
is_python_set = any(
103-
conda_dep.startswith("python=") or "::python=" in conda_dep
104-
for conda_dep in conda_dependencies
105-
)
106-
107-
if not is_python_set:
108-
conda_dependencies = [
109-
f"conda-forge::python={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
110-
] + conda_dependencies
111-
112-
if len(pip_dependencies) > 0:
113-
with open(project_dir / "requirements.txt", "w") as requirements_file:
114-
requirements_file.write("\n".join(pip_dependencies))
115-
116-
if len(conda_dependencies) > 0:
117-
with open(project_dir / "conda-packages.txt", "w") as conda_file:
118-
conda_file.write("\n".join(conda_dependencies))
119-
120-
if not inspect.isclass(predictor):
121-
raise ValueError("predictor parameter must be a class definition")
122-
123-
with open(project_dir / "predictor.pickle", "wb") as pickle_file:
124-
dill.dump(predictor, pickle_file)
125-
if api_spec.get("predictor") is None:
126-
api_spec["predictor"] = {}
127-
128-
if predictor.__name__ == "PythonPredictor":
129-
predictor_type = "python"
130-
if predictor.__name__ == "TensorFlowPredictor":
131-
predictor_type = "tensorflow"
132-
if predictor.__name__ == "ONNXPredictor":
133-
predictor_type = "onnx"
134-
135-
api_spec["predictor"]["path"] = "predictor.pickle"
136-
api_spec["predictor"]["type"] = predictor_type
90+
if api_spec.get("name") is None:
91+
raise ValueError("`api_spec` must have the `name` key set")
92+
93+
project_dir = Path.home() / ".cortex" / "deployments" / api_spec["name"]
94+
95+
if project_dir.exists():
96+
shutil.rmtree(str(project_dir))
97+
98+
project_dir.mkdir(parents=True)
99+
100+
cortex_yaml_path = os.path.join(project_dir, "cortex.yaml")
137101

102+
if predictor is None:
103+
# for deploying a traffic splitter
138104
with open(cortex_yaml_path, "w") as f:
139105
yaml.dump([api_spec], f) # write a list
140106
return self._deploy(cortex_yaml_path, force=force, wait=wait)
141107

108+
actual_version = (
109+
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
110+
)
111+
112+
if actual_version != EXPECTED_PYTHON_VERSION:
113+
is_python_set = any(
114+
conda_dep.startswith("python=") or "::python=" in conda_dep
115+
for conda_dep in conda_packages
116+
)
117+
118+
if not is_python_set:
119+
conda_packages = [f"python={actual_version}"] + conda_packages
120+
121+
if len(requirements) > 0:
122+
with open(project_dir / "requirements.txt", "w") as requirements_file:
123+
requirements_file.write("\n".join(requirements))
124+
125+
if len(conda_packages) > 0:
126+
with open(project_dir / "conda-packages.txt", "w") as conda_file:
127+
conda_file.write("\n".join(conda_packages))
128+
129+
if not inspect.isclass(predictor):
130+
raise ValueError("predictor parameter must be a class definition")
131+
132+
with open(project_dir / "predictor.pickle", "wb") as pickle_file:
133+
dill.dump(predictor, pickle_file)
134+
if api_spec.get("predictor") is None:
135+
api_spec["predictor"] = {}
136+
137+
if predictor.__name__ == "PythonPredictor":
138+
predictor_type = "python"
139+
if predictor.__name__ == "TensorFlowPredictor":
140+
predictor_type = "tensorflow"
141+
if predictor.__name__ == "ONNXPredictor":
142+
predictor_type = "onnx"
143+
144+
api_spec["predictor"]["path"] = "predictor.pickle"
145+
api_spec["predictor"]["type"] = predictor_type
146+
147+
with open(cortex_yaml_path, "w") as f:
148+
yaml.dump([api_spec], f) # write a list
149+
return self._deploy(cortex_yaml_path, force=force, wait=wait)
150+
142151
def _deploy(
143152
self,
144153
config_file: str,
@@ -164,6 +173,7 @@ def _deploy(
164173
self.env,
165174
"-o",
166175
"mixed",
176+
"-y",
167177
]
168178

169179
if force:
@@ -173,42 +183,44 @@ def _deploy(
173183

174184
deploy_results = json.loads(output.strip())
175185

186+
deploy_result = deploy_results[0]
187+
176188
if not wait:
177-
return deploy_results
189+
return deploy_result
178190

179191
def stream_to_stdout(process):
180192
for c in iter(lambda: process.stdout.read(1), ""):
181193
sys.stdout.write(c)
182-
183-
for deploy_result in deploy_results:
184-
api_name = deploy_result["api"]["spec"]["name"]
185-
kind = deploy_result["api"]["spec"]["kind"]
186-
if kind != "RealtimeAPI":
187-
continue
188-
189-
env = os.environ.copy()
190-
env["CORTEX_CLI_INVOKER"] = "python"
191-
process = subprocess.Popen(
192-
[get_cli_path(), "logs", "--env", self.env, api_name],
193-
stderr=subprocess.STDOUT,
194-
stdout=subprocess.PIPE,
195-
encoding="utf8",
196-
env=env,
197-
)
198-
199-
streamer = threading.Thread(target=stream_to_stdout, args=[process])
200-
streamer.start()
201-
202-
while process.poll() is None:
203-
api = self.get_api(api_name)
204-
if api["status"]["status_code"] != "status_updating":
205-
if api["status"]["status_code"] == "status_live":
206-
time.sleep(2)
207-
process.terminate()
208-
break
209-
time.sleep(2)
210-
211-
return deploy_results
194+
sys.stdout.flush()
195+
196+
api_name = deploy_result["api"]["spec"]["name"]
197+
if deploy_result["api"]["spec"]["kind"] != "RealtimeAPI":
198+
return deploy_result
199+
200+
env = os.environ.copy()
201+
env["CORTEX_CLI_INVOKER"] = "python"
202+
process = subprocess.Popen(
203+
[get_cli_path(), "logs", "--env", self.env, api_name],
204+
stderr=subprocess.STDOUT,
205+
stdout=subprocess.PIPE,
206+
encoding="utf8",
207+
errors="replace", # replace non-utf8 characters with `?` instead of failing
208+
env=env,
209+
)
210+
211+
streamer = threading.Thread(target=stream_to_stdout, args=[process])
212+
streamer.start()
213+
214+
while process.poll() is None:
215+
api = self.get_api(api_name)
216+
if api["status"]["status_code"] != "status_updating":
217+
time.sleep(10) # wait for logs to stream
218+
process.terminate()
219+
break
220+
time.sleep(5)
221+
streamer.join(timeout=10)
222+
223+
return api
212224

213225
def get_api(self, api_name: str) -> dict:
214226
"""

pkg/workloads/cortex/serve/init/bootloader.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,19 @@ if [ -f "/mnt/project/conda-packages.txt" ]; then
6363
py_version_cmd='echo $(python -c "import sys; v=sys.version_info[:2]; print(\"{}.{}\".format(*v));")'
6464
old_py_version=$(eval $py_version_cmd)
6565

66+
# look for packages in defaults and then conda-forge to improve chances of finding the package (specifically for python reinstalls)
67+
conda config --append channels conda-forge
68+
6669
conda install -y --file /mnt/project/conda-packages.txt
70+
6771
new_py_version=$(eval $py_version_cmd)
6872

6973
# reinstall core packages if Python version has changed
7074
if [ $old_py_version != $new_py_version ]; then
7175
echo "warning: you have changed the Python version from $old_py_version to $new_py_version; this may break Cortex's web server"
7276
echo "reinstalling core packages ..."
7377
pip --no-cache-dir install -r /src/cortex/serve/requirements.txt
78+
7479
rm -rf $CONDA_PREFIX/lib/python${old_py_version} # previous python is no longer needed
7580
fi
7681
fi

0 commit comments

Comments
 (0)