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:])