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

499 lines
18 KiB
Python

import filecmp
import json
import os
import shutil
from unittest import mock
import pytest
import mlflow
from mlflow import MlflowClient, cli
from mlflow.entities import RunStatus
from mlflow.environment_variables import MLFLOW_TRACKING_URI
from mlflow.exceptions import MlflowException
from mlflow.legacy_databricks_cli.configure.provider import DatabricksConfig
from mlflow.projects import ExecutionException, databricks
from mlflow.projects.databricks import DatabricksJobRunner, _get_cluster_mlflow_run_cmd
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE, ErrorCode
from mlflow.store.tracking.file_store import FileStore
from mlflow.tracking.request_header.default_request_header_provider import (
DefaultRequestHeaderProvider,
)
from mlflow.utils import file_utils
from mlflow.utils.mlflow_tags import (
MLFLOW_DATABRICKS_RUN_URL,
MLFLOW_DATABRICKS_SHELL_JOB_RUN_ID,
MLFLOW_DATABRICKS_WEBAPP_URL,
)
from mlflow.utils.rest_utils import MlflowHostCreds
from mlflow.utils.uri import construct_db_uri_from_profile
from tests import helper_functions
from tests.integration.utils import invoke_cli_runner
from tests.projects.utils import TEST_PROJECT_DIR, validate_exit_status
@pytest.fixture
def runs_cancel_mock():
"""Mocks the Jobs Runs Cancel API request"""
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner.jobs_runs_cancel"
) as runs_cancel_mock:
runs_cancel_mock.return_value = None
yield runs_cancel_mock
@pytest.fixture
def runs_submit_mock():
"""Mocks the Jobs Runs Submit API request"""
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner._jobs_runs_submit",
return_value={"run_id": "-1"},
) as runs_submit_mock:
yield runs_submit_mock
@pytest.fixture
def runs_get_mock():
"""Mocks the Jobs Runs Get API request"""
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner.jobs_runs_get"
) as runs_get_mock:
yield runs_get_mock
@pytest.fixture
def databricks_cluster_mlflow_run_cmd_mock():
"""Mocks the Jobs Runs Get API request"""
with mock.patch(
"mlflow.projects.databricks._get_cluster_mlflow_run_cmd"
) as mlflow_run_cmd_mock:
yield mlflow_run_cmd_mock
@pytest.fixture
def cluster_spec_mock(tmp_path):
cluster_spec_handle = tmp_path.joinpath("cluster_spec.json")
cluster_spec_handle.write_text("{}")
return str(cluster_spec_handle)
@pytest.fixture
def dbfs_root_mock(tmp_path):
return str(tmp_path.joinpath("dbfs-root"))
@pytest.fixture
def upload_to_dbfs_mock(dbfs_root_mock):
def upload_mock_fn(_, src_path, dbfs_uri):
mock_dbfs_dst = os.path.join(dbfs_root_mock, dbfs_uri.split("/dbfs/")[1])
os.makedirs(os.path.dirname(mock_dbfs_dst))
shutil.copy(src_path, mock_dbfs_dst)
with mock.patch.object(
mlflow.projects.databricks.DatabricksJobRunner, "_upload_to_dbfs", new=upload_mock_fn
) as upload_mock:
yield upload_mock
@pytest.fixture
def dbfs_path_exists_mock(dbfs_root_mock):
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner._dbfs_path_exists"
) as path_exists_mock:
yield path_exists_mock
@pytest.fixture
def dbfs_mocks(dbfs_path_exists_mock, upload_to_dbfs_mock):
return
@pytest.fixture
def before_run_validations_mock():
with mock.patch("mlflow.projects.databricks.before_run_validations"):
yield
@pytest.fixture
def set_tag_mock():
with mock.patch("mlflow.projects.databricks.tracking.MlflowClient") as m:
mlflow_service_mock = mock.Mock(wraps=MlflowClient())
m.return_value = mlflow_service_mock
yield mlflow_service_mock.set_tag
def _get_mock_run_state(succeeded):
if succeeded is None:
return {"life_cycle_state": "RUNNING", "state_message": ""}
run_result_state = "SUCCESS" if succeeded else "FAILED"
return {"life_cycle_state": "TERMINATED", "state_message": "", "result_state": run_result_state}
def mock_runs_get_result(succeeded):
run_state = _get_mock_run_state(succeeded)
return {"state": run_state, "run_page_url": "test_url"}
def run_databricks_project(cluster_spec, **kwargs):
return mlflow.projects.run(
uri=TEST_PROJECT_DIR,
backend="databricks",
backend_config=cluster_spec,
parameters={"alpha": "0.4"},
**kwargs,
)
def test_upload_project_to_dbfs(
dbfs_root_mock, tmp_path, dbfs_path_exists_mock, upload_to_dbfs_mock
):
# Upload project to a mock directory
dbfs_path_exists_mock.return_value = False
runner = DatabricksJobRunner(databricks_profile_uri=construct_db_uri_from_profile("DEFAULT"))
dbfs_uri = runner._upload_project_to_dbfs(
project_dir=TEST_PROJECT_DIR, experiment_id=FileStore.DEFAULT_EXPERIMENT_ID
)
# Get expected tar
local_tar_path = os.path.join(dbfs_root_mock, dbfs_uri.split("/dbfs/")[1])
expected_tar_path = str(tmp_path.joinpath("expected.tar.gz"))
file_utils.make_tarfile(
output_filename=expected_tar_path,
source_dir=TEST_PROJECT_DIR,
archive_name=databricks.DB_TARFILE_ARCHIVE_NAME,
)
# Extract the tarred project, verify its contents
assert filecmp.cmp(local_tar_path, expected_tar_path, shallow=False)
def test_upload_existing_project_to_dbfs(dbfs_path_exists_mock):
# Check that we don't upload the project if it already exists on DBFS
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner._upload_to_dbfs"
) as upload_to_dbfs_mock:
dbfs_path_exists_mock.return_value = True
runner = DatabricksJobRunner(
databricks_profile_uri=construct_db_uri_from_profile("DEFAULT")
)
runner._upload_project_to_dbfs(
project_dir=TEST_PROJECT_DIR, experiment_id=FileStore.DEFAULT_EXPERIMENT_ID
)
assert upload_to_dbfs_mock.call_count == 0
@pytest.mark.parametrize(
"response_mock",
[
helper_functions.create_mock_response(400, "Error message but not a JSON string"),
helper_functions.create_mock_response(400, ""),
helper_functions.create_mock_response(400, None),
],
)
def test_dbfs_path_exists_error_response_handling(response_mock):
with (
mock.patch(
"mlflow.utils.databricks_utils.get_databricks_host_creds"
) as get_databricks_host_creds_mock,
mock.patch("mlflow.utils.rest_utils.http_request") as http_request_mock,
):
# given a well formed DatabricksJobRunner
# note: databricks_profile is None needed because clients using profile are mocked
job_runner = DatabricksJobRunner(databricks_profile_uri=None)
# when the http request to validate the dbfs path returns a 400 response with an
# error message that is either well-formed JSON or not
get_databricks_host_creds_mock.return_value = None
http_request_mock.return_value = response_mock
# then _dbfs_path_exists should return a MlflowException
with pytest.raises(MlflowException, match="API request to check existence of file at DBFS"):
job_runner._dbfs_path_exists("some/path")
def test_run_databricks_validations(
tmp_path,
monkeypatch,
cluster_spec_mock,
dbfs_mocks,
set_tag_mock,
):
"""
Tests that running on Databricks fails before making any API requests if validations fail.
"""
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
with mock.patch(
"mlflow.projects.databricks.DatabricksJobRunner._databricks_api_request"
) as db_api_req_mock:
# Test bad tracking URI
mlflow.set_tracking_uri(f"sqlite:///{tmp_path / 'mlflow.db'}")
with pytest.raises(ExecutionException, match="MLflow tracking URI must be of"):
run_databricks_project(cluster_spec_mock, synchronous=True)
assert db_api_req_mock.call_count == 0
db_api_req_mock.reset_mock()
mlflow_service = MlflowClient()
assert len(mlflow_service.search_runs([FileStore.DEFAULT_EXPERIMENT_ID])) == 0
mlflow.set_tracking_uri("databricks")
# Test misspecified parameters
with pytest.raises(
ExecutionException, match="No value given for missing parameters: 'name'"
):
mlflow.projects.run(
TEST_PROJECT_DIR,
backend="databricks",
entry_point="greeter",
backend_config=cluster_spec_mock,
)
assert db_api_req_mock.call_count == 0
db_api_req_mock.reset_mock()
# Test bad cluster spec
with pytest.raises(ExecutionException, match="Backend spec must be provided"):
mlflow.projects.run(
TEST_PROJECT_DIR, backend="databricks", synchronous=True, backend_config=None
)
assert db_api_req_mock.call_count == 0
db_api_req_mock.reset_mock()
# Test that validations pass with good tracking URIs
databricks.before_run_validations("http://", cluster_spec_mock)
databricks.before_run_validations("databricks", cluster_spec_mock)
@pytest.mark.usefixtures(
"before_run_validations_mock",
"runs_cancel_mock",
"dbfs_mocks",
"databricks_cluster_mlflow_run_cmd_mock",
)
def test_run_databricks(
runs_submit_mock,
runs_get_mock,
cluster_spec_mock,
set_tag_mock,
databricks_cluster_mlflow_run_cmd_mock,
monkeypatch,
):
monkeypatch.setenv("DATABRICKS_HOST", "https://test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
mlflow.set_tracking_uri("databricks")
# Test that MLflow gets the correct run status when performing a Databricks run
for run_succeeded, expect_status in [(True, RunStatus.FINISHED), (False, RunStatus.FAILED)]:
runs_get_mock.return_value = mock_runs_get_result(succeeded=run_succeeded)
submitted_run = run_databricks_project(cluster_spec_mock, synchronous=False)
assert submitted_run.wait() == run_succeeded
assert submitted_run.run_id is not None
assert runs_submit_mock.call_count == 1
assert databricks_cluster_mlflow_run_cmd_mock.call_count == 1
tags = {}
for call_args, _ in set_tag_mock.call_args_list:
tags[call_args[1]] = call_args[2]
assert tags[MLFLOW_DATABRICKS_RUN_URL] == "test_url"
assert tags[MLFLOW_DATABRICKS_SHELL_JOB_RUN_ID] == "-1"
assert tags[MLFLOW_DATABRICKS_WEBAPP_URL] == "https://test-host"
set_tag_mock.reset_mock()
runs_submit_mock.reset_mock()
databricks_cluster_mlflow_run_cmd_mock.reset_mock()
validate_exit_status(submitted_run.get_status(), expect_status)
@pytest.mark.usefixtures(
"before_run_validations_mock",
"runs_cancel_mock",
"dbfs_mocks",
"cluster_spec_mock",
"set_tag_mock",
)
def test_run_databricks_cluster_spec_json(runs_submit_mock, runs_get_mock, monkeypatch):
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
runs_get_mock.return_value = mock_runs_get_result(succeeded=True)
cluster_spec = {
"spark_version": "5.0.x-scala2.11",
"num_workers": 2,
"node_type_id": "i3.xlarge",
}
# Run project synchronously, verify that it succeeds (doesn't throw)
run_databricks_project(cluster_spec=cluster_spec, synchronous=True)
assert runs_submit_mock.call_count == 1
runs_submit_args, _ = runs_submit_mock.call_args_list[0]
req_body = runs_submit_args[0]
assert req_body["new_cluster"] == cluster_spec
@pytest.mark.usefixtures(
"before_run_validations_mock",
"runs_cancel_mock",
"dbfs_mocks",
"cluster_spec_mock",
"set_tag_mock",
)
def test_run_databricks_extended_cluster_spec_json(runs_submit_mock, runs_get_mock, monkeypatch):
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
runs_get_mock.return_value = mock_runs_get_result(succeeded=True)
new_cluster_spec = {
"spark_version": "6.5.x-scala2.11",
"num_workers": 2,
"node_type_id": "i3.xlarge",
}
extra_library = {"pypi": {"package": "tensorflow"}}
cluster_spec = {"new_cluster": new_cluster_spec, "libraries": [extra_library]}
# Run project synchronously, verify that it succeeds (doesn't throw)
run_databricks_project(cluster_spec=cluster_spec, synchronous=True)
assert runs_submit_mock.call_count == 1
runs_submit_args, _ = runs_submit_mock.call_args_list[0]
req_body = runs_submit_args[0]
assert req_body["new_cluster"] == new_cluster_spec
# This does test deep object equivalence
assert extra_library in req_body["libraries"]
@pytest.mark.usefixtures(
"before_run_validations_mock",
"runs_cancel_mock",
"dbfs_mocks",
"cluster_spec_mock",
"set_tag_mock",
)
def test_run_databricks_extended_cluster_spec_json_without_libraries(
runs_submit_mock, runs_get_mock, monkeypatch
):
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
runs_get_mock.return_value = mock_runs_get_result(succeeded=True)
new_cluster_spec = {
"spark_version": "6.5.x-scala2.11",
"num_workers": 2,
"node_type_id": "i3.xlarge",
}
cluster_spec = {
"new_cluster": new_cluster_spec,
}
# Run project synchronously, verify that it succeeds (doesn't throw)
run_databricks_project(cluster_spec=cluster_spec, synchronous=True)
assert runs_submit_mock.call_count == 1
runs_submit_args, _ = runs_submit_mock.call_args_list[0]
req_body = runs_submit_args[0]
assert req_body["new_cluster"] == new_cluster_spec
def test_run_databricks_throws_exception_when_spec_uses_existing_cluster(monkeypatch):
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
existing_cluster_spec = {
"existing_cluster_id": "1000-123456-clust1",
}
with pytest.raises(
MlflowException, match="execution against existing clusters is not currently supported"
) as exc:
run_databricks_project(cluster_spec=existing_cluster_spec)
assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
def test_run_databricks_cancel(
before_run_validations_mock,
runs_submit_mock,
dbfs_mocks,
set_tag_mock,
runs_cancel_mock,
runs_get_mock,
cluster_spec_mock,
monkeypatch,
):
# Test that MLflow properly handles Databricks run cancellation. We mock the result of
# the runs-get API to indicate run failure so that cancel() exits instead of blocking while
# waiting for run status.
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "foo")
runs_get_mock.return_value = mock_runs_get_result(succeeded=False)
submitted_run = run_databricks_project(cluster_spec_mock, synchronous=False)
submitted_run.cancel()
validate_exit_status(submitted_run.get_status(), RunStatus.FAILED)
assert runs_cancel_mock.call_count == 1
# Test that we raise an exception when a blocking Databricks run fails
runs_get_mock.return_value = mock_runs_get_result(succeeded=False)
with pytest.raises(mlflow.projects.ExecutionException, match=r"Run \(ID '.+'\) failed"):
run_databricks_project(cluster_spec_mock, synchronous=True)
def test_get_tracking_uri_for_run(monkeypatch):
mlflow.set_tracking_uri("http://some-uri")
assert databricks._get_tracking_uri_for_run() == "http://some-uri"
mlflow.set_tracking_uri("databricks://profile")
assert databricks._get_tracking_uri_for_run() == "databricks"
mlflow.set_tracking_uri(None)
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, "http://some-uri")
assert mlflow.tracking._tracking_service.utils.get_tracking_uri() == "http://some-uri"
class MockProfileConfigProvider:
def __init__(self, profile):
assert profile == "my-profile"
def get_config(self):
return DatabricksConfig.from_password("host", "user", "pass", insecure=False)
def test_databricks_http_request_integration():
def confirm_request_params(*args, **kwargs):
headers = DefaultRequestHeaderProvider().request_headers()
headers["Authorization"] = "Basic dXNlcjpwYXNz"
assert args == ("PUT", "host/clusters/list")
assert kwargs == {
"allow_redirects": True,
"headers": headers,
"verify": True,
"json": {"a": "b"},
"timeout": 120,
}
http_response = mock.MagicMock()
http_response.status_code = 200
http_response.text = '{"OK": "woo"}'
return http_response
with (
mock.patch("requests.Session.request", side_effect=confirm_request_params),
mock.patch(
"mlflow.utils.databricks_utils.get_databricks_host_creds",
return_value=MlflowHostCreds(
host="host", username="user", password="pass", ignore_tls_verification=False
),
),
):
response = DatabricksJobRunner(databricks_profile_uri=None)._databricks_api_request(
"/clusters/list", "PUT", json={"a": "b"}
)
assert json.loads(response.text) == {"OK": "woo"}
def test_run_databricks_failed():
text = '{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": "Node type not supported"}'
with (
mock.patch("mlflow.utils.databricks_utils.get_databricks_host_creds"),
mock.patch(
"mlflow.utils.rest_utils.http_request",
return_value=mock.Mock(text=text, status_code=400),
),
):
runner = DatabricksJobRunner(construct_db_uri_from_profile("profile"))
with pytest.raises(
MlflowException, match="RESOURCE_DOES_NOT_EXIST: Node type not supported"
):
runner._run_shell_command_job("/project", "command", {}, {})
def test_run_databricks_generates_valid_mlflow_run_cmd():
cmd = _get_cluster_mlflow_run_cmd(
project_dir="my_project_dir",
run_id="hi",
entry_point="main",
parameters={"a": "b"},
env_manager="conda",
)
assert cmd[0] == "mlflow"
with mock.patch("mlflow.projects.run"):
invoke_cli_runner(cli.cli, cmd[1:])