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

548 lines
20 KiB
Python

import io
import itertools
import os
import pickle
import uuid
from importlib import reload
from pathlib import Path
from unittest import mock
from urllib.parse import urlparse
from urllib.request import url2pathname
import pytest
import mlflow
from mlflow.environment_variables import (
MLFLOW_ENABLE_WORKSPACES,
MLFLOW_TRACKING_INSECURE_TLS,
MLFLOW_TRACKING_PASSWORD,
MLFLOW_TRACKING_TOKEN,
MLFLOW_TRACKING_URI,
MLFLOW_TRACKING_USERNAME,
)
from mlflow.exceptions import MlflowException
from mlflow.server import ARTIFACT_ROOT_ENV_VAR
from mlflow.store.db.db_types import DATABASE_ENGINES
from mlflow.store.tracking.databricks_rest_store import DatabricksTracingRestStore
from mlflow.store.tracking.file_store import FileStore
from mlflow.store.tracking.rest_store import RestStore
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.tracking._tracking_service.registry import TrackingStoreRegistry
from mlflow.tracking._tracking_service.utils import (
_get_store,
_get_tracking_scheme,
_resolve_custom_scheme,
_resolve_tracking_uri,
_use_tracking_uri,
get_tracking_uri,
set_tracking_uri,
)
from mlflow.tracking.registry import UnsupportedModelRegistryStoreURIException
from mlflow.utils.file_utils import path_to_local_file_uri
from mlflow.utils.os import is_windows
from tests.helpers.db_mocks import mock_get_managed_session_maker
from tests.tracing.helper import get_tracer_tracking_uri
# Disable mocking tracking URI here, as we want to test setting the tracking URI via
# environment variable. See
# http://doc.pytest.org/en/latest/skipping.html#skip-all-test-functions-of-a-class-or-module
# and https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#writing-python-tests
# for more information.
pytestmark = pytest.mark.notrackingurimock
@pytest.mark.skip(reason="FileStore is no longer supported")
def test_tracking_scheme_with_existing_mlruns(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
mlruns_dir = tmp_path / "mlruns"
mlruns_dir.mkdir()
exp_dir = mlruns_dir / "0"
exp_dir.mkdir()
(exp_dir / "meta.yaml").touch()
store = _get_store()
assert isinstance(store, FileStore)
def test_tracking_scheme_without_existing_mlruns(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
store = _get_store()
assert isinstance(store, SqlAlchemyStore)
@pytest.mark.skip(reason="FileStore is no longer supported")
def test_get_store_with_existing_mlruns_data(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
mlruns_dir = tmp_path / "mlruns"
mlruns_dir.mkdir()
exp_dir = mlruns_dir / "0"
exp_dir.mkdir()
(exp_dir / "meta.yaml").touch()
store = _get_store()
assert isinstance(store, FileStore)
assert os.path.abspath(store.root_directory) == os.path.abspath("mlruns")
def test_get_store_with_empty_mlruns(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
mlruns_dir = tmp_path / "mlruns"
mlruns_dir.mkdir()
store = _get_store()
assert isinstance(store, SqlAlchemyStore)
def test_get_store_with_mlruns_dir_but_no_meta_yaml(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
mlruns_dir = tmp_path / "mlruns"
mlruns_dir.mkdir()
(mlruns_dir / "0").mkdir()
store = _get_store()
assert isinstance(store, SqlAlchemyStore)
def test_default_sqlite_tracking_uri_respects_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with _use_tracking_uri(None):
store = _get_store()
assert isinstance(store, SqlAlchemyStore)
sqlite_uri = store.db_uri
assert sqlite_uri.startswith("sqlite:")
parsed = urlparse(sqlite_uri)
path = parsed.path
if not parsed.netloc and path.startswith("//"):
path = path[1:]
if parsed.netloc:
path = f"//{parsed.netloc}{path}"
db_path = Path(url2pathname(path))
assert db_path.parent == tmp_path
@pytest.mark.skip(reason="FileStore is no longer supported")
def test_get_store_file_store_from_arg(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
store = _get_store("other/path")
assert isinstance(store, FileStore)
assert os.path.abspath(store.root_directory) == os.path.abspath("other/path")
@pytest.mark.skip(reason="FileStore is no longer supported")
@pytest.mark.parametrize("uri", ["other/path", "file:other/path"])
def test_get_store_file_store_from_env(tmp_path, monkeypatch, uri):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, uri)
store = _get_store()
assert isinstance(store, FileStore)
assert os.path.abspath(store.root_directory) == os.path.abspath("other/path")
def test_get_store_basic_rest_store(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, "https://my-tracking-server:5050")
store = _get_store()
assert isinstance(store, RestStore)
assert store.get_host_creds().host == "https://my-tracking-server:5050"
assert store.get_host_creds().token is None
assert _get_tracking_scheme() == "https"
def test_get_store_rest_store_with_password(monkeypatch):
for k, v in {
MLFLOW_TRACKING_URI.name: "https://my-tracking-server:5050",
MLFLOW_TRACKING_USERNAME.name: "Bob",
MLFLOW_TRACKING_PASSWORD.name: "Ross",
}.items():
monkeypatch.setenv(k, v)
store = _get_store()
assert isinstance(store, RestStore)
assert store.get_host_creds().host == "https://my-tracking-server:5050"
assert store.get_host_creds().username == "Bob"
assert store.get_host_creds().password == "Ross"
def test_get_store_rest_store_with_token(monkeypatch):
for k, v in {
MLFLOW_TRACKING_URI.name: "https://my-tracking-server:5050",
MLFLOW_TRACKING_TOKEN.name: "my-token",
}.items():
monkeypatch.setenv(k, v)
store = _get_store()
assert isinstance(store, RestStore)
assert store.get_host_creds().token == "my-token"
def test_get_store_rest_store_with_insecure(monkeypatch):
for k, v in {
MLFLOW_TRACKING_URI.name: "https://my-tracking-server:5050",
MLFLOW_TRACKING_INSECURE_TLS.name: "true",
}.items():
monkeypatch.setenv(k, v)
store = _get_store()
assert isinstance(store, RestStore)
assert store.get_host_creds().ignore_tls_verification
def test_get_store_rest_store_with_no_insecure(monkeypatch):
with monkeypatch.context() as m:
for k, v in {
MLFLOW_TRACKING_URI.name: "https://my-tracking-server:5050",
MLFLOW_TRACKING_INSECURE_TLS.name: "false",
}.items():
m.setenv(k, v)
store = _get_store()
assert isinstance(store, RestStore)
assert not store.get_host_creds().ignore_tls_verification
# By default, should not ignore verification.
with monkeypatch.context() as m:
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, "https://my-tracking-server:5050")
store = _get_store()
assert isinstance(store, RestStore)
assert not store.get_host_creds().ignore_tls_verification
@pytest.mark.parametrize("db_type", DATABASE_ENGINES)
def test_get_store_sqlalchemy_store(tmp_path, monkeypatch, db_type):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false")
uri = f"{db_type}://hostname/database-{uuid.uuid4().hex}"
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, uri)
monkeypatch.delenv("MLFLOW_SQLALCHEMYSTORE_POOLCLASS", raising=False)
with (
mock.patch("sqlalchemy.create_engine") as mock_create_engine,
mock.patch("sqlalchemy.event.listens_for"),
mock.patch("mlflow.store.db.utils._verify_schema"),
mock.patch("mlflow.store.db.utils._initialize_tables"),
mock.patch(
"mlflow.store.db.utils._get_managed_session_maker",
new=mock_get_managed_session_maker,
),
mock.patch(
# In sqlalchemy 1.4.0, `SqlAlchemyStore.search_experiments`, which is called when
# fetching the store, results in an error when called with a mocked sqlalchemy engine.
# Accordingly, we mock `SqlAlchemyStore.search_experiments`
"mlflow.store.tracking.sqlalchemy_store.SqlAlchemyStore.search_experiments",
return_value=[],
),
mock.patch(
"mlflow.store.tracking.sqlalchemy_store.SqlAlchemyStore._initialize_store_state",
return_value=None,
),
):
store = _get_store()
assert isinstance(store, SqlAlchemyStore)
assert store.db_uri == uri
# Create another store to ensure the engine is cached
another_store = _get_store()
assert store.engine is another_store.engine
if is_windows():
assert store.artifact_root_uri == Path.cwd().joinpath("mlruns").as_uri()
else:
assert store.artifact_root_uri == Path.cwd().joinpath("mlruns").as_posix()
assert _get_tracking_scheme() == db_type
mock_create_engine.assert_called_once_with(uri, pool_pre_ping=True)
@pytest.mark.parametrize("db_type", DATABASE_ENGINES)
def test_get_store_sqlalchemy_store_with_artifact_uri(tmp_path, monkeypatch, db_type):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false")
uri = f"{db_type}://hostname/database-{uuid.uuid4().hex}"
artifact_uri = "file:artifact/path"
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, uri)
monkeypatch.delenv("MLFLOW_SQLALCHEMYSTORE_POOLCLASS", raising=False)
with (
mock.patch("sqlalchemy.create_engine") as mock_create_engine,
mock.patch("sqlalchemy.event.listens_for"),
mock.patch("mlflow.store.db.utils._verify_schema"),
mock.patch("mlflow.store.db.utils._initialize_tables"),
mock.patch(
"mlflow.store.db.utils._get_managed_session_maker",
new=mock_get_managed_session_maker,
),
mock.patch(
"mlflow.store.tracking.sqlalchemy_store.SqlAlchemyStore.search_experiments",
return_value=[],
),
mock.patch(
"mlflow.store.tracking.sqlalchemy_store.SqlAlchemyStore._initialize_store_state",
return_value=None,
),
):
store = _get_store(artifact_uri=artifact_uri)
assert isinstance(store, SqlAlchemyStore)
assert store.db_uri == uri
if is_windows():
assert store.artifact_root_uri == Path.cwd().joinpath("artifact", "path").as_uri()
else:
assert store.artifact_root_uri == path_to_local_file_uri(
Path.cwd().joinpath("artifact", "path")
)
mock_create_engine.assert_called_once_with(uri, pool_pre_ping=True)
def test_get_sqlalchemy_store_uses_server_artifact_root(tmp_path, monkeypatch):
store_uri = f"sqlite:///{tmp_path.joinpath('backend_store.db')}"
artifact_path = tmp_path / "server-artifacts"
artifact_uri = path_to_local_file_uri(artifact_path)
monkeypatch.setenv(ARTIFACT_ROOT_ENV_VAR, artifact_uri)
with mock.patch("mlflow.store.tracking.sqlalchemy_store.SqlAlchemyStore") as mock_store:
mlflow.tracking._tracking_service.utils._get_sqlalchemy_store(
store_uri=store_uri, artifact_uri=None
)
mock_store.assert_called_once()
assert mock_store.call_args.args[1] == artifact_uri
monkeypatch.delenv(ARTIFACT_ROOT_ENV_VAR, raising=False)
def test_get_store_databricks(monkeypatch):
for k, v in {
MLFLOW_TRACKING_URI.name: "databricks",
"DATABRICKS_HOST": "https://my-tracking-server",
"DATABRICKS_TOKEN": "abcdef",
}.items():
monkeypatch.setenv(k, v)
store = _get_store()
assert isinstance(store, DatabricksTracingRestStore)
assert store.get_host_creds().use_databricks_sdk
assert _get_tracking_scheme() == "databricks"
def test_get_store_databricks_profile(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, "databricks://mycoolprofile")
# It's kind of annoying to setup a profile, and we're not really trying to test
# that anyway, so just check if we raise a relevant exception.
store = _get_store()
assert isinstance(store, DatabricksTracingRestStore)
with pytest.raises(MlflowException, match="mycoolprofile"):
store.get_host_creds()
def test_get_store_caches_on_store_uri_and_artifact_uri(tmp_path):
registry = mlflow.tracking._tracking_service.utils._tracking_store_registry
store_uri_1 = f"sqlite:///{tmp_path.joinpath('backend_store_1.db')}"
store_uri_2 = f"sqlite:///{tmp_path.joinpath('backend_store_2.db')}"
stores_uris = [store_uri_1, store_uri_2]
artifact_uris = [
None,
str(tmp_path.joinpath("artifact_root_1")),
str(tmp_path.joinpath("artifact_root_2")),
]
stores = []
for args in itertools.product(stores_uris, artifact_uris):
store1 = registry.get_store(*args)
store2 = registry.get_store(*args)
assert store1 is store2
stores.append(store1)
assert all(s1 is not s2 for s1, s2 in itertools.combinations(stores, 2))
def test_standard_store_registry_with_mocked_entrypoint():
mock_entrypoint = mock.Mock()
mock_entrypoint.name = "mock-scheme"
with mock.patch("mlflow.utils.plugins._get_entry_points", return_value=[mock_entrypoint]):
# Entrypoints are registered at import time, so we need to reload the
# module to register the entrypoint given by the mocked
# entrypoints.get_group_all
reload(mlflow.tracking._tracking_service.utils)
expected_standard_registry = {
"",
"file",
"http",
"https",
"postgresql",
"mysql",
"sqlite",
"mssql",
"databricks",
"mock-scheme",
}
assert expected_standard_registry.issubset(
mlflow.tracking._tracking_service.utils._tracking_store_registry._registry.keys()
)
@pytest.mark.skip(reason="FileStore is no longer supported")
def test_standard_store_registry_with_installed_plugin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
reload(mlflow.tracking._tracking_service.utils)
assert (
"file-plugin" in mlflow.tracking._tracking_service.utils._tracking_store_registry._registry
)
from mlflow_test_plugin.file_store import PluginFileStore
monkeypatch.setenv(MLFLOW_TRACKING_URI.name, "file-plugin:test-path")
plugin_file_store = mlflow.tracking._tracking_service.utils._get_store()
assert isinstance(plugin_file_store, PluginFileStore)
assert plugin_file_store.is_plugin
assert _get_tracking_scheme() == "custom_scheme"
def test_plugin_registration():
tracking_store = TrackingStoreRegistry()
test_uri = "mock-scheme://fake-host/fake-path"
test_scheme = "mock-scheme"
mock_plugin = mock.Mock()
tracking_store.register(test_scheme, mock_plugin)
assert test_scheme in tracking_store._registry
assert tracking_store.get_store(test_uri) == mock_plugin.return_value
mock_plugin.assert_called_once_with(store_uri=test_uri, artifact_uri=None)
def test_plugin_registration_via_entrypoints():
mock_plugin_function = mock.Mock()
mock_entrypoint = mock.Mock(load=mock.Mock(return_value=mock_plugin_function))
mock_entrypoint.name = "mock-scheme"
with mock.patch(
"mlflow.utils.plugins._get_entry_points", return_value=[mock_entrypoint]
) as mock_get_group_all:
tracking_store = TrackingStoreRegistry()
tracking_store.register_entrypoints()
assert tracking_store.get_store("mock-scheme://") == mock_plugin_function.return_value
mock_plugin_function.assert_called_once_with(store_uri="mock-scheme://", artifact_uri=None)
mock_get_group_all.assert_called_once_with("mlflow.tracking_store")
@pytest.mark.parametrize(
"exception", [AttributeError("test exception"), ImportError("test exception")]
)
def test_handle_plugin_registration_failure_via_entrypoints(exception):
mock_entrypoint = mock.Mock(load=mock.Mock(side_effect=exception))
mock_entrypoint.name = "mock-scheme"
with mock.patch(
"mlflow.utils.plugins._get_entry_points", return_value=[mock_entrypoint]
) as mock_get_group_all:
tracking_store = TrackingStoreRegistry()
# Check that the raised warning contains the message from the original exception
with pytest.warns(UserWarning, match="test exception"):
tracking_store.register_entrypoints()
mock_entrypoint.load.assert_called_once()
mock_get_group_all.assert_called_once_with("mlflow.tracking_store")
def test_get_store_for_unregistered_scheme():
tracking_store = TrackingStoreRegistry()
with pytest.raises(
UnsupportedModelRegistryStoreURIException,
match="Model registry functionality is unavailable",
):
tracking_store.get_store("unknown-scheme://")
def test_resolve_tracking_uri_with_param():
with mock.patch(
"mlflow.tracking._tracking_service.utils.get_tracking_uri",
return_value="databricks://tracking_qoeirj",
):
overriding_uri = "databricks://tracking_poiwerow"
assert _resolve_tracking_uri(overriding_uri) == overriding_uri
def test_resolve_tracking_uri_with_no_param():
with mock.patch(
"mlflow.tracking._tracking_service.utils.get_tracking_uri",
return_value="databricks://tracking_zlkjdas",
):
assert _resolve_tracking_uri() == "databricks://tracking_zlkjdas"
@pytest.mark.skip(reason="FileStore is no longer supported")
def test_store_object_can_be_serialized_by_pickle(tmp_path):
"""
This test ensures a store object generated by `_get_store` can be serialized by pickle
to prevent issues such as https://github.com/mlflow/mlflow/issues/2954
"""
pickle.dump(_get_store(f"file:///{tmp_path.joinpath('mlflow')}"), io.BytesIO())
pickle.dump(_get_store("databricks"), io.BytesIO())
pickle.dump(_get_store("https://example.com"), io.BytesIO())
# pickle.dump(_get_store(f"sqlite:///{tmpdir.strpath}/mlflow.db"), io.BytesIO())
# This throws `AttributeError: Can't pickle local object 'create_engine.<locals>.connect'`
@pytest.mark.parametrize("absolute", [True, False], ids=["absolute", "relative"])
def test_set_tracking_uri_with_path(tmp_path, monkeypatch, absolute):
monkeypatch.chdir(tmp_path)
path = Path("foo/bar")
if absolute:
path = tmp_path / path
with mock.patch("mlflow.tracking._tracking_service.utils._tracking_uri", None):
set_tracking_uri(path)
assert get_tracking_uri() == path.absolute().resolve().as_uri()
def test_set_tracking_uri_update_trace_provider(tmp_path):
default_uri = mlflow.get_tracking_uri()
sqlite_uri = f"sqlite:///{tmp_path / 'mlflow.db'}"
try:
assert get_tracer_tracking_uri() != sqlite_uri
set_tracking_uri(sqlite_uri)
assert get_tracer_tracking_uri() == sqlite_uri
set_tracking_uri("https://foo")
assert get_tracer_tracking_uri() == "https://foo"
finally:
# clean up
set_tracking_uri(default_uri)
@pytest.mark.parametrize("store_uri", ["databricks-uc", "databricks-uc://profile"])
def test_get_store_raises_on_uc_uri(store_uri):
set_tracking_uri(store_uri)
with pytest.raises(
MlflowException,
match="Setting the tracking URI to a Unity Catalog backend is not "
"supported in the current version of the MLflow client",
):
mlflow.tracking.MlflowClient()
assert _get_tracking_scheme() == "databricks-uc"
@pytest.mark.parametrize("tracking_uri", ["file:///tmp/mlruns", "sqlite:///tmp/mlruns.db", ""])
def test_set_get_tracking_uri_consistency(tracking_uri):
mlflow.set_tracking_uri(tracking_uri)
assert mlflow.get_tracking_uri() == tracking_uri
def test_get_tracking_scheme():
assert _get_tracking_scheme("uc://profile@databricks") == "uc"
# no builder registered for custom scheme
assert _get_tracking_scheme("custom-scheme://") == "None"
@pytest.mark.parametrize(
("scheme", "uri", "expected"),
[
("arn", "arn:aws:sagemaker:us-east-1:123456789:mlflow-tracking-server/my-server", "aws"),
("arn", "arn:aws:sagemaker:eu-west-1:987654321:mlflow-tracking-server/test", "aws"),
("azureml", "azureml://eastus.api.azureml.ms/mlflow/v2.0/subscriptions/123", "azure"),
("azureml", "azureml://workspace", "azure"),
("some-plugin", "some-plugin://host/path", "custom_scheme"),
],
)
def test_resolve_custom_scheme(scheme, uri, expected):
assert _resolve_custom_scheme(scheme, uri) == expected