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()