1467 lines
57 KiB
Python
1467 lines
57 KiB
Python
import builtins
|
|
import json
|
|
import os
|
|
import platform
|
|
import sys
|
|
import time
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import mlflow
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.legacy_databricks_cli.configure.provider import (
|
|
DatabricksConfig,
|
|
DatabricksModelServingConfigProvider,
|
|
)
|
|
from mlflow.utils import databricks_utils
|
|
from mlflow.utils.databricks_utils import (
|
|
DatabricksConfigProvider,
|
|
DatabricksRuntimeVersion,
|
|
_NoDbutilsError,
|
|
check_databricks_secret_scope_access,
|
|
get_databricks_host_creds,
|
|
get_databricks_runtime_major_minor_version,
|
|
get_databricks_runtime_version,
|
|
get_databricks_workspace_client_config,
|
|
get_dbconnect_udf_sandbox_info,
|
|
get_mlflow_credential_context_by_run_id,
|
|
get_sgc_job_run_id,
|
|
get_workspace_info_from_databricks_secrets,
|
|
get_workspace_info_from_dbutils,
|
|
get_workspace_url,
|
|
is_databricks_default_tracking_uri,
|
|
is_running_in_ipython_environment,
|
|
)
|
|
from mlflow.utils.os import is_windows
|
|
|
|
from tests.helper_functions import mock_method_chain
|
|
from tests.pyfunc.test_spark import spark # noqa: F401
|
|
|
|
|
|
def test_no_throw():
|
|
"""
|
|
Outside of Databricks the databricks_utils methods should never throw and should only return
|
|
None.
|
|
"""
|
|
assert not databricks_utils.is_in_databricks_notebook()
|
|
assert not databricks_utils.is_in_databricks_repo_notebook()
|
|
assert not databricks_utils.is_in_databricks_job()
|
|
assert not databricks_utils.is_dbfs_fuse_available()
|
|
assert not databricks_utils.is_in_databricks_runtime()
|
|
|
|
|
|
def test_databricks_registry_profile():
|
|
mock_provider = mock.MagicMock()
|
|
mock_provider.get_config.return_value = None
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.secrets.get.return_value = "random"
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils.ProfileConfigProvider", return_value=mock_provider
|
|
),
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils),
|
|
):
|
|
params = databricks_utils.get_databricks_host_creds("databricks://profile:prefix")
|
|
mock_dbutils.secrets.get.assert_any_call(key="prefix-host", scope="profile")
|
|
mock_dbutils.secrets.get.assert_any_call(key="prefix-token", scope="profile")
|
|
assert params.host == "random"
|
|
assert params.token == "random"
|
|
|
|
|
|
def test_databricks_no_creds_found():
|
|
with pytest.raises(MlflowException, match="Reading Databricks credential configuration failed"):
|
|
databricks_utils.get_databricks_host_creds()
|
|
|
|
|
|
def test_databricks_host_creds_uses_sdk_when_enabled(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://test.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient") as mock_ws:
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# When profile is None, WorkspaceClient() should be called without arguments
|
|
# to allow env-based auth (like OIDC) to work properly
|
|
mock_ws.assert_called_once_with()
|
|
assert creds.host == "https://test.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
assert creds.databricks_auth_profile is None
|
|
|
|
|
|
def test_databricks_host_creds_with_oidc_env_vars(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://test.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_AUTH_TYPE", "file-oidc")
|
|
monkeypatch.setenv("DATABRICKS_CLIENT_ID", "test-client-id")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient") as mock_ws:
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# WorkspaceClient() should be called without profile arg to allow OIDC auth
|
|
mock_ws.assert_called_once_with()
|
|
assert creds.host == "https://test.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
|
|
|
|
def test_databricks_host_creds_with_profile_uses_sdk(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_CONFIG_PROFILE", "my-profile")
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.host = "https://test.databricks.com"
|
|
mock_config.token = "test-token"
|
|
mock_config.username = None
|
|
mock_config.password = None
|
|
mock_config.insecure = None
|
|
mock_config.client_id = None
|
|
mock_config.client_secret = None
|
|
|
|
with (
|
|
mock.patch("databricks.sdk.WorkspaceClient") as mock_ws,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_databricks_creds_config", return_value=mock_config
|
|
),
|
|
):
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# When profile is set, it should be passed to WorkspaceClient
|
|
mock_ws.assert_called_once_with(profile="my-profile")
|
|
assert creds.host == "https://test.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
assert creds.databricks_auth_profile == "my-profile"
|
|
|
|
|
|
def test_databricks_host_creds_oidc_only_no_legacy_token(monkeypatch):
|
|
# Pure OIDC / Azure CLI / Azure Managed Identity flows: SDK auth succeeds, but legacy
|
|
# credential providers fail because no token/password is set. The SDK's resolved host
|
|
# must still be returned so MLflow can route requests through the SDK.
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://oidc.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_AUTH_TYPE", "file-oidc")
|
|
monkeypatch.delenv("DATABRICKS_TOKEN", raising=False)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.host = "https://oidc.databricks.com"
|
|
mock_config.workspace_id = "12345"
|
|
|
|
mock_ws = mock.MagicMock()
|
|
mock_ws.config = mock_config
|
|
|
|
with (
|
|
mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_ws),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_databricks_creds_config",
|
|
side_effect=MlflowException("malformed databricks auth"),
|
|
),
|
|
):
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
assert creds.host == "https://oidc.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
assert creds.token is None
|
|
assert creds.workspace_id == "12345"
|
|
|
|
|
|
def test_databricks_host_creds_legacy_failure_without_sdk_reraises(monkeypatch):
|
|
# When SDK auth failed AND legacy auth is malformed, the legacy MlflowException must
|
|
# propagate (no silent SDK-only fallback to mask configuration mistakes).
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.delenv("DATABRICKS_HOST", raising=False)
|
|
monkeypatch.delenv("DATABRICKS_TOKEN", raising=False)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
with (
|
|
mock.patch("databricks.sdk.WorkspaceClient", side_effect=Exception("SDK auth failed")),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_databricks_creds_config",
|
|
side_effect=MlflowException("malformed databricks auth"),
|
|
),
|
|
pytest.raises(MlflowException, match="malformed databricks auth"),
|
|
):
|
|
databricks_utils.get_databricks_host_creds("databricks")
|
|
|
|
|
|
def test_databricks_host_creds_falls_back_when_sdk_fails(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://test.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
with (
|
|
mock.patch("databricks.sdk.WorkspaceClient", side_effect=Exception("SDK auth failed")),
|
|
mock.patch.object(databricks_utils._logger, "warning") as mock_warning,
|
|
):
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# Should fall back to legacy and use env vars
|
|
assert creds.host == "https://test.databricks.com"
|
|
assert creds.token == "test-token"
|
|
assert not creds.use_databricks_sdk
|
|
# SDK init failure must surface as a WARNING so callers can correlate it with
|
|
# any downstream OAuth error.
|
|
mock_warning.assert_called_once()
|
|
msg = mock_warning.call_args.args[0]
|
|
assert "Failed to create databricks SDK workspace client" in msg
|
|
assert "SDK auth failed" in msg
|
|
|
|
|
|
def test_databricks_host_creds_keeps_sdk_when_workspace_id_attr_missing(monkeypatch):
|
|
# Older databricks-sdk releases don't have Config.workspace_id. The legacy behavior
|
|
# let the resulting AttributeError nuke use_databricks_sdk, breaking OAuth M2M auth
|
|
# for users who had MLFLOW_ENABLE_DB_SDK=true correctly set. workspace_id must
|
|
# degrade to None without dropping the SDK auth path. See ES-1918390.
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://test.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_CLIENT_ID", "test-client-id")
|
|
monkeypatch.setenv("DATABRICKS_CLIENT_SECRET", "test-client-secret")
|
|
monkeypatch.delenv("DATABRICKS_TOKEN", raising=False)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
# Use a real object (not MagicMock) so getattr fallback is exercised.
|
|
class OldSdkConfig:
|
|
host = "https://test.databricks.com"
|
|
|
|
mock_ws = mock.MagicMock()
|
|
mock_ws.config = OldSdkConfig()
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_ws):
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
assert creds.use_databricks_sdk
|
|
assert creds.workspace_id is None
|
|
|
|
|
|
def test_sdk_respects_env_var_priority(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://env-var-host.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "env-token")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient") as mock_ws:
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# SDK should be called without profile (uses env vars)
|
|
mock_ws.assert_called_once_with()
|
|
assert creds.host == "https://env-var-host.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
|
|
|
|
def test_sdk_respects_config_profile_env_var(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_CONFIG_PROFILE", "my-profile")
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.host = "https://profile-host.databricks.com"
|
|
mock_config.token = "test-token"
|
|
mock_config.username = None
|
|
mock_config.password = None
|
|
mock_config.insecure = None
|
|
mock_config.client_id = None
|
|
mock_config.client_secret = None
|
|
|
|
with (
|
|
mock.patch("databricks.sdk.WorkspaceClient") as mock_ws,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_databricks_creds_config", return_value=mock_config
|
|
),
|
|
):
|
|
creds = databricks_utils.get_databricks_host_creds("databricks")
|
|
# SDK should be called with the profile from env var
|
|
mock_ws.assert_called_once_with(profile="my-profile")
|
|
assert creds.host == "https://profile-host.databricks.com"
|
|
assert creds.use_databricks_sdk
|
|
assert creds.databricks_auth_profile == "my-profile"
|
|
|
|
|
|
def test_databricks_no_creds_found_in_model_serving(monkeypatch):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
with pytest.raises(
|
|
MlflowException, match="Reading Databricks credential configuration in model serving failed"
|
|
):
|
|
databricks_utils.get_databricks_host_creds()
|
|
|
|
|
|
def test_databricks_single_slash_in_uri_scheme_throws():
|
|
with pytest.raises(MlflowException, match="URI is formatted incorrectly"):
|
|
databricks_utils.get_databricks_host_creds("databricks:/profile:path")
|
|
|
|
|
|
@pytest.fixture
|
|
def oauth_file(tmp_path):
|
|
token_contents = {"OAUTH_TOKEN": [{"oauthTokenValue": "token2"}]}
|
|
oauth_file = tmp_path.joinpath("model-dependencies-oauth-token")
|
|
with open(oauth_file, "w") as f:
|
|
json.dump(token_contents, f)
|
|
return oauth_file
|
|
|
|
|
|
def test_get_model_dependency_token(oauth_file):
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
token = databricks_utils.get_model_dependency_oauth_token()
|
|
assert token == "token2"
|
|
|
|
|
|
def test_get_model_dependency_oauth_token_model_serving_throws():
|
|
with pytest.raises(MlflowException, match="Unable to read Oauth credentials"):
|
|
databricks_utils.get_model_dependency_oauth_token()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_serving_env_var"),
|
|
[
|
|
("DATABRICKS_MODEL_SERVING_HOST_URL"),
|
|
("DB_MODEL_SERVING_HOST_URL"),
|
|
],
|
|
)
|
|
def test_databricks_params_model_serving_oauth_cache_databricks(
|
|
monkeypatch, oauth_file, model_serving_env_var
|
|
):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
monkeypatch.setenv(model_serving_env_var, "host")
|
|
monkeypatch.setenv("DB_DEPENDENCY_OAUTH_CACHE", "token")
|
|
monkeypatch.setenv("DB_DEPENDENCY_OAUTH_CACHE_EXPIRY_TS", str(time.time() + 5))
|
|
# oauth file still needs to be present for should_fetch_model_serving_environment_oauth()
|
|
# to evaluate true
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
params = databricks_utils.get_databricks_host_creds()
|
|
assert params.host == "host"
|
|
# should use token from cache, rather than token from oauthfile
|
|
assert params.token == "token"
|
|
|
|
|
|
def test_databricks_params_model_serving_oauth_cache_expired(monkeypatch, oauth_file):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
monkeypatch.setenv("DATABRICKS_MODEL_SERVING_HOST_URL", "host")
|
|
monkeypatch.setenv("DB_DEPENDENCY_OAUTH_CACHE", "token")
|
|
monkeypatch.setenv("DB_DEPENDENCY_OAUTH_CACHE_EXPIRY_TS", str(time.time() - 5))
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
params = databricks_utils.get_databricks_host_creds()
|
|
# cache should get updated with new token
|
|
assert os.environ["DB_DEPENDENCY_OAUTH_CACHE"] == "token2"
|
|
assert float(os.environ["DB_DEPENDENCY_OAUTH_CACHE_EXPIRY_TS"]) > time.time()
|
|
assert params.host == "host"
|
|
# should use token2 from oauthfile, rather than token from cache
|
|
assert params.token == "token2"
|
|
|
|
|
|
def test_databricks_params_model_serving_read_oauth(monkeypatch, oauth_file):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
monkeypatch.setenv("DATABRICKS_MODEL_SERVING_HOST_URL", "host")
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
params = databricks_utils.get_databricks_host_creds()
|
|
assert os.environ["DB_DEPENDENCY_OAUTH_CACHE"] == "token2"
|
|
assert float(os.environ["DB_DEPENDENCY_OAUTH_CACHE_EXPIRY_TS"]) > time.time()
|
|
assert params.host == "host"
|
|
assert params.token == "token2"
|
|
|
|
|
|
def test_databricks_params_env_var_overrides_model_serving_oauth(monkeypatch, oauth_file):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
monkeypatch.setenv("DATABRICKS_MODEL_SERVING_HOST_URL", "host")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "host_envvar")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "pat_token")
|
|
# oauth file still needs to be present for should_fetch_model_serving_environment_oauth()
|
|
# to evaluate true
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
params = databricks_utils.get_databricks_host_creds()
|
|
# should use token and host from envvar, rather than token from oauthfile
|
|
assert params.use_databricks_sdk
|
|
|
|
|
|
def test_model_serving_config_provider_errors_caught():
|
|
provider = DatabricksModelServingConfigProvider()
|
|
with mock.patch.object(
|
|
provider,
|
|
"_get_databricks_model_serving_config",
|
|
side_effect=Exception("Failed to Read OAuth Creds"),
|
|
):
|
|
assert provider.get_config() is None
|
|
|
|
|
|
def test_get_workspace_info_from_databricks_secrets():
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.secrets.get.return_value = "workspace-placeholder-info"
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
workspace_host, workspace_id = get_workspace_info_from_databricks_secrets(
|
|
"databricks://profile:prefix"
|
|
)
|
|
mock_dbutils.secrets.get.assert_any_call(key="prefix-host", scope="profile")
|
|
mock_dbutils.secrets.get.assert_any_call(key="prefix-workspace-id", scope="profile")
|
|
assert workspace_host == "workspace-placeholder-info"
|
|
assert workspace_id == "workspace-placeholder-info"
|
|
|
|
|
|
def test_get_workspace_info_from_dbutils():
|
|
mock_dbutils = mock.MagicMock()
|
|
methods = ["notebook.entry_point.getDbutils", "notebook", "getContext"]
|
|
mock_method_chain(
|
|
mock_dbutils, methods + ["browserHostName", "get"], return_value="mlflow.databricks.com"
|
|
)
|
|
mock_method_chain(mock_dbutils, methods + ["workspaceId", "get"], return_value="1111")
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
workspace_host, workspace_id = get_workspace_info_from_dbutils()
|
|
assert workspace_host == "https://mlflow.databricks.com"
|
|
assert workspace_id == "1111"
|
|
|
|
|
|
def test_get_workspace_info_from_dbutils_no_browser_host_name():
|
|
mock_dbutils = mock.MagicMock()
|
|
methods = ["notebook.entry_point.getDbutils", "notebook", "getContext"]
|
|
mock_method_chain(mock_dbutils, methods + ["browserHostName", "get"], return_value=None)
|
|
mock_method_chain(
|
|
mock_dbutils, methods + ["apiUrl", "get"], return_value="https://mlflow.databricks.com"
|
|
)
|
|
mock_method_chain(mock_dbutils, methods + ["workspaceId", "get"], return_value="1111")
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
workspace_host, workspace_id = get_workspace_info_from_dbutils()
|
|
assert workspace_host == "https://mlflow.databricks.com"
|
|
assert workspace_id == "1111"
|
|
|
|
|
|
def test_get_workspace_info_from_dbutils_old_runtimes():
|
|
mock_dbutils = mock.MagicMock()
|
|
methods = ["notebook.entry_point.getDbutils", "notebook", "getContext"]
|
|
mock_method_chain(
|
|
mock_dbutils,
|
|
methods + ["toJson", "get"],
|
|
return_value='{"tags": {"orgId" : "1111", "browserHostName": "mlflow.databricks.com"}}',
|
|
)
|
|
mock_method_chain(
|
|
mock_dbutils, methods + ["browserHostName", "get"], return_value="mlflow.databricks.com"
|
|
)
|
|
|
|
# Mock out workspace ID tag
|
|
mock_workspace_id_tag_opt = mock.MagicMock()
|
|
mock_workspace_id_tag_opt.isDefined.return_value = True
|
|
mock_workspace_id_tag_opt.get.return_value = "1111"
|
|
mock_method_chain(
|
|
mock_dbutils, methods + ["tags", "get"], return_value=mock_workspace_id_tag_opt
|
|
)
|
|
|
|
# Mimic old runtimes by raising an exception when the nonexistent "workspaceId" method is called
|
|
mock_method_chain(
|
|
mock_dbutils,
|
|
methods + ["workspaceId"],
|
|
side_effect=Exception("workspaceId method not defined!"),
|
|
)
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
workspace_host, workspace_id = get_workspace_info_from_dbutils()
|
|
assert workspace_host == "https://mlflow.databricks.com"
|
|
assert workspace_id == "1111"
|
|
|
|
|
|
def test_get_workspace_info_from_dbutils_when_no_dbutils_available():
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=None):
|
|
workspace_host, workspace_id = get_workspace_info_from_dbutils()
|
|
assert workspace_host is None
|
|
assert workspace_id is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("tracking_uri", "result"),
|
|
[
|
|
("databricks", True),
|
|
("databricks://profile:prefix", False),
|
|
("databricks://profile/prefix", False),
|
|
("nondatabricks", False),
|
|
("databricks\t\r", True),
|
|
("databricks\n", True),
|
|
("databricks://", False),
|
|
("databricks://aAbB", False),
|
|
],
|
|
)
|
|
def test_is_databricks_default_tracking_uri(tracking_uri, result):
|
|
assert is_databricks_default_tracking_uri(tracking_uri) == result
|
|
|
|
|
|
def test_databricks_params_throws_errors():
|
|
# No hostname
|
|
mock_provider = mock.MagicMock()
|
|
mock_provider.get_config.return_value = DatabricksConfig.from_password(
|
|
None, "user", "pass", insecure=True
|
|
)
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils.ProfileConfigProvider", return_value=mock_provider
|
|
):
|
|
with pytest.raises(
|
|
Exception, match="Reading Databricks credential configuration failed with"
|
|
):
|
|
databricks_utils.get_databricks_host_creds()
|
|
|
|
# No authentication
|
|
mock_provider = mock.MagicMock()
|
|
mock_provider.get_config.return_value = DatabricksConfig.from_password(
|
|
"host", None, None, insecure=True
|
|
)
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils.ProfileConfigProvider", return_value=mock_provider
|
|
):
|
|
with pytest.raises(
|
|
Exception, match="Reading Databricks credential configuration failed with"
|
|
):
|
|
databricks_utils.get_databricks_host_creds()
|
|
|
|
|
|
def test_is_in_databricks_runtime(monkeypatch):
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "11.x")
|
|
assert databricks_utils.is_in_databricks_runtime()
|
|
|
|
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION")
|
|
assert not databricks_utils.is_in_databricks_runtime()
|
|
|
|
|
|
@pytest.mark.parametrize("val", ["true", "1"])
|
|
def test_is_in_databricks_model_serving_environment(monkeypatch, val):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", val)
|
|
assert databricks_utils.is_in_databricks_model_serving_environment()
|
|
|
|
monkeypatch.delenv("IS_IN_DB_MODEL_SERVING_ENV")
|
|
assert not databricks_utils.is_in_databricks_model_serving_environment()
|
|
|
|
|
|
# test both is_in_databricks_model_serving_environment and
|
|
# should_fetch_model_serving_environment_oauth return apprropriate values
|
|
def test_should_fetch_model_serving_environment_oauth(monkeypatch, oauth_file):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
# will return false if file mount is not configured even if env var set
|
|
assert not databricks_utils.should_fetch_model_serving_environment_oauth()
|
|
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._MODEL_DEPENDENCY_OAUTH_TOKEN_FILE_PATH", str(oauth_file)
|
|
):
|
|
# both file mount and env var exist, both values should return true
|
|
assert databricks_utils.should_fetch_model_serving_environment_oauth()
|
|
|
|
# file mount without env var should return false
|
|
monkeypatch.delenv("IS_IN_DB_MODEL_SERVING_ENV")
|
|
assert not databricks_utils.should_fetch_model_serving_environment_oauth()
|
|
|
|
|
|
def test_get_repl_id():
|
|
# Outside of Databricks environments, the Databricks REPL ID should be absent
|
|
assert databricks_utils.get_repl_id() is None
|
|
|
|
mock_client = mock.MagicMock()
|
|
mock_client.getReplId.return_value = "testReplId1"
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
return_value=mock_client,
|
|
):
|
|
assert databricks_utils.get_repl_id() == "testReplId1"
|
|
mock_client.getReplId.assert_called_once()
|
|
|
|
# When runtime_integration_client is unavailable, fall back to entry_point.
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.entry_point.getReplId.return_value = "testReplId1"
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
side_effect=Exception("unavailable"),
|
|
),
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils),
|
|
):
|
|
assert databricks_utils.get_repl_id() == "testReplId1"
|
|
mock_dbutils.entry_point.getReplId.assert_called_once()
|
|
|
|
mock_sparkcontext_inst = mock.MagicMock()
|
|
mock_sparkcontext_inst.getLocalProperty.return_value = "testReplId2"
|
|
mock_sparkcontext_class = mock.MagicMock()
|
|
mock_sparkcontext_class.getOrCreate.return_value = mock_sparkcontext_inst
|
|
mock_spark = mock.MagicMock()
|
|
mock_spark.SparkContext = mock_sparkcontext_class
|
|
|
|
original_import = builtins.__import__
|
|
|
|
def mock_import(name, *args, **kwargs):
|
|
if name == "pyspark":
|
|
return mock_spark
|
|
else:
|
|
return original_import(name, *args, **kwargs)
|
|
|
|
with mock.patch("builtins.__import__", side_effect=mock_import):
|
|
assert databricks_utils.get_repl_id() == "testReplId2"
|
|
|
|
|
|
def test_use_repl_context_if_available(tmp_path, monkeypatch):
|
|
# Simulate a case where `dbruntime.databricks_repl_context.get_context` is unavailable.
|
|
with pytest.raises(ModuleNotFoundError, match="No module named 'dbruntime'"):
|
|
from dbruntime.databricks_repl_context import get_context # noqa: F401
|
|
|
|
command_context_mock = mock.MagicMock()
|
|
command_context_mock.jobId().get.return_value = "job_id"
|
|
command_context_mock.tags().get("jobType").get.return_value = "NORMAL"
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils._get_command_context", return_value=command_context_mock
|
|
) as mock_get_command_context:
|
|
assert databricks_utils.get_job_id() == "job_id"
|
|
mock_get_command_context.assert_called_once()
|
|
|
|
# Create a fake databricks_repl_context module
|
|
dbruntime = tmp_path.joinpath("dbruntime")
|
|
dbruntime.mkdir()
|
|
dbruntime.joinpath("databricks_repl_context.py").write_text(
|
|
"""
|
|
def get_context():
|
|
pass
|
|
"""
|
|
)
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
|
|
# Simulate a case where the REPL context object is not initialized.
|
|
with (
|
|
mock.patch(
|
|
"dbruntime.databricks_repl_context.get_context",
|
|
return_value=None,
|
|
) as mock_get_context,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_command_context", return_value=command_context_mock
|
|
) as mock_get_command_context,
|
|
):
|
|
assert databricks_utils.get_job_id() == "job_id"
|
|
assert mock_get_command_context.call_count == 1
|
|
|
|
with (
|
|
mock.patch(
|
|
"dbruntime.databricks_repl_context.get_context",
|
|
return_value=mock.MagicMock(jobId="job_id"),
|
|
) as mock_get_context,
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils") as mock_dbutils,
|
|
):
|
|
assert databricks_utils.get_job_id() == "job_id"
|
|
mock_get_context.assert_called_once()
|
|
mock_dbutils.assert_not_called()
|
|
|
|
with (
|
|
mock.patch(
|
|
"dbruntime.databricks_repl_context.get_context",
|
|
return_value=mock.MagicMock(
|
|
notebookId="notebook_id", notebookPath="/Repos/notebook_path"
|
|
),
|
|
) as mock_get_context,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_property_from_spark_context"
|
|
) as mock_spark_context,
|
|
):
|
|
assert databricks_utils.get_notebook_id() == "notebook_id"
|
|
assert databricks_utils.is_in_databricks_repo_notebook()
|
|
assert mock_get_context.call_count == 2
|
|
mock_spark_context.assert_not_called()
|
|
|
|
with (
|
|
mock.patch(
|
|
"dbruntime.databricks_repl_context.get_context",
|
|
return_value=mock.MagicMock(
|
|
notebookId="notebook_id", notebookPath="/Users/notebook_path"
|
|
),
|
|
) as mock_get_context,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_property_from_spark_context"
|
|
) as mock_spark_context,
|
|
):
|
|
assert not databricks_utils.is_in_databricks_repo_notebook()
|
|
|
|
with (
|
|
mock.patch(
|
|
"dbruntime.databricks_repl_context.get_context",
|
|
return_value=mock.MagicMock(isInCluster=True),
|
|
) as mock_get_context,
|
|
mock.patch("mlflow.utils._spark_utils._get_active_spark_session") as mock_spark_session,
|
|
):
|
|
assert databricks_utils.is_in_cluster()
|
|
mock_get_context.assert_called_once()
|
|
mock_spark_session.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("get_ipython", [True, None])
|
|
def test_is_running_in_ipython_environment_works(get_ipython):
|
|
mod_name = "IPython"
|
|
if mod_name in sys.modules:
|
|
ipython_mod = sys.modules.pop(mod_name)
|
|
assert not is_running_in_ipython_environment()
|
|
sys.modules["IPython"] = ipython_mod
|
|
|
|
with mock.patch("IPython.get_ipython", return_value=get_ipython):
|
|
assert is_running_in_ipython_environment() == (get_ipython is not None)
|
|
|
|
|
|
def test_get_mlflow_credential_context_by_run_id():
|
|
with (
|
|
mock.patch(
|
|
"mlflow.tracking.artifact_utils.get_artifact_uri", return_value="dbfs:/path/to/artifact"
|
|
) as mock_get_artifact_uri,
|
|
mock.patch(
|
|
"mlflow.utils.uri.get_databricks_profile_uri_from_artifact_uri",
|
|
return_value="databricks://path/to/profile",
|
|
) as mock_get_databricks_profile,
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils.MlflowCredentialContext"
|
|
) as mock_credential_context,
|
|
):
|
|
get_mlflow_credential_context_by_run_id(run_id="abc")
|
|
mock_get_artifact_uri.assert_called_once_with(run_id="abc")
|
|
mock_get_databricks_profile.assert_called_once_with("dbfs:/path/to/artifact")
|
|
mock_credential_context.assert_called_once_with("databricks://path/to/profile")
|
|
|
|
|
|
def test_check_databricks_secret_scope_access():
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.secrets.list.return_value = "random"
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
check_databricks_secret_scope_access("scope")
|
|
mock_dbutils.secrets.list.assert_called_once_with("scope")
|
|
|
|
|
|
def test_check_databricks_secret_scope_access_error():
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.secrets.list.side_effect = Exception("no scope access")
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils),
|
|
mock.patch("mlflow.utils.databricks_utils._logger.warning") as mock_warning,
|
|
):
|
|
check_databricks_secret_scope_access("scope")
|
|
mock_warning.assert_called_once_with(
|
|
"Unable to access Databricks secret scope 'scope' for OpenAI credentials that will be "
|
|
"used to deploy the model to Databricks Model Serving. Please verify that the current "
|
|
"Databricks user has 'READ' permission for this scope. For more information, see "
|
|
"https://mlflow.org/docs/latest/python_api/openai/index.html#credential-management-for-openai-on-databricks. " # noqa: E501
|
|
"Error: no scope access"
|
|
)
|
|
mock_dbutils.secrets.list.assert_called_once_with("scope")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("version_str", "is_client_image", "major", "minor"),
|
|
[
|
|
("client.0", True, 0, 0),
|
|
("client.1", True, 1, 0),
|
|
("client.1.6", True, 1, 6),
|
|
("15.1", False, 15, 1),
|
|
("12.1.1", False, 12, 1),
|
|
],
|
|
)
|
|
def test_get_databricks_runtime_major_minor_version(
|
|
monkeypatch, version_str, is_client_image, major, minor
|
|
):
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", version_str)
|
|
dbr_version = get_databricks_runtime_major_minor_version()
|
|
|
|
assert dbr_version.is_client_image == is_client_image
|
|
assert dbr_version.major == major
|
|
assert dbr_version.minor == minor
|
|
|
|
|
|
def test_get_dbr_major_minor_version_uncut_minor(monkeypatch):
|
|
# '{major}.x' is the latest uncut minor of that major, not an error.
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "12.x")
|
|
dbr_version = get_databricks_runtime_major_minor_version()
|
|
assert dbr_version.major == 12
|
|
assert dbr_version.minor == databricks_utils._UNCUT_MINOR
|
|
|
|
|
|
def test_get_dbr_major_minor_version_throws_on_invalid_version_key(monkeypatch):
|
|
# A malformed minor (not numeric, not the uncut 'x' marker) is still an error.
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "12.yyy")
|
|
with pytest.raises(MlflowException, match="Failed to parse databricks runtime version"):
|
|
get_databricks_runtime_major_minor_version()
|
|
|
|
|
|
def test_prioritize_env_var_config_provider(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "false")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "my_host1")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "token1")
|
|
|
|
class MyProvider(DatabricksConfigProvider):
|
|
def get_config(self):
|
|
return DatabricksConfig(host="my_host2", token="token2")
|
|
|
|
monkeypatch.setattr(databricks_utils, "_dynamic_token_config_provider", MyProvider)
|
|
|
|
hc = get_databricks_host_creds("databricks")
|
|
assert hc.host == "my_host1"
|
|
assert hc.token == "token1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_url", "expected_result"),
|
|
[
|
|
# Test with a valid URL without https:// prefix
|
|
("example.com", "https://example.com"),
|
|
# Test with a valid URL with https:// prefix
|
|
("https://example.com", "https://example.com"),
|
|
# Test with None URL
|
|
(None, None),
|
|
],
|
|
)
|
|
def test_get_workspace_url(input_url, expected_result):
|
|
with mock.patch("mlflow.utils.databricks_utils._get_workspace_url", return_value=input_url):
|
|
result = get_workspace_url()
|
|
assert result == expected_result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("dbr_version", "expected_runtime_version"),
|
|
[
|
|
("15.4.x-scala2.12", "15.4"),
|
|
("18.x-aarch64-photon-scala2", "18.x"),
|
|
("16.2.x-scala2.13", "16.2"),
|
|
],
|
|
)
|
|
@pytest.mark.skipif(is_windows(), reason="This test doesn't work on Windows")
|
|
def test_get_dbconnect_udf_sandbox_info(spark, monkeypatch, dbr_version, expected_runtime_version):
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "client.1.2")
|
|
databricks_utils._dbconnect_udf_sandbox_info_cache = None
|
|
|
|
spark.udf.register(
|
|
"current_version",
|
|
lambda: {"dbr_version": dbr_version},
|
|
returnType="dbr_version string",
|
|
)
|
|
|
|
info = get_dbconnect_udf_sandbox_info(spark)
|
|
assert info.mlflow_version == mlflow.__version__
|
|
# `image_version` comes from DATABRICKS_RUNTIME_VERSION and must stay raw for archive naming.
|
|
assert info.image_version == "client.1.2"
|
|
assert info.runtime_version == expected_runtime_version
|
|
assert info.platform_machine == platform.machine()
|
|
|
|
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION")
|
|
databricks_utils._dbconnect_udf_sandbox_info_cache = None
|
|
|
|
info = get_dbconnect_udf_sandbox_info(spark)
|
|
assert info.mlflow_version == mlflow.__version__
|
|
assert info.image_version == expected_runtime_version
|
|
assert info.runtime_version == expected_runtime_version
|
|
assert info.platform_machine == platform.machine()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("dbr_version", "expected"),
|
|
[
|
|
("15.4.x-scala2.12", (15, 4)),
|
|
("18.x-aarch64-photon-scala2", (18, databricks_utils._UNCUT_MINOR)),
|
|
("16.2.x-scala2.13", (16, 2)),
|
|
("15.3", (15, 3)),
|
|
("18", (18, databricks_utils._UNCUT_MINOR)),
|
|
],
|
|
)
|
|
def test_parse_dbr_runtime_major_minor(dbr_version, expected):
|
|
assert databricks_utils.parse_dbr_runtime_major_minor(dbr_version) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dbr_version",
|
|
[
|
|
"17.yyy",
|
|
"18.foo-bar",
|
|
# Non-ASCII digits (superscript '²', Thai '๓') satisfy str.isdigit() but are not valid
|
|
# DBR minors and must raise rather than be treated as numeric.
|
|
"15.²",
|
|
"15.๓",
|
|
],
|
|
)
|
|
def test_parse_dbr_runtime_major_minor_malformed(dbr_version):
|
|
# A malformed minor (not ASCII-numeric and not the uncut 'x' marker) must raise, not silently
|
|
# degrade to the uncut sentinel.
|
|
with pytest.raises(ValueError, match="Unrecognized Databricks runtime minor version token"):
|
|
databricks_utils.parse_dbr_runtime_major_minor(dbr_version)
|
|
|
|
|
|
def test_parse_dbr_runtime_uncut_minor_sorts_above_concrete_minor():
|
|
# '{major}.x' is the latest uncut minor and must compare greater than any released minor,
|
|
# including a hypothetical future gate threshold within the same major.
|
|
uncut = databricks_utils.parse_dbr_runtime_major_minor("18.x-aarch64-photon-scala2")
|
|
assert uncut > (18, 0)
|
|
assert uncut > (18, 9)
|
|
assert uncut > (18, 99)
|
|
assert uncut < (19, 0)
|
|
|
|
|
|
def test_construct_databricks_uc_registered_model_url():
|
|
# Test case with workspace ID
|
|
workspace_url = "https://databricks.com"
|
|
registered_model_name = "name.mlflow.echo_model"
|
|
version = "6"
|
|
workspace_id = "123"
|
|
|
|
expected_url = (
|
|
"https://databricks.com/explore/data/models/name/mlflow/echo_model/version/6?o=123"
|
|
)
|
|
|
|
result = databricks_utils._construct_databricks_uc_registered_model_url(
|
|
workspace_url=workspace_url,
|
|
registered_model_name=registered_model_name,
|
|
version=version,
|
|
workspace_id=workspace_id,
|
|
)
|
|
|
|
assert result == expected_url
|
|
|
|
# Test case without workspace ID
|
|
expected_url_no_workspace = (
|
|
"https://databricks.com/explore/data/models/name/mlflow/echo_model/version/6"
|
|
)
|
|
|
|
result_no_workspace = databricks_utils._construct_databricks_uc_registered_model_url(
|
|
workspace_url=workspace_url,
|
|
registered_model_name=registered_model_name,
|
|
version=version,
|
|
)
|
|
|
|
assert result_no_workspace == expected_url_no_workspace
|
|
|
|
|
|
def test_construct_databricks_logged_model_url():
|
|
# Test case with workspace ID
|
|
workspace_url = "https://databricks.com"
|
|
experiment_id = "123456"
|
|
model_id = "model_789"
|
|
workspace_id = "123"
|
|
|
|
expected_url = "https://databricks.com/ml/experiments/123456/models/model_789?o=123"
|
|
|
|
result = databricks_utils._construct_databricks_logged_model_url(
|
|
workspace_url=workspace_url,
|
|
experiment_id=experiment_id,
|
|
model_id=model_id,
|
|
workspace_id=workspace_id,
|
|
)
|
|
|
|
assert result == expected_url
|
|
|
|
# Test case without workspace ID
|
|
expected_url_no_workspace = "https://databricks.com/ml/experiments/123456/models/model_789"
|
|
|
|
result_no_workspace = databricks_utils._construct_databricks_logged_model_url(
|
|
workspace_url=workspace_url,
|
|
experiment_id=experiment_id,
|
|
model_id=model_id,
|
|
)
|
|
|
|
assert result_no_workspace == expected_url_no_workspace
|
|
|
|
|
|
def test_print_databricks_deployment_job_url():
|
|
workspace_url = "https://databricks.com"
|
|
job_id = "123"
|
|
workspace_id = "456"
|
|
|
|
expected_url_no_workspace = "https://databricks.com/jobs/123"
|
|
expected_url = f"{expected_url_no_workspace}?o=456"
|
|
model_name = "main.models.name"
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils.eprint") as mock_eprint,
|
|
mock.patch("mlflow.utils.databricks_utils.get_workspace_url", return_value=workspace_url),
|
|
):
|
|
# Test case with a workspace ID
|
|
with mock.patch(
|
|
"mlflow.utils.databricks_utils.get_workspace_id", return_value=workspace_id
|
|
):
|
|
result = databricks_utils._print_databricks_deployment_job_url(
|
|
model_name=model_name,
|
|
job_id=job_id,
|
|
)
|
|
|
|
assert result == expected_url
|
|
mock_eprint.assert_called_once_with(
|
|
f"🔗 Linked deployment job to '{model_name}': {expected_url}"
|
|
)
|
|
mock_eprint.reset_mock()
|
|
|
|
# Test case without a workspace ID
|
|
with mock.patch("mlflow.utils.databricks_utils.get_workspace_id", return_value=None):
|
|
result_no_workspace = databricks_utils._print_databricks_deployment_job_url(
|
|
model_name=model_name,
|
|
job_id=job_id,
|
|
)
|
|
|
|
assert result_no_workspace == expected_url_no_workspace
|
|
mock_eprint.assert_called_once_with(
|
|
f"🔗 Linked deployment job to '{model_name}': {expected_url_no_workspace}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("version_str", "expected_is_client", "expected_major", "expected_minor", "expected_is_gpu"),
|
|
[
|
|
("client.2.0", True, 2, 0, False),
|
|
("client.3.1", True, 3, 1, False),
|
|
("13.2", False, 13, 2, False),
|
|
("15.4", False, 15, 4, False),
|
|
("client.8.1-gpu", True, 8, 1, True),
|
|
("client.10.0-gpu", True, 10, 0, True),
|
|
("14.3-gpu", False, 14, 3, True),
|
|
("15.1-gpu", False, 15, 1, True),
|
|
# Newer uncut images have a non-numeric minor -> latest uncut minor of that major.
|
|
("18.x-photon-scala2", False, 18, databricks_utils._UNCUT_MINOR, False),
|
|
("18.x-aarch64-photon-scala2", False, 18, databricks_utils._UNCUT_MINOR, False),
|
|
("client.5.x", True, 5, databricks_utils._UNCUT_MINOR, False),
|
|
("18.x-gpu", False, 18, databricks_utils._UNCUT_MINOR, True),
|
|
],
|
|
)
|
|
def test_databricks_runtime_version_parse(
|
|
version_str,
|
|
expected_is_client,
|
|
expected_major,
|
|
expected_minor,
|
|
expected_is_gpu,
|
|
):
|
|
version = DatabricksRuntimeVersion.parse(version_str)
|
|
assert version.is_client_image == expected_is_client
|
|
assert version.major == expected_major
|
|
assert version.minor == expected_minor
|
|
assert version.is_gpu_image == expected_is_gpu
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_version", "expected_is_client", "expected_major", "expected_minor", "expected_is_gpu"),
|
|
[
|
|
("client.2.0", True, 2, 0, False),
|
|
("13.2", False, 13, 2, False),
|
|
("client.8.1-gpu", True, 8, 1, True),
|
|
("14.3-gpu", False, 14, 3, True),
|
|
],
|
|
)
|
|
def test_databricks_runtime_version_parse_default(
|
|
monkeypatch,
|
|
env_version,
|
|
expected_is_client,
|
|
expected_major,
|
|
expected_minor,
|
|
expected_is_gpu,
|
|
):
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", env_version)
|
|
version = DatabricksRuntimeVersion.parse()
|
|
assert version.is_client_image == expected_is_client
|
|
assert version.major == expected_major
|
|
assert version.minor == expected_minor
|
|
assert version.is_gpu_image == expected_is_gpu
|
|
|
|
|
|
def test_databricks_runtime_version_parse_default_no_env(monkeypatch):
|
|
"""Test that DatabricksRuntimeVersion.parse() raises error when no environment variable is
|
|
set.
|
|
"""
|
|
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION", raising=False)
|
|
monkeypatch.delenv("DATABRICKS_ENV_VERSION", raising=False)
|
|
with pytest.raises(Exception, match="Failed to parse databricks runtime version"):
|
|
DatabricksRuntimeVersion.parse()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_version", "accelerator", "expected"),
|
|
[
|
|
("4", "A10G", "client.4-gpu"),
|
|
("4", "NVIDIA H100", "client.4-gpu"),
|
|
("4", None, "client.4"),
|
|
],
|
|
)
|
|
def test_get_databricks_runtime_version_from_env_version(
|
|
monkeypatch, env_version, accelerator, expected
|
|
):
|
|
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION", raising=False)
|
|
monkeypatch.setenv("DATABRICKS_ENV_VERSION", env_version)
|
|
if accelerator:
|
|
monkeypatch.setenv("DATABRICKS_ACCELERATOR", accelerator)
|
|
else:
|
|
monkeypatch.delenv("DATABRICKS_ACCELERATOR", raising=False)
|
|
assert get_databricks_runtime_version() == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("accelerator", "expected"),
|
|
[
|
|
("A10G", "client.4-gpu"),
|
|
(None, "client.4"),
|
|
],
|
|
)
|
|
def test_databricks_env_version_takes_priority_over_runtime_version(
|
|
monkeypatch, accelerator, expected
|
|
):
|
|
monkeypatch.setenv("DATABRICKS_ENV_VERSION", "4")
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "client.4.1")
|
|
if accelerator:
|
|
monkeypatch.setenv("DATABRICKS_ACCELERATOR", accelerator)
|
|
else:
|
|
monkeypatch.delenv("DATABRICKS_ACCELERATOR", raising=False)
|
|
assert get_databricks_runtime_version() == expected
|
|
|
|
|
|
def test_databricks_runtime_version_parse_from_env_version(monkeypatch):
|
|
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION", raising=False)
|
|
monkeypatch.setenv("DATABRICKS_ENV_VERSION", "4")
|
|
monkeypatch.setenv("DATABRICKS_ACCELERATOR", "A10")
|
|
version = DatabricksRuntimeVersion.parse()
|
|
assert version.is_client_image is True
|
|
assert version.major == 4
|
|
assert version.minor == 0
|
|
assert version.is_gpu_image is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_version",
|
|
[
|
|
"invalid",
|
|
"client",
|
|
"client.invalid",
|
|
"13",
|
|
# A malformed minor (not numeric, not the uncut 'x' marker) must still raise.
|
|
"17.yyy",
|
|
"18.foo-bar",
|
|
"client.5.yyy",
|
|
],
|
|
)
|
|
def test_databricks_runtime_version_parse_invalid(invalid_version):
|
|
with pytest.raises(Exception, match="Failed to parse databricks runtime version"):
|
|
DatabricksRuntimeVersion.parse(invalid_version)
|
|
|
|
|
|
def test_get_databricks_workspace_client_config_with_tracking_uri_provider():
|
|
# Mock the workspace client and its config
|
|
mock_config = mock.MagicMock()
|
|
mock_client_instance = mock.MagicMock()
|
|
mock_client_instance.config = mock_config
|
|
|
|
# Mock TrackingURIConfigProvider
|
|
mock_uri_config = mock.MagicMock()
|
|
mock_uri_config.host = "https://test.databricks.com"
|
|
mock_uri_config.token = "test_token"
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils.get_db_info_from_uri",
|
|
return_value=("profile_name", "key_prefix"),
|
|
),
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient", return_value=mock_client_instance
|
|
) as mock_workspace_client,
|
|
mock.patch("mlflow.utils.databricks_utils.TrackingURIConfigProvider") as mock_provider,
|
|
):
|
|
mock_provider.return_value.get_config.return_value = mock_uri_config
|
|
|
|
result = get_databricks_workspace_client_config("databricks://profile:prefix")
|
|
|
|
# Verify the WorkspaceClient was created with correct parameters
|
|
mock_workspace_client.assert_called_once_with(
|
|
host="https://test.databricks.com", token="test_token"
|
|
)
|
|
assert result == mock_config
|
|
|
|
|
|
def test_get_databricks_workspace_client_config_with_profile():
|
|
# Mock the workspace client and its config
|
|
mock_config = mock.MagicMock()
|
|
mock_client_instance = mock.MagicMock()
|
|
mock_client_instance.config = mock_config
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils.get_db_info_from_uri",
|
|
return_value=("profile_name", None),
|
|
),
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient", return_value=mock_client_instance
|
|
) as mock_workspace_client,
|
|
):
|
|
result = get_databricks_workspace_client_config("databricks://profile_name")
|
|
|
|
# Verify the WorkspaceClient was created with profile
|
|
mock_workspace_client.assert_called_once_with(profile="profile_name")
|
|
assert result == mock_config
|
|
|
|
|
|
def test_get_databricks_workspace_client_config_env_profile(monkeypatch):
|
|
monkeypatch.setenv("DATABRICKS_CONFIG_PROFILE", "env_profile")
|
|
# Mock the workspace client and its config
|
|
mock_config = mock.MagicMock()
|
|
mock_client_instance = mock.MagicMock()
|
|
mock_client_instance.config = mock_config
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils.get_db_info_from_uri", return_value=(None, None)),
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient", return_value=mock_client_instance
|
|
) as mock_workspace_client,
|
|
):
|
|
result = get_databricks_workspace_client_config("databricks")
|
|
|
|
# Verify the WorkspaceClient was created with environment profile
|
|
mock_workspace_client.assert_called_once_with(profile="env_profile")
|
|
assert result == mock_config
|
|
|
|
|
|
def test_get_databricks_workspace_client_config_client_creation_error():
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils.get_db_info_from_uri", return_value=("profile", None)
|
|
),
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient", side_effect=Exception("Client creation failed")
|
|
),
|
|
):
|
|
with pytest.raises(Exception, match="Client creation failed"):
|
|
get_databricks_workspace_client_config("databricks://profile")
|
|
|
|
|
|
def test_get_sgc_job_run_id_success(monkeypatch):
|
|
monkeypatch.delenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", raising=False)
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.return_value = "test_job_run_id_12345"
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "test_job_run_id_12345"
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_get_sgc_job_run_id_no_dbutils(monkeypatch):
|
|
monkeypatch.delenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", raising=False)
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", side_effect=_NoDbutilsError()):
|
|
result = get_sgc_job_run_id()
|
|
assert result is None
|
|
|
|
|
|
def test_get_sgc_job_run_id_no_dbutils_with_env_var(monkeypatch):
|
|
monkeypatch.setenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", "env_job_run_id_456")
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", side_effect=_NoDbutilsError()):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "env_job_run_id_456"
|
|
|
|
|
|
def test_get_sgc_job_run_id_value_error(monkeypatch):
|
|
monkeypatch.delenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", raising=False)
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.side_effect = ValueError("Widget not found")
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result is None
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_get_sgc_job_run_id_value_error_with_env_var(monkeypatch):
|
|
monkeypatch.setenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", "env_job_run_id_789")
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.side_effect = ValueError("Widget not found")
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "env_job_run_id_789"
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_get_sgc_job_run_id_empty_widget_with_env_var(monkeypatch):
|
|
monkeypatch.setenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", "env_job_run_id_999")
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.return_value = ""
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "env_job_run_id_999"
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_get_sgc_job_run_id_none_widget_with_env_var(monkeypatch):
|
|
monkeypatch.setenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", "env_job_run_id_111")
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.return_value = None
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "env_job_run_id_111"
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_get_sgc_job_run_id_widget_takes_precedence_over_env_var(monkeypatch):
|
|
monkeypatch.setenv("SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID", "env_job_run_id_222")
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_dbutils.widgets.get.return_value = "widget_job_run_id_333"
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils):
|
|
result = get_sgc_job_run_id()
|
|
assert result == "widget_job_run_id_333"
|
|
mock_dbutils.widgets.get.assert_called_once_with(
|
|
"SERVERLESS_GPU_COMPUTE_ASSOCIATED_JOB_RUN_ID"
|
|
)
|
|
|
|
|
|
def test_databricks_config_profile_env_var_is_respected(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "false")
|
|
file_path = tmp_path / ".databrickscfg"
|
|
monkeypatch.setenv("MLFLOW_TRACKING_URI", "databricks")
|
|
monkeypatch.setenv("DATABRICKS_CONFIG_FILE", str(file_path))
|
|
monkeypatch.setenv("DATABRICKS_CONFIG_PROFILE", "test")
|
|
|
|
file_path.write_text("""[DEFAULT]
|
|
host = http://default-workspace.databricks.com
|
|
token = default-token
|
|
|
|
[test]
|
|
host = https://test-workspace.databricks.com
|
|
token = test-token
|
|
""")
|
|
|
|
# the resulting config should be the one from the [test] section
|
|
result = get_databricks_host_creds("databricks")
|
|
assert result.host == "https://test-workspace.databricks.com"
|
|
assert result.token == "test-token"
|
|
|
|
|
|
def test_get_databricks_nfs_temp_dir():
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_client = mock.MagicMock()
|
|
mock_client.getUserNFSTempDir.return_value = "/nfs/user/grpc"
|
|
|
|
# When runtime_integration_client is available, use getUserNFSTempDir from client
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
return_value=mock_client,
|
|
),
|
|
):
|
|
assert databricks_utils.get_databricks_nfs_temp_dir() == "/nfs/user/grpc"
|
|
mock_client.getUserNFSTempDir.assert_called_once()
|
|
|
|
# When runtime_integration_client raises, fall back to entry_point.getUserNFSTempDir
|
|
mock_dbutils2 = mock.MagicMock()
|
|
mock_dbutils2.entry_point.getUserNFSTempDir.return_value = "/nfs/user"
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils2),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
side_effect=Exception("unavailable"),
|
|
),
|
|
):
|
|
assert databricks_utils.get_databricks_nfs_temp_dir() == "/nfs/user"
|
|
mock_dbutils2.entry_point.getUserNFSTempDir.assert_called_once()
|
|
|
|
|
|
def test_get_databricks_local_temp_dir():
|
|
mock_dbutils = mock.MagicMock()
|
|
mock_client = mock.MagicMock()
|
|
mock_client.getUserLocalTempDir.return_value = "/local/user/grpc"
|
|
|
|
# When runtime_integration_client is available, use getUserLocalTempDir from client
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
return_value=mock_client,
|
|
),
|
|
):
|
|
assert databricks_utils.get_databricks_local_temp_dir() == "/local/user/grpc"
|
|
mock_client.getUserLocalTempDir.assert_called_once()
|
|
|
|
# When runtime_integration_client raises, fall back to entry_point.getUserLocalTempDir
|
|
mock_dbutils2 = mock.MagicMock()
|
|
mock_dbutils2.entry_point.getUserLocalTempDir.return_value = "/local/user"
|
|
with (
|
|
mock.patch("mlflow.utils.databricks_utils._get_dbutils", return_value=mock_dbutils2),
|
|
mock.patch(
|
|
"mlflow.utils.databricks_utils._get_runtime_integration_client",
|
|
side_effect=Exception("unavailable"),
|
|
),
|
|
):
|
|
assert databricks_utils.get_databricks_local_temp_dir() == "/local/user"
|
|
mock_dbutils2.entry_point.getUserLocalTempDir.assert_called_once()
|
|
|
|
|
|
def test_get_databricks_host_creds_propagates_workspace_id(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://spog.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.workspace_id = "6051921418418893"
|
|
|
|
mock_ws = mock.MagicMock()
|
|
mock_ws.config = mock_config
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_ws) as mock_ws_cls:
|
|
result = get_databricks_host_creds("databricks")
|
|
# WorkspaceClient must be called without args when profile is None so the SDK
|
|
# uses env-var-based auth (e.g. OIDC). Passing profile=None disables that.
|
|
mock_ws_cls.assert_called_once_with()
|
|
assert result.workspace_id == "6051921418418893"
|
|
assert result.use_databricks_sdk
|
|
|
|
|
|
def test_get_databricks_host_creds_workspace_id_none_when_not_set(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://workspace.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.workspace_id = None
|
|
|
|
mock_ws = mock.MagicMock()
|
|
mock_ws.config = mock_config
|
|
|
|
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_ws):
|
|
result = get_databricks_host_creds("databricks")
|
|
assert result.workspace_id is None
|
|
|
|
|
|
def test_get_databricks_host_creds_workspace_id_from_config_on_sdk_failure(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://spog.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
|
|
mock_config = mock.MagicMock()
|
|
mock_config.workspace_id = "6051921418418893"
|
|
|
|
with (
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient",
|
|
side_effect=Exception("SDK auth failed"),
|
|
),
|
|
mock.patch(
|
|
"databricks.sdk.config.Config",
|
|
return_value=mock_config,
|
|
),
|
|
):
|
|
result = get_databricks_host_creds("databricks")
|
|
assert result.workspace_id == "6051921418418893"
|
|
assert not result.use_databricks_sdk
|
|
|
|
|
|
def test_get_databricks_host_creds_workspace_id_none_on_full_failure(monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_DB_SDK", "true")
|
|
monkeypatch.setenv("DATABRICKS_HOST", "https://workspace.databricks.com")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
|
|
|
|
with (
|
|
mock.patch(
|
|
"databricks.sdk.WorkspaceClient",
|
|
side_effect=Exception("SDK auth failed"),
|
|
),
|
|
mock.patch(
|
|
"databricks.sdk.config.Config",
|
|
side_effect=Exception("Config failed"),
|
|
),
|
|
):
|
|
result = get_databricks_host_creds("databricks")
|
|
assert result.workspace_id is None
|
|
assert not result.use_databricks_sdk
|