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

232 lines
7.9 KiB
Python

import os
import sys
from io import BytesIO
from stat import S_IRGRP, S_IROTH, S_IRUSR, S_IXGRP, S_IXOTH, S_IXUSR
from typing import NamedTuple
import numpy as np
import pandas as pd
import pytest
import sklearn
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
import mlflow
from mlflow.environment_variables import MLFLOW_ENV_ROOT
from mlflow.pyfunc.scoring_server import CONTENT_TYPE_JSON
from mlflow.utils.environment import _PYTHON_ENV_FILE_NAME, _REQUIREMENTS_FILE_NAME
from mlflow.utils.virtualenv import _is_pyenv_available
from tests.helper_functions import pyfunc_serve_and_score_model
pytestmark = pytest.mark.skipif(
not _is_pyenv_available(),
reason="requires pyenv",
)
TEST_DIR = "tests"
TEST_MLFLOW_1X_MODEL_DIR = os.path.join(TEST_DIR, "resources", "example_mlflow_1x_sklearn_model")
class Model(NamedTuple):
model: LogisticRegression
X_pred: pd.DataFrame
y_pred: np.ndarray
@pytest.fixture(scope="module")
def sklearn_model():
X, y = load_iris(return_X_y=True, as_frame=True)
model = LogisticRegression().fit(X, y)
X_pred = X.sample(frac=0.1, random_state=0)
y_pred = model.predict(X_pred)
return Model(model, X_pred, y_pred)
def serve_and_score(model_uri, data, extra_args=None):
resp = pyfunc_serve_and_score_model(
model_uri,
data=data,
content_type=CONTENT_TYPE_JSON,
extra_args=["--env-manager=virtualenv"] + (extra_args or []),
)
return pd.read_json(BytesIO(resp.content), orient="records").values.squeeze()
@pytest.fixture
def temp_mlflow_env_root(tmp_path, monkeypatch):
env_root = tmp_path / "envs"
env_root.mkdir(exist_ok=True)
monkeypatch.setenv(MLFLOW_ENV_ROOT.name, str(env_root))
return env_root
use_temp_mlflow_env_root = pytest.mark.usefixtures(temp_mlflow_env_root.__name__)
@use_temp_mlflow_env_root
def test_restore_environment_with_virtualenv(sklearn_model):
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
@use_temp_mlflow_env_root
def test_serve_and_score_read_only_model_directory(sklearn_model, tmp_path):
model_path = str(tmp_path / "model")
mlflow.sklearn.save_model(sklearn_model.model, path=model_path)
os.chmod(
model_path,
S_IRUSR | S_IRGRP | S_IROTH | S_IXUSR | S_IXGRP | S_IXOTH,
)
scores = serve_and_score(model_path, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
@use_temp_mlflow_env_root
def test_serve_and_score_1x_models():
X, _ = load_iris(return_X_y=True, as_frame=True)
X_pred = X.sample(frac=0.1, random_state=0)
loaded_model = mlflow.pyfunc.load_model(TEST_MLFLOW_1X_MODEL_DIR)
y_pred = loaded_model.predict(X_pred)
scores = serve_and_score(TEST_MLFLOW_1X_MODEL_DIR, X_pred)
np.testing.assert_array_almost_equal(scores, y_pred)
@use_temp_mlflow_env_root
def test_reuse_environment(temp_mlflow_env_root, sklearn_model):
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
# Serve the model
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
# Serve the model again. The environment created in the previous serving should be reused.
scores = serve_and_score(model_info.model_uri, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
assert len(list(temp_mlflow_env_root.iterdir())) == 1
@use_temp_mlflow_env_root
def test_different_requirements_create_different_environments(temp_mlflow_env_root, sklearn_model):
sklearn_req = f"scikit-learn=={sklearn.__version__}"
with mlflow.start_run():
model_info1 = mlflow.sklearn.log_model(
sklearn_model.model,
name="model",
pip_requirements=[sklearn_req],
)
scores = serve_and_score(model_info1.model_uri, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
# Log the same model with different requirements
with mlflow.start_run():
model_info2 = mlflow.sklearn.log_model(
sklearn_model.model,
name="model",
pip_requirements=[sklearn_req, "numpy"],
)
scores = serve_and_score(model_info2.model_uri, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
# Two environments should exist now because the first and second models have different
# requirements
assert len(list(temp_mlflow_env_root.iterdir())) == 2
@use_temp_mlflow_env_root
def test_environment_directory_is_cleaned_up_when_unexpected_error_occurs(
temp_mlflow_env_root, sklearn_model
):
sklearn_req = "scikit-learn==999.999.999"
with mlflow.start_run():
model_info1 = mlflow.sklearn.log_model(
sklearn_model.model,
name="model",
pip_requirements=[sklearn_req],
)
try:
serve_and_score(model_info1.model_uri, sklearn_model.X_pred)
except Exception:
pass
else:
assert False, "Should have raised an exception"
assert len(list(temp_mlflow_env_root.iterdir())) == 0
@use_temp_mlflow_env_root
def test_python_env_file_does_not_exist(sklearn_model, tmp_path):
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
python_env.unlink()
scores = serve_and_score(tmp_path, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
@use_temp_mlflow_env_root
def test_python_env_file_and_requirements_file_do_not_exist(sklearn_model, tmp_path):
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(sklearn_model.model, name="model")
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
python_env.unlink()
requirements = next(tmp_path.rglob(_REQUIREMENTS_FILE_NAME))
requirements.unlink()
scores = serve_and_score(tmp_path, sklearn_model.X_pred)
np.testing.assert_array_almost_equal(scores, sklearn_model.y_pred)
def test_environment_is_removed_when_package_installation_fails(
temp_mlflow_env_root, sklearn_model
):
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(
sklearn_model.model,
name="model",
# Enforce pip install to fail using a non-existent package version
pip_requirements=["mlflow==999.999.999"],
)
with pytest.raises(AssertionError, match="scoring process died"):
serve_and_score(model_info.model_uri, sklearn_model.X_pred)
assert len(list(temp_mlflow_env_root.iterdir())) == 0
@use_temp_mlflow_env_root
def test_restore_environment_from_conda_yaml_containing_conda_packages(sklearn_model, tmp_path):
conda_env = {
"name": "mlflow-env",
"channels": ["conda-forge"],
"dependencies": [
"python=" + ".".join(map(str, sys.version_info[:3])),
"conda-package=1.2.3", # conda package
"pip",
{
"pip": [
"mlflow",
f"scikit-learn=={sklearn.__version__}",
]
},
],
}
with mlflow.start_run():
model_info = mlflow.sklearn.log_model(
sklearn_model.model,
name="model",
conda_env=conda_env,
)
mlflow.artifacts.download_artifacts(artifact_uri=model_info.model_uri, dst_path=tmp_path)
python_env = next(tmp_path.rglob(_PYTHON_ENV_FILE_NAME))
python_env.unlink()
serve_and_score(tmp_path, sklearn_model.X_pred)