项目文件夹

文件
2026-07-13 13:22:34 +08:00

424 行
15 KiB
Python

import subprocess
import sys
import tarfile
import venv
from pathlib import Path
from unittest import mock
import pytest
import yaml
from mlflow.exceptions import MlflowException
from mlflow.utils import env_pack
from mlflow.utils.databricks_utils import DatabricksRuntimeVersion
from mlflow.utils.env_pack import EnvPackConfig, _validate_env_pack
@pytest.fixture
def mock_dbr_version():
with mock.patch.object(
DatabricksRuntimeVersion,
"parse",
return_value=DatabricksRuntimeVersion(
is_client_image=True,
major=2,
minor=0,
is_gpu_image=False,
),
):
yield
def test_tar_function_path_handling(tmp_path):
# Create test files
root_dir = tmp_path / "root"
root_dir.mkdir()
(root_dir / "test.txt").write_text("test content")
(root_dir / "__pycache__").mkdir()
(root_dir / "__pycache__" / "test.pyc").write_text("bytecode")
(root_dir / "wheels_info.json").write_text("{}")
# Create tar file
tar_path = tmp_path / "test.tar"
env_pack._tar(root_dir, tar_path)
# Verify tar contents
with tarfile.open(tar_path) as tar:
members = tar.getmembers()
names = {m.name for m in members}
assert names == {".", "./test.txt"}
def test_pack_env_for_databricks_model_serving_pip_requirements(tmp_path, mock_dbr_version):
"""Test that pack_env_for_databricks_model_serving correctly handles pip requirements
installation.
"""
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "artifacts"
mock_artifacts_dir.mkdir()
(mock_artifacts_dir / "requirements.txt").write_text("numpy==1.21.0")
# Create MLmodel file with correct runtime version
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.0",
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Create a mock environment directory
mock_env_dir = tmp_path / "mock_env"
venv.create(mock_env_dir, with_pip=True)
with (
mock.patch(
"mlflow.utils.env_pack.download_artifacts",
return_value=str(mock_artifacts_dir),
),
mock.patch("subprocess.run") as mock_run,
mock.patch("sys.prefix", str(mock_env_dir)),
):
# Mock subprocess.run to simulate successful pip install
mock_run.return_value = mock.Mock(returncode=0)
with env_pack.pack_env_for_databricks_model_serving(
"models:/test-model/1", enforce_pip_requirements=True
) as artifacts_dir:
# Verify artifacts directory exists and contains expected files
artifacts_path = Path(artifacts_dir)
assert artifacts_path.exists()
assert (artifacts_path / env_pack._ARTIFACT_PATH).exists()
assert (artifacts_path / env_pack._ARTIFACT_PATH / env_pack._MODEL_VERSION_TAR).exists()
assert (
artifacts_path / env_pack._ARTIFACT_PATH / env_pack._MODEL_ENVIRONMENT_TAR
).exists()
# Verify the environment tar contains our mock files
env_tar_path = (
artifacts_path / env_pack._ARTIFACT_PATH / env_pack._MODEL_ENVIRONMENT_TAR
)
with tarfile.open(env_tar_path, "r:tar") as tar:
members = tar.getmembers()
member_names = {m.name for m in members}
# Check for pip in site-packages based on platform
if sys.platform == "win32":
expected_pip_path = "./Lib/site-packages/pip"
else:
expected_pip_path = (
f"./lib/python{sys.version_info.major}.{sys.version_info.minor}"
"/site-packages/pip"
)
assert expected_pip_path in member_names
# Verify subprocess.run was called with correct arguments
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
assert args[0] == [
sys.executable,
"-m",
"pip",
"install",
"-r",
str(mock_artifacts_dir / "requirements.txt"),
]
assert kwargs["check"] is True
assert kwargs["stdout"] == subprocess.PIPE
assert kwargs["stderr"] == subprocess.STDOUT
assert kwargs["text"] is True
def test_pack_env_for_databricks_model_serving_pip_requirements_error(tmp_path, mock_dbr_version):
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "artifacts"
mock_artifacts_dir.mkdir()
(mock_artifacts_dir / "requirements.txt").write_text("invalid-package==1.0.0")
# Create MLmodel file with correct runtime version
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.0",
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
with (
mock.patch(
"mlflow.utils.env_pack.download_artifacts",
return_value=str(mock_artifacts_dir),
),
mock.patch("subprocess.run") as mock_run,
mock.patch("mlflow.utils.env_pack.eprint") as mock_eprint,
):
mock_run.return_value = mock.Mock(
returncode=1,
stdout="ERROR: Could not find a version that satisfies the requirement invalid-package",
)
mock_run.side_effect = subprocess.CalledProcessError(1, "pip install", "Error message")
with pytest.raises(
subprocess.CalledProcessError,
match="Command 'pip install' returned non-zero exit status 1.",
):
with env_pack.pack_env_for_databricks_model_serving(
"models:/test/1", enforce_pip_requirements=True
):
pass
# Verify error messages were printed
mock_eprint.assert_any_call("Error installing requirements:")
mock_eprint.assert_any_call("Error message")
def test_pack_env_for_databricks_model_serving_unsupported_version():
with mock.patch.object(
DatabricksRuntimeVersion,
"parse",
return_value=DatabricksRuntimeVersion(
is_client_image=False, # Not a client image
major=13,
minor=0,
is_gpu_image=False,
),
):
with pytest.raises(ValueError, match="Serverless environment is required"):
with env_pack.pack_env_for_databricks_model_serving("models:/test/1"):
pass
def test_pack_env_for_databricks_model_serving_runtime_version_check(tmp_path, monkeypatch):
"""Test that pack_env_for_databricks_model_serving correctly checks runtime version
compatibility.
"""
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "artifacts"
mock_artifacts_dir.mkdir()
# Create MLmodel file with different runtime version
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.3.0", # Different major version
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Set current runtime to client.2.0
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "client.2.0")
with mock.patch(
"mlflow.utils.env_pack.download_artifacts", return_value=str(mock_artifacts_dir)
):
with pytest.raises(ValueError, match="Runtime version mismatch"):
with env_pack.pack_env_for_databricks_model_serving("models:/test-model/1"):
pass
# Test that same major version works
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.1", # Same major version
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Create a mock environment directory
mock_env_dir = tmp_path / "mock_env"
mock_env_dir.mkdir()
with (
mock.patch(
"mlflow.utils.env_pack.download_artifacts", return_value=str(mock_artifacts_dir)
),
mock.patch("sys.prefix", str(mock_env_dir)),
):
with env_pack.pack_env_for_databricks_model_serving(
"models:/test-model/1"
) as artifacts_dir:
assert Path(artifacts_dir).exists()
@pytest.mark.parametrize(
"test_input",
[
None,
"databricks_model_serving",
EnvPackConfig(name="databricks_model_serving", install_dependencies=True),
EnvPackConfig(name="databricks_model_serving", install_dependencies=False),
],
)
def test_validate_env_pack_with_valid_inputs(test_input):
# valid string should not raise; None should be treated as no-op
if test_input is None:
assert _validate_env_pack(test_input) is None
else:
assert _validate_env_pack(test_input) is not None
@pytest.mark.parametrize(
("test_input", "error_message"),
[
(EnvPackConfig(name="other", install_dependencies=True), "Invalid EnvPackConfig.name*"),
(
EnvPackConfig(name="databricks_model_serving", install_dependencies="yes"),
"EnvPackConfig.install_dependencies must be a bool.",
),
({"name": "databricks_model_serving"}, "env_pack must be either None*"),
("something_else", "Invalid env_pack value*"),
],
)
def test_validate_env_pack_throws_errors_on_invalid_inputs(test_input, error_message):
with pytest.raises(MlflowException, match=error_message):
_validate_env_pack(test_input)
def test_pack_env_for_databricks_model_serving_missing_runtime_version(tmp_path, mock_dbr_version):
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "artifacts"
mock_artifacts_dir.mkdir()
# Create MLmodel file without databricks_runtime field
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
with mock.patch(
"mlflow.utils.env_pack.download_artifacts", return_value=str(mock_artifacts_dir)
):
with pytest.raises(
ValueError, match="Model must have been created in a Databricks runtime environment"
):
with env_pack.pack_env_for_databricks_model_serving("models:/test-model/1"):
pass
def test_pack_env_for_databricks_model_serving_rejects_existing_databricks_dir(
tmp_path, mock_dbr_version
):
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "artifacts"
mock_artifacts_dir.mkdir()
(mock_artifacts_dir / "requirements.txt").write_text("numpy==1.21.0")
# Create MLmodel file with correct runtime version
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.0",
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Create existing _databricks directory
existing_databricks_dir = mock_artifacts_dir / env_pack._ARTIFACT_PATH
existing_databricks_dir.mkdir()
with (
mock.patch(
"mlflow.utils.env_pack.download_artifacts",
return_value=str(mock_artifacts_dir),
),
):
# This should raise an error because _databricks directory exists in source
with pytest.raises(
MlflowException, match="Source artifacts contain a '_databricks' directory"
):
with env_pack.pack_env_for_databricks_model_serving(
"models:/test-model/1", enforce_pip_requirements=False
):
pass
def test_pack_env_with_local_model_path_no_mutation(tmp_path, mock_dbr_version):
# Create a local directory with model artifacts
local_model_dir = tmp_path / "local_model"
local_model_dir.mkdir()
(local_model_dir / "requirements.txt").write_text("numpy==1.21.0")
(local_model_dir / "model.pkl").write_text("model data")
# Create MLmodel file with correct runtime version
mlmodel_path = local_model_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.0",
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Create a mock environment directory
mock_env_dir = tmp_path / "mock_env"
venv.create(mock_env_dir, with_pip=True)
with mock.patch("sys.prefix", str(mock_env_dir)):
# Call with local_model_path
with env_pack.pack_env_for_databricks_model_serving(
"models:/test-model/1",
local_model_path=str(local_model_dir),
enforce_pip_requirements=False,
) as artifacts_dir:
# Verify returned directory contains expected files
artifacts_path = Path(artifacts_dir)
assert artifacts_path.exists()
assert (artifacts_path / "requirements.txt").exists()
assert (artifacts_path / "model.pkl").exists()
assert (artifacts_path / "MLmodel").exists()
# Verify _databricks directory exists in returned path
databricks_path = artifacts_path / env_pack._ARTIFACT_PATH
assert databricks_path.exists()
assert (databricks_path / env_pack._MODEL_VERSION_TAR).exists()
assert (databricks_path / env_pack._MODEL_ENVIRONMENT_TAR).exists()
# CRITICAL: Verify original local_model_dir is NOT mutated
assert not (local_model_dir / env_pack._ARTIFACT_PATH).exists()
# Verify original files are untouched
assert (local_model_dir / "requirements.txt").read_text() == "numpy==1.21.0"
assert (local_model_dir / "model.pkl").read_text() == "model data"
# After context exit, verify local_model_dir is still not mutated
assert not (local_model_dir / env_pack._ARTIFACT_PATH).exists()
def test_pack_env_with_download_cleanup(tmp_path, mock_dbr_version):
# Mock download_artifacts to return a path
mock_artifacts_dir = tmp_path / "downloaded_artifacts"
mock_artifacts_dir.mkdir()
(mock_artifacts_dir / "requirements.txt").write_text("numpy==1.21.0")
# Create MLmodel file with correct runtime version
mlmodel_path = mock_artifacts_dir / "MLmodel"
mlmodel_path.write_text(
yaml.dump({
"databricks_runtime": "client.2.0",
"flavors": {"python_function": {"model_path": "model.pkl"}},
})
)
# Create a mock environment directory
mock_env_dir = tmp_path / "mock_env"
venv.create(mock_env_dir, with_pip=True)
with (
mock.patch(
"mlflow.utils.env_pack.download_artifacts",
return_value=str(mock_artifacts_dir),
),
mock.patch("sys.prefix", str(mock_env_dir)),
):
# Call without local_model_path to trigger download
with env_pack.pack_env_for_databricks_model_serving(
"models:/test-model/1", enforce_pip_requirements=False
) as artifacts_dir:
# During context, downloaded artifacts should exist
assert Path(artifacts_dir).exists()
assert (Path(artifacts_dir) / "requirements.txt").exists()
# After context exit, downloaded artifacts should be cleaned up
assert not mock_artifacts_dir.exists()