import json import multiprocessing import os import random import re import subprocess import sys import threading import time import uuid from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from importlib import reload from itertools import zip_longest from pathlib import Path from types import SimpleNamespace from unittest import mock import pandas as pd import polars as pl import pytest import mlflow import mlflow.tracking.context.registry import mlflow.tracking.fluent from mlflow import MlflowClient, clear_active_model, set_active_model from mlflow.data.http_dataset_source import HTTPDatasetSource from mlflow.data.meta_dataset import MetaDataset from mlflow.data.pandas_dataset import from_pandas from mlflow.entities import ( LifecycleStage, Metric, Param, Run, RunData, RunInfo, RunStatus, RunTag, SourceType, ViewType, ) from mlflow.entities.logged_model_status import LoggedModelStatus from mlflow.environment_variables import ( _MLFLOW_ACTIVE_MODEL_ID, _MLFLOW_ENABLE_SGC_RUN_RESUMPTION_FOR_DATABRICKS_JOBS, MLFLOW_ACTIVE_MODEL_ID, MLFLOW_EXPERIMENT_ID, MLFLOW_EXPERIMENT_NAME, MLFLOW_REGISTRY_URI, MLFLOW_RUN_ID, ) from mlflow.exceptions import MlflowException from mlflow.models.model import ( MLMODEL_FILE_NAME, Model, _update_active_model_id_based_on_mlflow_model, ) from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST, TEMPORARILY_UNAVAILABLE from mlflow.store.entities.paged_list import PagedList from mlflow.store.model_registry import ( SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT, ) from mlflow.tracing.constant import TraceMetadataKey from mlflow.tracking.fluent import ( _ACTIVE_MODEL_CONTEXT, ActiveModelContext, _get_active_model_id_global, _get_experiment_id, _get_experiment_id_from_env, _get_sgc_mlflow_run_id_for_resumption, _reset_last_logged_model_id, get_run, search_runs, set_experiment, start_run, ) from mlflow.utils import get_results_from_paginated_fn, mlflow_tags from mlflow.utils.async_logging.async_logging_queue import ( ASYNC_LOGGING_STATUS_CHECK_THREAD_PREFIX, ASYNC_LOGGING_WORKER_THREAD_PREFIX, ) from mlflow.utils.time import get_current_time_millis from tests.tracing.helper import get_traces def create_run( run_id="", exp_id="", uid="", start=0, end=0, metrics=None, params=None, tags=None, status=RunStatus.FINISHED, a_uri=None, ): return Run( RunInfo( run_id=run_id, experiment_id=exp_id, user_id=uid, status=status, start_time=start, end_time=end, lifecycle_stage=LifecycleStage.ACTIVE, artifact_uri=a_uri, ), RunData(metrics=metrics, params=params, tags=tags), ) def create_test_runs_and_expected_data(experiment_id=None): """ Create a pair of runs and a corresponding data to expect when runs are searched for the same experiment. Returns: A tuple of a list and a dictionary """ start_times = [get_current_time_millis(), get_current_time_millis()] end_times = [get_current_time_millis(), get_current_time_millis()] exp_id = experiment_id or "123" runs = [ create_run( status=RunStatus.FINISHED, a_uri="dbfs:/test", run_id="abc", exp_id=exp_id, start=start_times[0], end=end_times[0], metrics=[Metric("mse", 0.2, 0, 0)], params=[Param("param", "value")], tags=[RunTag("tag", "value")], ), create_run( status=RunStatus.SCHEDULED, a_uri="dbfs:/test2", run_id="def", exp_id=exp_id, start=start_times[1], end=end_times[1], metrics=[Metric("mse", 0.6, 0, 0), Metric("loss", 1.2, 0, 5)], params=[Param("param2", "val"), Param("k", "v")], tags=[RunTag("tag2", "v2")], ), ] data = { "status": [RunStatus.FINISHED, RunStatus.SCHEDULED], "artifact_uri": ["dbfs:/test", "dbfs:/test2"], "run_id": ["abc", "def"], "experiment_id": [exp_id] * 2, "start_time": start_times, "end_time": end_times, "metrics.mse": [0.2, 0.6], "metrics.loss": [None, 1.2], "params.param": ["value", None], "params.param2": [None, "val"], "params.k": [None, "v"], "tags.tag": ["value", None], "tags.tag2": [None, "v2"], } return runs, data def create_experiment( experiment_id=uuid.uuid4().hex, name="Test Experiment", artifact_location="/tmp", lifecycle_stage=LifecycleStage.ACTIVE, tags=None, ): return mlflow.entities.Experiment(experiment_id, name, artifact_location, lifecycle_stage, tags) @pytest.fixture(autouse=True) def reset_experiment_id(): """ This fixture resets the active experiment id *after* the execution of the test case in which its included """ yield mlflow.tracking.fluent._active_experiment_id = None @pytest.fixture(autouse=True) def reload_context_registry(): """Reload the context registry module to clear caches.""" reload(mlflow.tracking.context.registry) @pytest.fixture(params=["list", "pandas"]) def search_runs_output_format(request): if "MLFLOW_SKINNY" in os.environ and request.param == "pandas": pytest.skip("pandas output_format is not supported with skinny client") return request.param def test_get_experiment_id_from_env(monkeypatch): # When no env variables are set assert not MLFLOW_EXPERIMENT_NAME.defined assert not MLFLOW_EXPERIMENT_ID.defined assert _get_experiment_id_from_env() is None # set only ID name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) assert exp_id is not None monkeypatch.delenv(MLFLOW_EXPERIMENT_NAME.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_ID.name, str(exp_id)) assert _get_experiment_id_from_env() == exp_id # set only name name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) assert exp_id is not None monkeypatch.delenv(MLFLOW_EXPERIMENT_ID.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_NAME.name, name) assert _get_experiment_id_from_env() == exp_id # create experiment from env name name = f"random experiment {random.randint(1, int(1e6))}" monkeypatch.delenv(MLFLOW_EXPERIMENT_ID.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_NAME.name, name) assert MlflowClient().get_experiment_by_name(name) is None assert _get_experiment_id_from_env() is not None # assert experiment creation from encapsulating function name = f"random experiment {random.randint(1, int(1e6))}" monkeypatch.delenv(MLFLOW_EXPERIMENT_ID.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_NAME.name, name) assert MlflowClient().get_experiment_by_name(name) is None assert _get_experiment_id() is not None # assert raises from conflicting experiment_ids name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) random_id = random.randint(100, int(1e6)) assert exp_id != random_id monkeypatch.delenv(MLFLOW_EXPERIMENT_NAME.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_ID.name, str(random_id)) with pytest.raises( MlflowException, match=( f"The provided {MLFLOW_EXPERIMENT_ID} environment variable value " f"`{random_id}` does not exist in the tracking server" ), ): _get_experiment_id_from_env() # assert raises from name to id mismatch name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) random_id = random.randint(100, int(1e6)) assert exp_id != random_id monkeypatch.setenv(MLFLOW_EXPERIMENT_ID.name, str(random_id)) monkeypatch.setenv(MLFLOW_EXPERIMENT_NAME.name, name) with pytest.raises( MlflowException, match=( f"The provided {MLFLOW_EXPERIMENT_ID} environment variable value " f"`{random_id}` does not match the experiment id" ), ): _get_experiment_id_from_env() # assert does not raise if active experiment is set with invalid env variables invalid_name = "invalid experiment" name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) assert exp_id is not None random_id = random.randint(100, int(1e6)) monkeypatch.setenv(MLFLOW_EXPERIMENT_ID.name, str(random_id)) monkeypatch.setenv(MLFLOW_EXPERIMENT_NAME.name, invalid_name) mlflow.set_experiment(experiment_id=exp_id) assert _get_experiment_id() == exp_id def test_get_experiment_id_with_active_experiment_returns_active_experiment_id(): # Create a new experiment and set that as active experiment name = f"Random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) assert exp_id is not None mlflow.set_experiment(name) assert _get_experiment_id() == exp_id def test_get_experiment_id_with_no_active_experiments_returns_zero(): assert _get_experiment_id() == "0" def test_get_experiment_id_in_databricks_detects_notebook_id_by_default(): notebook_id = 768 with mock.patch( "mlflow.tracking.fluent.default_experiment_registry.get_experiment_id", return_value=notebook_id, ): assert _get_experiment_id() == notebook_id def test_get_experiment_id_in_databricks_with_active_experiment_returns_active_experiment_id(): exp_name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(exp_name) mlflow.set_experiment(exp_name) notebook_id = str(int(exp_id) + 73) with mock.patch( "mlflow.tracking.fluent.default_experiment_registry.get_experiment_id", return_value=notebook_id, ): assert _get_experiment_id() != notebook_id assert _get_experiment_id() == exp_id def test_get_experiment_id_in_databricks_with_experiment_defined_in_env_returns_env_experiment_id( monkeypatch, ): exp_name = f"random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(exp_name) notebook_id = str(int(exp_id) + 73) monkeypatch.delenv(MLFLOW_EXPERIMENT_NAME.name, raising=False) monkeypatch.setenv(MLFLOW_EXPERIMENT_ID.name, str(exp_id)) with mock.patch( "mlflow.tracking.fluent.default_experiment_registry.get_experiment_id", return_value=notebook_id, ): assert _get_experiment_id() != notebook_id assert _get_experiment_id() == exp_id def test_get_experiment_by_id(): name = f"Random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) experiment = mlflow.get_experiment(exp_id) assert experiment.experiment_id == exp_id def test_get_experiment_by_id_with_is_in_databricks_job(): job_exp_id = 123 with mock.patch( "mlflow.tracking.fluent.default_experiment_registry.get_experiment_id", return_value=job_exp_id, ): assert _get_experiment_id() == job_exp_id def test_get_experiment_by_name(): name = f"Random experiment {random.randint(1, int(1e6))}" exp_id = mlflow.create_experiment(name) experiment = mlflow.get_experiment_by_name(name) assert experiment.experiment_id == exp_id def test_search_experiments(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Reduce max results to a small number to speed up test execution MAX_RESULTS = 50 monkeypatch.setattr( "mlflow.store.tracking.sqlalchemy_store.SEARCH_MAX_RESULTS_DEFAULT", MAX_RESULTS ) sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) # Why do we need this line? If we didn't have this line, the first `mlflow.create_experiment` # call in the loop below would create two experiments, the default experiment (when the sqlite # database is initialized) and another one with the specified name. They might have the same # creation time, which makes the search order non-deterministic and this test flaky. mlflow.search_experiments() num_all_experiments = MAX_RESULTS + 1 # +1 for the default experiment num_active_experiments = MAX_RESULTS // 2 num_deleted_experiments = MAX_RESULTS - num_active_experiments active_experiment_names = [f"active_{i}" for i in range(num_active_experiments)] tag_values = ["x", "x", "y"] for tag, active_experiment_name in zip_longest(tag_values, active_experiment_names): # Sleep to ensure that each experiment has a different creation time time.sleep(0.001) mlflow.create_experiment(active_experiment_name, tags={"tag": tag} if tag else None) deleted_experiment_names = [f"deleted_{i}" for i in range(num_deleted_experiments)] for deleted_experiment_name in deleted_experiment_names: time.sleep(0.001) exp_id = mlflow.create_experiment(deleted_experiment_name) mlflow.delete_experiment(exp_id) # max_results is unspecified experiments = mlflow.search_experiments(view_type=ViewType.ALL) assert len(experiments) == num_all_experiments # max_results is larger than the number of experiments in the database experiments = mlflow.search_experiments( view_type=ViewType.ALL, max_results=num_all_experiments + 1 ) assert len(experiments) == num_all_experiments # max_results is equal to the number of experiments in the database experiments = mlflow.search_experiments(view_type=ViewType.ALL, max_results=num_all_experiments) assert len(experiments) == num_all_experiments # max_results is smaller than the number of experiments in the database experiments = mlflow.search_experiments( view_type=ViewType.ALL, max_results=num_all_experiments - 1 ) assert len(experiments) == num_all_experiments - 1 # Filter by view_type experiments = mlflow.search_experiments(view_type=ViewType.ACTIVE_ONLY) assert [e.name for e in experiments] == active_experiment_names[::-1] + ["Default"] experiments = mlflow.search_experiments(view_type=ViewType.DELETED_ONLY) assert [e.name for e in experiments] == deleted_experiment_names[::-1] experiments = mlflow.search_experiments(view_type=ViewType.ALL) assert [e.name for e in experiments] == ( deleted_experiment_names[::-1] + active_experiment_names[::-1] + ["Default"] ) # Filter by name experiments = mlflow.search_experiments(filter_string="name = 'active_1'") assert [e.name for e in experiments] == ["active_1"] experiments = mlflow.search_experiments(filter_string="name ILIKE 'active_%'") assert [e.name for e in experiments] == active_experiment_names[::-1] # Filter by tags experiments = mlflow.search_experiments(filter_string="tags.tag = 'x'") assert [e.name for e in experiments] == active_experiment_names[:2][::-1] experiments = mlflow.search_experiments(filter_string="tags.tag = 'y'") assert [e.experiment_id for e in experiments] == ["3"] # Order by name experiments = mlflow.search_experiments(order_by=["name DESC"], max_results=3) assert [e.name for e in experiments] == sorted(active_experiment_names, reverse=True)[:3] def test_search_registered_models(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) num_all_models = SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT + 1 num_a_models = num_all_models // 4 num_b_models = num_all_models - num_a_models a_model_names = [f"AModel_{i}" for i in range(num_a_models)] b_model_names = [f"BModel_{i}" for i in range(num_b_models)] model_names = b_model_names + a_model_names tag_values = ["x", "x", "y"] for tag, model_name in zip_longest(tag_values, model_names): MlflowClient().create_registered_model(model_name, tags={"tag": tag} if tag else None) # max_results is unspecified models = mlflow.search_registered_models() assert len(models) == num_all_models # max_results is larger than the number of models in the database models = mlflow.search_registered_models(max_results=num_all_models + 1) assert len(models) == num_all_models # max_results is equal to the number of models in the database models = mlflow.search_registered_models(max_results=num_all_models) assert len(models) == num_all_models # max_results is smaller than the number of models in the database models = mlflow.search_registered_models(max_results=num_all_models - 1) assert len(models) == num_all_models - 1 # Filter by name models = mlflow.search_registered_models(filter_string="name = 'AModel_1'") assert [m.name for m in models] == ["AModel_1"] models = mlflow.search_registered_models(filter_string="name ILIKE 'bmodel_%'") assert len(models) == num_b_models # Filter by tags models = mlflow.search_registered_models(filter_string="tags.tag = 'x'") assert [m.name for m in models] == model_names[:2] models = mlflow.search_registered_models(filter_string="tags.tag = 'y'") assert [m.name for m in models] == [model_names[2]] # Order by name models = mlflow.search_registered_models(order_by=["name DESC"], max_results=3) assert [m.name for m in models] == sorted(model_names, reverse=True)[:3] def test_search_model_versions(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) max_results_default = 100 with mock.patch( "mlflow.store.model_registry.SEARCH_MODEL_VERSION_MAX_RESULTS_DEFAULT", max_results_default, ): num_all_model_versions = max_results_default + 1 num_a_model_versions = num_all_model_versions // 4 num_b_model_versions = num_all_model_versions - num_a_model_versions a_model_version_names = ["AModel" for i in range(num_a_model_versions)] b_model_version_names = ["BModel" for i in range(num_b_model_versions)] model_version_names = b_model_version_names + a_model_version_names MlflowClient().create_registered_model(name="AModel") MlflowClient().create_registered_model(name="BModel") tag_values = ["x", "x", "y"] for tag, model_name in zip_longest(tag_values, model_version_names): MlflowClient().create_model_version( name=model_name, source="foo/bar", tags={"tag": tag} if tag else None ) # max_results is unspecified model_versions = mlflow.search_model_versions() assert len(model_versions) == num_all_model_versions # max_results is larger than the number of model versions in the database model_versions = mlflow.search_model_versions(max_results=num_all_model_versions + 1) assert len(model_versions) == num_all_model_versions # max_results is equal to the number of model versions in the database model_versions = mlflow.search_model_versions(max_results=num_all_model_versions) assert len(model_versions) == num_all_model_versions # max_results is smaller than the number of models in the database model_versions = mlflow.search_model_versions(max_results=num_all_model_versions - 1) assert len(model_versions) == num_all_model_versions - 1 # Filter by name model_versions = mlflow.search_model_versions(filter_string="name = 'AModel'") assert [m.name for m in model_versions] == a_model_version_names model_versions = mlflow.search_model_versions(filter_string="name ILIKE 'bmodel'") assert len(model_versions) == num_b_model_versions # Filter by tags model_versions = mlflow.search_model_versions(filter_string="tags.tag = 'x'") assert [m.name for m in model_versions] == model_version_names[:2] model_versions = mlflow.search_model_versions(filter_string="tags.tag = 'y'") assert [m.name for m in model_versions] == [model_version_names[2]] # Order by version_number model_versions = mlflow.search_model_versions( order_by=["version_number ASC"], max_results=5 ) assert [m.version for m in model_versions] == [1, 1, 2, 2, 3] @pytest.fixture def empty_active_run_stack(): with mock.patch("mlflow.tracking.fluent._active_run_stack.get", return_value=[]): yield def is_from_run(active_run, run): return active_run.info == run.info and active_run.data == run.data def test_start_run_defaults(empty_active_run_stack): mlflow.disable_system_metrics_logging() mock_experiment_id = mock.Mock() experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) mock_user = mock.Mock() user_patch = mock.patch( "mlflow.tracking.context.default_context._get_user", return_value=mock_user ) mock_source_name = mock.Mock() source_name_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_name", return_value=mock_source_name ) source_type_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_type", return_value=SourceType.NOTEBOOK ) mock_source_version = mock.Mock() source_version_patch = mock.patch( "mlflow.tracking.context.git_context._resolve_git_info", return_value={mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version}, ) run_name = "my name" expected_tags = { mlflow_tags.MLFLOW_USER: mock_user, mlflow_tags.MLFLOW_SOURCE_NAME: mock_source_name, mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.NOTEBOOK), mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version, mlflow_tags.MLFLOW_RUN_NAME: run_name, } create_run_patch = mock.patch.object(MlflowClient, "create_run") with ( experiment_id_patch, user_patch, source_name_patch, source_type_patch, source_version_patch, create_run_patch, ): active_run = start_run(run_name=run_name) MlflowClient.create_run.assert_called_once_with( experiment_id=mock_experiment_id, tags=expected_tags, run_name="my name" ) assert is_from_run(active_run, MlflowClient.create_run.return_value) def test_start_run_defaults_databricks_notebook( empty_active_run_stack, ): mock_experiment_id = mock.Mock() experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) databricks_notebook_patch = mock.patch( "mlflow.utils.databricks_utils.is_in_databricks_notebook", return_value=True ) mock_user = mock.Mock() user_patch = mock.patch( "mlflow.tracking.context.default_context._get_user", return_value=mock_user ) mock_source_version = mock.Mock() source_version_patch = mock.patch( "mlflow.tracking.context.git_context._resolve_git_info", return_value={mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version}, ) mock_notebook_id = mock.Mock() notebook_id_patch = mock.patch( "mlflow.utils.databricks_utils.get_notebook_id", return_value=mock_notebook_id ) mock_notebook_path = mock.Mock() notebook_path_patch = mock.patch( "mlflow.utils.databricks_utils.get_notebook_path", return_value=mock_notebook_path ) mock_webapp_url = mock.Mock() webapp_url_patch = mock.patch( "mlflow.utils.databricks_utils.get_webapp_url", return_value=mock_webapp_url ) mock_workspace_url = mock.Mock() workspace_url_patch = mock.patch( "mlflow.utils.databricks_utils.get_workspace_url", return_value=mock_workspace_url ) mock_workspace_id = mock.Mock() workspace_info_patch = mock.patch( "mlflow.utils.databricks_utils.get_workspace_id", return_value=mock_workspace_id, ) expected_tags = { mlflow_tags.MLFLOW_USER: mock_user, mlflow_tags.MLFLOW_SOURCE_NAME: mock_notebook_path, mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.NOTEBOOK), mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version, mlflow_tags.MLFLOW_DATABRICKS_NOTEBOOK_ID: mock_notebook_id, mlflow_tags.MLFLOW_DATABRICKS_NOTEBOOK_PATH: mock_notebook_path, mlflow_tags.MLFLOW_DATABRICKS_WEBAPP_URL: mock_webapp_url, mlflow_tags.MLFLOW_DATABRICKS_WORKSPACE_URL: mock_workspace_url, mlflow_tags.MLFLOW_DATABRICKS_WORKSPACE_ID: mock_workspace_id, } create_run_patch = mock.patch.object(MlflowClient, "create_run") with ( experiment_id_patch, databricks_notebook_patch, user_patch, source_version_patch, notebook_id_patch, notebook_path_patch, webapp_url_patch, workspace_url_patch, workspace_info_patch, create_run_patch, ): active_run = start_run() MlflowClient.create_run.assert_called_once_with( experiment_id=mock_experiment_id, tags=expected_tags, run_name=None ) assert is_from_run(active_run, MlflowClient.create_run.return_value) @pytest.mark.parametrize( "experiment_id", [("a", "b"), {"a", "b"}, ["a", "b"], {"a": 1}, [], (), {}] ) def test_start_run_raises_invalid_experiment_id(experiment_id): with pytest.raises(MlflowException, match="Invalid experiment id: "): start_run(experiment_id=experiment_id) @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_creates_new_run_with_user_specified_tags(): mock_experiment_id = mock.Mock() experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) mock_user = mock.Mock() user_patch = mock.patch( "mlflow.tracking.context.default_context._get_user", return_value=mock_user ) mock_source_name = mock.Mock() source_name_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_name", return_value=mock_source_name ) source_type_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_type", return_value=SourceType.NOTEBOOK ) mock_source_version = mock.Mock() source_version_patch = mock.patch( "mlflow.tracking.context.git_context._resolve_git_info", return_value={mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version}, ) user_specified_tags = { "ml_task": "regression", "num_layers": 7, mlflow_tags.MLFLOW_USER: "user_override", } expected_tags = { mlflow_tags.MLFLOW_SOURCE_NAME: mock_source_name, mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.NOTEBOOK), mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version, mlflow_tags.MLFLOW_USER: "user_override", "ml_task": "regression", "num_layers": 7, } create_run_patch = mock.patch.object(MlflowClient, "create_run") with ( experiment_id_patch, user_patch, source_name_patch, source_type_patch, source_version_patch, create_run_patch, ): active_run = start_run(tags=user_specified_tags) MlflowClient.create_run.assert_called_once_with( experiment_id=mock_experiment_id, tags=expected_tags, run_name=None ) assert is_from_run(active_run, MlflowClient.create_run.return_value) @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_resumes_existing_run_and_sets_user_specified_tags(): tags_to_set = { "A": "B", "C": "D", } run_id = mlflow.start_run().info.run_id mlflow.end_run() restarted_run = mlflow.start_run(run_id, tags=tags_to_set) assert tags_to_set.items() <= restarted_run.data.tags.items() def test_start_run_resumes_existing_run_and_update_run_name(): with mlflow.start_run(run_name="old_name") as run: run_id = run.info.run_id with mlflow.start_run(run_id, run_name="new_name"): pass assert MlflowClient().get_run(run_id).info.run_name == "new_name" def test_start_run_with_parent(): parent_run = mock.Mock() mock_experiment_id = "123456" mock_source_name = mock.Mock() active_run_stack_patch = mock.patch( "mlflow.tracking.fluent._active_run_stack.get", lambda: [parent_run] ) mock_user = mock.Mock() user_patch = mock.patch( "mlflow.tracking.context.default_context._get_user", return_value=mock_user ) source_name_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_name", return_value=mock_source_name ) expected_tags = { mlflow_tags.MLFLOW_USER: mock_user, mlflow_tags.MLFLOW_SOURCE_NAME: mock_source_name, mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.LOCAL), mlflow_tags.MLFLOW_PARENT_RUN_ID: parent_run.info.run_id, } create_run_patch = mock.patch.object(MlflowClient, "create_run") with ( active_run_stack_patch, create_run_patch, user_patch, source_name_patch, ): active_run = start_run(experiment_id=mock_experiment_id, nested=True) MlflowClient.create_run.assert_called_once_with( experiment_id=mock_experiment_id, tags=expected_tags, run_name=None ) assert is_from_run(active_run, MlflowClient.create_run.return_value) @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_with_parent_id(): parent_run_id = mlflow.start_run().info.run_id mlflow.end_run() nested_run_id = mlflow.start_run(parent_run_id=parent_run_id).info.run_id mlflow.end_run() assert mlflow.get_parent_run(nested_run_id).info.run_id == parent_run_id @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_with_invalid_parent_id(): with mlflow.start_run() as run: with pytest.raises(MlflowException, match=f"Current run with UUID {run.info.run_id}"): with mlflow.start_run(nested=True, parent_run_id="hello"): pass def test_start_run_with_parent_non_nested(): with mock.patch("mlflow.tracking.fluent._active_run_stack.get", return_value=[mock.Mock()]): with pytest.raises(Exception, match=r"Run with UUID .+ is already active"): start_run() def test_start_run_existing_run(empty_active_run_stack): mock_run = mock.Mock() mock_run.info.lifecycle_stage = LifecycleStage.ACTIVE run_id = uuid.uuid4().hex mock_get_store = mock.patch("mlflow.tracking.fluent._get_store") with mock_get_store, mock.patch.object(MlflowClient, "get_run", return_value=mock_run): active_run = start_run(run_id) assert is_from_run(active_run, mock_run) MlflowClient.get_run.assert_called_with(run_id) def test_start_run_existing_run_from_environment(empty_active_run_stack, monkeypatch): mock_run = mock.Mock() mock_run.info.lifecycle_stage = LifecycleStage.ACTIVE run_id = uuid.uuid4().hex monkeypatch.setenv(MLFLOW_RUN_ID.name, run_id) mock_get_store = mock.patch("mlflow.tracking.fluent._get_store") with mock_get_store, mock.patch.object(MlflowClient, "get_run", return_value=mock_run): active_run = start_run() assert is_from_run(active_run, mock_run) MlflowClient.get_run.assert_called_with(run_id) def test_start_run_existing_run_from_environment_with_set_environment( empty_active_run_stack, monkeypatch ): mock_run = mock.Mock() mock_run.info.lifecycle_stage = LifecycleStage.ACTIVE run_id = uuid.uuid4().hex monkeypatch.setenv(MLFLOW_RUN_ID.name, run_id) with mock.patch.object(MlflowClient, "get_run", return_value=mock_run): set_experiment("test-run") with pytest.raises( MlflowException, match="active experiment ID does not match environment run ID" ): start_run() def test_start_run_existing_run_deleted(empty_active_run_stack): mock_run = mock.Mock() mock_run.info.lifecycle_stage = LifecycleStage.DELETED run_id = uuid.uuid4().hex match = f"Cannot start run with ID {run_id} because it is in the deleted state" with mock.patch.object(MlflowClient, "get_run", return_value=mock_run): with pytest.raises(MlflowException, match=match): start_run(run_id) def test_start_existing_run_status(empty_active_run_stack): run_id = mlflow.start_run().info.run_id mlflow.end_run() assert MlflowClient().get_run(run_id).info.status == RunStatus.to_string(RunStatus.FINISHED) restarted_run = mlflow.start_run(run_id) assert restarted_run.info.status == RunStatus.to_string(RunStatus.RUNNING) def test_start_existing_run_end_time(empty_active_run_stack): run_id = mlflow.start_run().info.run_id mlflow.end_run() run_obj_info = MlflowClient().get_run(run_id).info old_end = run_obj_info.end_time assert run_obj_info.status == RunStatus.to_string(RunStatus.FINISHED) mlflow.start_run(run_id) mlflow.end_run() run_obj_info = MlflowClient().get_run(run_id).info assert run_obj_info.end_time > old_end def test_start_run_with_description(empty_active_run_stack): mock_experiment_id = mock.Mock() experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) mock_user = mock.Mock() user_patch = mock.patch( "mlflow.tracking.context.default_context._get_user", return_value=mock_user ) mock_source_name = mock.Mock() source_name_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_name", return_value=mock_source_name ) source_type_patch = mock.patch( "mlflow.tracking.context.default_context._get_source_type", return_value=SourceType.NOTEBOOK ) mock_source_version = mock.Mock() source_version_patch = mock.patch( "mlflow.tracking.context.git_context._resolve_git_info", return_value={mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version}, ) description = "Test description" expected_tags = { mlflow_tags.MLFLOW_SOURCE_NAME: mock_source_name, mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.NOTEBOOK), mlflow_tags.MLFLOW_GIT_COMMIT: mock_source_version, mlflow_tags.MLFLOW_USER: mock_user, mlflow_tags.MLFLOW_RUN_NOTE: description, } create_run_patch = mock.patch.object(MlflowClient, "create_run") with ( experiment_id_patch, user_patch, source_name_patch, source_type_patch, source_version_patch, create_run_patch, ): active_run = start_run(description=description) MlflowClient.create_run.assert_called_once_with( experiment_id=mock_experiment_id, tags=expected_tags, run_name=None ) assert is_from_run(active_run, MlflowClient.create_run.return_value) def test_start_run_conflicting_description(): description = "Test description" invalid_tags = {mlflow_tags.MLFLOW_RUN_NOTE: "Another description"} match = ( f"Description is already set via the tag {mlflow_tags.MLFLOW_RUN_NOTE} in tags." f"Remove the key {mlflow_tags.MLFLOW_RUN_NOTE} from the tags or omit the description." ) with pytest.raises(MlflowException, match=match): start_run(tags=invalid_tags, description=description) @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_resumes_existing_run_and_sets_description(): description = "Description" run_id = mlflow.start_run().info.run_id mlflow.end_run() restarted_run = mlflow.start_run(run_id, description=description) assert mlflow_tags.MLFLOW_RUN_NOTE in restarted_run.data.tags @pytest.mark.usefixtures(empty_active_run_stack.__name__) def test_start_run_resumes_existing_run_and_sets_description_twice(): description = "Description" invalid_tags = {mlflow_tags.MLFLOW_RUN_NOTE: "Another description"} match = ( f"Description is already set via the tag {mlflow_tags.MLFLOW_RUN_NOTE} in tags." f"Remove the key {mlflow_tags.MLFLOW_RUN_NOTE} from the tags or omit the description." ) run_id = mlflow.start_run().info.run_id mlflow.end_run() with pytest.raises(MlflowException, match=match): mlflow.start_run(run_id, tags=invalid_tags, description=description) def test_get_run(): run_id = uuid.uuid4().hex mock_run = mock.Mock() mock_run.info.user_id = "my_user_id" with mock.patch.object(MlflowClient, "get_run", return_value=mock_run): run = get_run(run_id) assert run.info.user_id == "my_user_id" def validate_search_runs(results, data, output_format): if output_format == "list": keys = ["status", "artifact_uri", "experiment_id", "run_id", "start_time", "end_time"] result_data = defaultdict(list) for run in results: result_data["status"].append(run.info.status) result_data["artifact_uri"].append(run.info.artifact_uri) result_data["experiment_id"].append(run.info.experiment_id) result_data["run_id"].append(run.info.run_id) result_data["start_time"].append(run.info.start_time) result_data["end_time"].append(run.info.end_time) data_subset = {k: data[k] for k in keys if k in keys} assert result_data == data_subset elif output_format == "pandas": expected_df = pd.DataFrame(data) expected_df["start_time"] = pd.to_datetime(expected_df["start_time"], unit="ms", utc=True) expected_df["end_time"] = pd.to_datetime(expected_df["end_time"], unit="ms", utc=True) pd.testing.assert_frame_equal(results, expected_df, check_like=True, check_frame_type=False) else: raise Exception(f"Invalid output format {output_format}") def test_search_runs_attributes(search_runs_output_format): runs, data = create_test_runs_and_expected_data(search_runs_output_format) with mock.patch("mlflow.tracking.fluent.get_results_from_paginated_fn", return_value=runs): pdf = search_runs(output_format=search_runs_output_format) validate_search_runs(pdf, data, search_runs_output_format) @pytest.mark.skipif( "MLFLOW_SKINNY" in os.environ, reason="Skinny client does not support the np or pandas dependencies", ) def test_search_runs_data(): runs, data = create_test_runs_and_expected_data("pandas") with mock.patch("mlflow.tracking.fluent.get_results_from_paginated_fn", return_value=runs): pdf = search_runs() validate_search_runs(pdf, data, "pandas") def test_search_runs_no_arguments(search_runs_output_format): """ When no experiment ID is specified, it should try to get the implicit one. """ mock_experiment_id = mock.Mock() experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) get_paginated_runs_patch = mock.patch( "mlflow.tracking.fluent.get_results_from_paginated_fn", return_value=[] ) with experiment_id_patch, get_paginated_runs_patch: search_runs(output_format=search_runs_output_format) mlflow.tracking.fluent.get_results_from_paginated_fn.assert_called_once() mlflow.tracking.fluent._get_experiment_id.assert_called_once() def test_search_runs_all_experiments(search_runs_output_format): """ When no experiment ID is specified but flag is passed, it should search all experiments. """ from mlflow.entities import Experiment mock_experiment_id = mock.Mock() mock_experiment = mock.Mock(Experiment) experiment_id_patch = mock.patch( "mlflow.tracking.fluent._get_experiment_id", return_value=mock_experiment_id ) experiment_list_patch = mock.patch( "mlflow.tracking.fluent.search_experiments", return_value=[mock_experiment] ) get_paginated_runs_patch = mock.patch( "mlflow.tracking.fluent.get_results_from_paginated_fn", return_value=[] ) with experiment_id_patch, experiment_list_patch, get_paginated_runs_patch: search_runs(output_format=search_runs_output_format, search_all_experiments=True) mlflow.tracking.fluent.search_experiments.assert_called_once() mlflow.tracking.fluent._get_experiment_id.assert_not_called() def test_search_runs_by_experiment_name(): name = f"Random experiment {random.randint(1, int(1e6))}" exp_id = uuid.uuid4().hex experiment = create_experiment(experiment_id=exp_id, name=name) runs, data = create_test_runs_and_expected_data(exp_id) get_experiment_patch = mock.patch( "mlflow.tracking.fluent.get_experiment_by_name", return_value=experiment ) get_paginated_runs_patch = mock.patch( "mlflow.tracking.fluent.get_results_from_paginated_fn", return_value=runs ) with get_experiment_patch, get_paginated_runs_patch: result = search_runs(experiment_names=[name]) validate_search_runs(result, data, "pandas") def test_search_runs_by_non_existing_experiment_name(): """When invalid experiment names are used (including None), it should return an empty collection. """ for name in [None, f"Random {random.randint(1, int(1e6))}"]: assert search_runs(experiment_names=[name], output_format="list") == [] def test_search_runs_by_experiment_id_and_name(): err_msg = "Only experiment_ids or experiment_names can be used, but not both" with pytest.raises(MlflowException, match=err_msg): search_runs(experiment_ids=["id"], experiment_names=["name"]) def test_paginate_lt_maxresults_onepage(): """ Number of runs is less than max_results and fits on one page, so we only need to fetch one page. """ runs = [create_run() for _ in range(5)] tokenized_runs = PagedList(runs, "") max_results = 50 max_per_page = 10 mocked_lambda = mock.Mock(return_value=tokenized_runs) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) mocked_lambda.assert_called_once() assert len(paginated_runs) == 5 def test_paginate_lt_maxresults_multipage(): """ Number of runs is less than max_results, but multiple pages are necessary to get all runs """ tokenized_runs = PagedList([create_run() for _ in range(10)], "token") no_token_runs = PagedList([create_run()], "") max_results = 50 max_per_page = 10 mocked_lambda = mock.Mock(side_effect=[tokenized_runs, tokenized_runs, no_token_runs]) TOTAL_RUNS = 21 paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) assert len(paginated_runs) == TOTAL_RUNS def test_paginate_lt_maxresults_onepage_nonetoken(): """ Number of runs is less than max_results and fits on one page. The token passed back on the last page is None, not the emptystring """ runs = [create_run() for _ in range(5)] tokenized_runs = PagedList(runs, None) max_results = 50 max_per_page = 10 mocked_lambda = mock.Mock(return_value=tokenized_runs) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) mocked_lambda.assert_called_once() assert len(paginated_runs) == 5 def test_paginate_eq_maxresults_blanktoken(): """ Runs returned are equal to max_results which are equal to a full number of pages. The server might send a token back, or they might not (depending on if they know if more runs exist). In this example, no token is sent back. Expected behavior is to NOT query for more pages. """ # runs returned equal to max_results, blank token runs = [create_run() for _ in range(10)] tokenized_runs = PagedList(runs, "") no_token_runs = PagedList([], "") max_results = 10 max_per_page = 10 mocked_lambda = mock.Mock(side_effect=[tokenized_runs, no_token_runs]) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) mocked_lambda.assert_called_once() assert len(paginated_runs) == 10 def test_paginate_eq_maxresults_token(): """ Runs returned are equal to max_results which are equal to a full number of pages. The server might send a token back, or they might not (depending on if they know if more runs exist). In this example, a token IS sent back. Expected behavior is to NOT query for more pages. """ runs = [create_run() for _ in range(10)] tokenized_runs = PagedList(runs, "abc") blank_runs = PagedList([], "") max_results = 10 max_per_page = 10 mocked_lambda = mock.Mock(side_effect=[tokenized_runs, blank_runs]) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) mocked_lambda.assert_called_once() assert len(paginated_runs) == 10 def test_paginate_gt_maxresults_multipage(): """ Number of runs that fit search criteria is greater than max_results. Multiple pages expected. Expected to only get max_results number of results back. """ # should ask for and return the correct number of max_results full_page_runs = PagedList([create_run() for _ in range(8)], "abc") partial_page = PagedList([create_run() for _ in range(4)], "def") max_results = 20 max_per_page = 8 mocked_lambda = mock.Mock(side_effect=[full_page_runs, full_page_runs, partial_page]) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) calls = [mock.call(8, None), mock.call(8, "abc"), mock.call(20 % 8, "abc")] mocked_lambda.assert_has_calls(calls) assert len(paginated_runs) == 20 def test_paginate_gt_maxresults_onepage(): """ Number of runs that fit search criteria is greater than max_results. Only one page expected. Expected to only get max_results number of results back. """ runs = [create_run() for _ in range(10)] tokenized_runs = PagedList(runs, "abc") max_results = 10 max_per_page = 20 mocked_lambda = mock.Mock(return_value=tokenized_runs) paginated_runs = get_results_from_paginated_fn(mocked_lambda, max_per_page, max_results) mocked_lambda.assert_called_once_with(max_results, None) assert len(paginated_runs) == 10 def test_delete_tag(): """ Confirm that fluent API delete tags actually works. """ mlflow.set_tag("a", "b") run = MlflowClient().get_run(mlflow.active_run().info.run_id) assert "a" in run.data.tags mlflow.delete_tag("a") run = MlflowClient().get_run(mlflow.active_run().info.run_id) assert "a" not in run.data.tags with pytest.raises(MlflowException, match="No tag with name"): mlflow.delete_tag("a") with pytest.raises(MlflowException, match="No tag with name"): mlflow.delete_tag("b") mlflow.end_run() def test_last_active_run_returns_currently_active_run(): run_id = mlflow.start_run().info.run_id last_active_run_id = mlflow.last_active_run().info.run_id mlflow.end_run() assert run_id == last_active_run_id def test_last_active_run_returns_most_recently_ended_active_run(): run_id = mlflow.start_run().info.run_id mlflow.log_metric("a", 1.0) mlflow.log_param("b", 2) mlflow.end_run() last_active_run = mlflow.last_active_run() assert last_active_run.info.run_id == run_id assert last_active_run.data.metrics == {"a": 1.0} assert last_active_run.data.params == {"b": "2"} def test_set_experiment_tag(): test_tags = {"new_test_tag_1": "abc", "new_test_tag_2": 5, "new/nested/tag": "cbd"} tag_counter = 0 with start_run() as active_run: test_experiment = active_run.info.experiment_id current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert len(current_experiment.tags) == 0 for tag_key, tag_value in test_tags.items(): mlflow.set_experiment_tag(tag_key, tag_value) tag_counter += 1 current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert tag_counter == len(current_experiment.tags) finished_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert len(finished_experiment.tags) == len(test_tags) for tag_key, tag_value in test_tags.items(): assert str(test_tags[tag_key] == tag_value) def test_set_experiment_tags(): exact_expected_tags = {"name_1": "c", "name_2": "b", "nested/nested/name": 5} with start_run() as active_run: test_experiment = active_run.info.experiment_id current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert len(current_experiment.tags) == 0 mlflow.set_experiment_tags(exact_expected_tags) finished_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) # Validate tags assert len(finished_experiment.tags) == len(exact_expected_tags) for tag_key, tag_value in finished_experiment.tags.items(): assert str(exact_expected_tags[tag_key]) == tag_value def test_delete_experiment_tag(): with start_run() as active_run: test_experiment = active_run.info.experiment_id mlflow.set_experiment_tag("a", "b") current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert "a" in current_experiment.tags mlflow.delete_experiment_tag("a") finished_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment) assert "a" not in finished_experiment.tags @pytest.mark.parametrize("error_code", [RESOURCE_DOES_NOT_EXIST, TEMPORARILY_UNAVAILABLE]) def test_set_experiment_throws_for_unexpected_error(error_code: int): with mock.patch( "mlflow.tracking._tracking_service.client.TrackingServiceClient.create_experiment", side_effect=MlflowException("Unexpected error", error_code=error_code), ) as mock_create_experiment: with pytest.raises(MlflowException, match="Unexpected error"): mlflow.set_experiment("test-experiment") mock_create_experiment.assert_called_once() def test_log_input(tmp_path): df = pd.DataFrame([[1, 2, 3], [1, 2, 3]], columns=["a", "b", "c"]) path = tmp_path / "temp.csv" df.to_csv(path) dataset = from_pandas(df, source=path) with start_run() as run: mlflow.log_input(dataset, "train", {"foo": "baz"}) dataset_inputs = MlflowClient().get_run(run.info.run_id).inputs.dataset_inputs assert len(dataset_inputs) == 1 assert dataset_inputs[0].dataset.name == "dataset" assert dataset_inputs[0].dataset.digest == "f0f3e026" assert dataset_inputs[0].dataset.source_type == "local" assert json.loads(dataset_inputs[0].dataset.source) == {"uri": str(path)} assert json.loads(dataset_inputs[0].dataset.schema) == { "mlflow_colspec": [ {"name": "a", "type": "long", "required": True}, {"name": "b", "type": "long", "required": True}, {"name": "c", "type": "long", "required": True}, ] } assert json.loads(dataset_inputs[0].dataset.profile) == {"num_rows": 2, "num_elements": 6} assert len(dataset_inputs[0].tags) == 2 assert dataset_inputs[0].tags[0].key == "foo" assert dataset_inputs[0].tags[0].value == "baz" assert dataset_inputs[0].tags[1].key == mlflow_tags.MLFLOW_DATASET_CONTEXT assert dataset_inputs[0].tags[1].value == "train" # ensure log_input also works without tags with start_run() as run: mlflow.log_input(dataset, "train") dataset_inputs = MlflowClient().get_run(run.info.run_id).inputs.dataset_inputs assert len(dataset_inputs) == 1 assert dataset_inputs[0].dataset.name == "dataset" assert dataset_inputs[0].dataset.digest == "f0f3e026" assert dataset_inputs[0].dataset.source_type == "local" assert json.loads(dataset_inputs[0].dataset.source) == {"uri": str(path)} assert json.loads(dataset_inputs[0].dataset.schema) == { "mlflow_colspec": [ {"name": "a", "type": "long", "required": True}, {"name": "b", "type": "long", "required": True}, {"name": "c", "type": "long", "required": True}, ] } assert json.loads(dataset_inputs[0].dataset.profile) == {"num_rows": 2, "num_elements": 6} assert len(dataset_inputs[0].tags) == 1 assert dataset_inputs[0].tags[0].key == mlflow_tags.MLFLOW_DATASET_CONTEXT assert dataset_inputs[0].tags[0].value == "train" def test_log_inputs(tmp_path): df1 = pd.DataFrame([[1, 2, 3], [1, 2, 3]], columns=["a", "b", "c"]) path1 = tmp_path / "temp1.csv" df1.to_csv(path1) dataset1 = from_pandas(df1, source=path1) df2 = pd.DataFrame([[4, 5, 6], [4, 5, 6]], columns=["a", "b", "c"]) path2 = tmp_path / "temp2.csv" df2.to_csv(path2) dataset2 = from_pandas(df2, source=path2) df3 = pd.DataFrame([[7, 8, 9], [7, 8, 9]], columns=["a", "b", "c"]) path3 = tmp_path / "temp3.csv" df3.to_csv(path3) dataset3 = from_pandas(df3, source=path3) with start_run() as run: mlflow.log_inputs( [dataset1, dataset2, dataset3], ["train1", "train2", "train3"], [{"foo": "baz"}, None, None], None, ) logged_inputs = MlflowClient().get_run(run.info.run_id).inputs dataset_inputs = logged_inputs.dataset_inputs assert len(dataset_inputs) == 3 assert json.loads(dataset_inputs[0].dataset.source) == {"uri": str(path1)} assert dataset_inputs[0].tags[0].key == "foo" assert dataset_inputs[0].tags[0].value == "baz" assert dataset_inputs[0].tags[1].key == mlflow_tags.MLFLOW_DATASET_CONTEXT assert dataset_inputs[0].tags[1].value == "train1" assert json.loads(dataset_inputs[1].dataset.source) == {"uri": str(path2)} assert dataset_inputs[1].tags[0].key == mlflow_tags.MLFLOW_DATASET_CONTEXT assert dataset_inputs[1].tags[0].value == "train2" assert json.loads(dataset_inputs[2].dataset.source) == {"uri": str(path3)} assert dataset_inputs[2].tags[0].key == mlflow_tags.MLFLOW_DATASET_CONTEXT assert dataset_inputs[2].tags[0].value == "train3" def test_log_input_polars(tmp_path): df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]}) path = tmp_path / "temp.csv" df.write_csv(path) dataset = mlflow.data.from_polars(df, source=path) with start_run() as run: mlflow.log_input(dataset, "train") logged_inputs = MlflowClient().get_run(run.info.run_id).inputs dataset_inputs = logged_inputs.dataset_inputs assert len(dataset_inputs) == 1 assert dataset_inputs[0].dataset.name == "dataset" # Digest value varies across Polars versions due to hash_rows() implementation changes assert re.match(r"^\d+$", dataset_inputs[0].dataset.digest) assert dataset_inputs[0].dataset.source_type == "local" def test_log_input_metadata_only(): source_uri = "test:/my/test/uri" source = HTTPDatasetSource(url=source_uri) dataset = mlflow.data.meta_dataset.MetaDataset(source=source) with start_run() as run: mlflow.log_input(dataset, "train") dataset_inputs = MlflowClient().get_run(run.info.run_id).inputs.dataset_inputs assert len(dataset_inputs) == 1 assert dataset_inputs[0].dataset.name == "dataset" assert dataset_inputs[0].dataset.digest is not None assert dataset_inputs[0].dataset.source_type == "http" assert json.loads(dataset_inputs[0].dataset.source) == {"url": source_uri} def test_get_parent_run(): with mlflow.start_run() as parent: mlflow.log_param("a", 1) mlflow.log_metric("b", 2.0) with mlflow.start_run(nested=True) as child_run: child_run_id = child_run.info.run_id with mlflow.start_run() as run: run_id = run.info.run_id parent_run = mlflow.get_parent_run(child_run_id) assert parent_run.info.run_id == parent.info.run_id assert parent_run.data.metrics == {"b": 2.0} assert parent_run.data.params == {"a": "1"} assert mlflow.get_parent_run(run_id) is None def test_log_metric_async(): run_operations = [] with mlflow.start_run() as parent: run_operations.extend( mlflow.log_metric("async single metric", step=num, value=num, synchronous=False) for num in range(100) ) metrics = {f"async batch metric {num}": num for num in range(100)} run_operations.append(mlflow.log_metrics(metrics=metrics, step=1, synchronous=False)) for run_operation in run_operations: run_operation.wait() parent_run = mlflow.get_run(parent.info.run_id) assert parent_run.info.run_id == parent.info.run_id assert parent_run.data.metrics["async single metric"] == 99 for num in range(100): assert parent_run.data.metrics[f"async batch metric {num}"] == num def test_log_metric_async_throws(): with mlflow.start_run(): with pytest.raises(MlflowException, match="Please specify value as a valid double"): mlflow.log_metric( "async single metric", step=1, value="single metric value", synchronous=False ).wait() with pytest.raises(MlflowException, match="Please specify value as a valid double"): mlflow.log_metrics( metrics={f"async batch metric {num}": "batch metric value" for num in range(2)}, step=1, synchronous=False, ).wait() def test_log_param_async(): run_operations = [] with mlflow.start_run() as parent: run_operations.append(mlflow.log_param("async single param", value="1", synchronous=False)) params = {f"async batch param {num}": num for num in range(100)} run_operations.append(mlflow.log_params(params=params, synchronous=False)) for run_operation in run_operations: run_operation.wait() parent_run = mlflow.get_run(parent.info.run_id) assert parent_run.info.run_id == parent.info.run_id assert parent_run.data.params["async single param"] == "1" for num in range(100): assert parent_run.data.params[f"async batch param {num}"] == str(num) def test_log_param_async_throws(): with mlflow.start_run(): mlflow.log_param("async single param", value="1", synchronous=False).wait() with pytest.raises(MlflowException, match=".*Changing param values is not allowed.*"): mlflow.log_param("async single param", value="2", synchronous=False).wait() mlflow.log_params({"async batch param": "2"}, synchronous=False).wait() with pytest.raises(MlflowException, match=".*Changing param values is not allowed.*"): mlflow.log_params({"async batch param": "3"}, synchronous=False).wait() @pytest.mark.parametrize("flush_within_run", [True, False]) def test_flush_async_logging(flush_within_run): # NB: This test validates whether the async logger threads are cleaned up after flushing. # The validation relies on the thread name so it may false alert if other tests create # similar threads without cleaning them up. To avoid this, we only validates the newly # create threads after the test starts. original_threads = set(threading.enumerate()) with mlflow.start_run() as run: for i in range(100): mlflow.log_metric("dummy", i, step=i, synchronous=False) if flush_within_run: mlflow.flush_async_logging() if not flush_within_run: mlflow.flush_async_logging() metric_history = mlflow.MlflowClient().get_metric_history(run.info.run_id, "dummy") assert len(metric_history) == 100 # Ensure logging worker threads are cleaned up after flushing for thread in set(threading.enumerate()) - original_threads: assert not thread.name.startswith(ASYNC_LOGGING_WORKER_THREAD_PREFIX) assert not thread.name.startswith(ASYNC_LOGGING_STATUS_CHECK_THREAD_PREFIX) def test_enable_async_logging(): mlflow.config.enable_async_logging(True) with mock.patch( "mlflow.utils.async_logging.async_logging_queue.AsyncLoggingQueue.log_batch_async" ) as mock_log_batch_async: with mlflow.start_run(): mlflow.log_metric("dummy", 1) mlflow.log_param("dummy", 1) mlflow.set_tag("dummy", 1) mlflow.log_metrics({"dummy": 1}) mlflow.log_params({"dummy": 1}) mlflow.set_tags({"dummy": 1}) assert mock_log_batch_async.call_count == 6 mlflow.config.enable_async_logging(False) with mock.patch( "mlflow.utils.async_logging.async_logging_queue.AsyncLoggingQueue.log_batch_async" ) as mock_log_batch_async: with mlflow.start_run(): mlflow.log_metric("dummy", 1) mlflow.log_param("dummy", 1) mlflow.set_tag("dummy", 1) mlflow.log_metrics({"dummy": 1}) mlflow.log_params({"dummy": 1}) mlflow.set_tags({"dummy": 1}) mock_log_batch_async.assert_not_called() def test_set_tag_async(): run_operations = [] with mlflow.start_run() as parent: run_operations.append(mlflow.set_tag("async single tag", value="1", synchronous=False)) tags = {f"async batch tag {num}": num for num in range(100)} run_operations.append(mlflow.set_tags(tags=tags, synchronous=False)) for run_operation in run_operations: run_operation.wait() parent_run = mlflow.get_run(parent.info.run_id) assert parent_run.info.run_id == parent.info.run_id assert parent_run.data.tags["async single tag"] == "1" for num in range(100): assert parent_run.data.tags[f"async batch tag {num}"] == str(num) @pytest.fixture def spark_session_with_registry_uri(request): with mock.patch( "mlflow.tracking._model_registry.utils._get_active_spark_session" ) as spark_session_getter: spark = mock.MagicMock() spark_session_getter.return_value = spark spark.conf.get.side_effect = lambda key, _: "http://custom.uri" yield spark def test_registry_uri_from_spark_conf( spark_session_with_registry_uri, monkeypatch: pytest.MonkeyPatch ): assert mlflow.get_registry_uri() == "http://custom.uri" # The MLFLOW_REGISTRY_URI environment variable should still take precedence over the # spark conf if present monkeypatch.setenv(MLFLOW_REGISTRY_URI.name, "something-else") assert mlflow.get_registry_uri() == "something-else" def test_set_experiment_thread_safety(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) origin_create_experiment = MlflowClient.create_experiment def patched_create_experiment(self, *args, **kwargs): # The sleep is for triggering `mlflow.set_experiment` # multiple thread / process execution race condition stably. time.sleep(0.1) return origin_create_experiment(self, *args, **kwargs) with mock.patch( "mlflow.tracking.client.MlflowClient.create_experiment", patched_create_experiment ): created_exp_ids = [] def thread_target(): exp = mlflow.set_experiment("test_experiment") created_exp_ids.append(exp.experiment_id) t1 = threading.Thread(name="test-fluent-set-experiment-1", target=thread_target) t1.start() t2 = threading.Thread(name="test-fluent-set-experiment-2", target=thread_target) t2.start() t1.join() t2.join() # assert the `set_experiment` invocations in the 2 threads both succeed. assert len(created_exp_ids) == 2 # assert the `set_experiment` invocations in the 2 threads use the same experiment ID. assert created_exp_ids[0] == created_exp_ids[1] if os.name == "posix": mp_ctx = multiprocessing.get_context("fork") queue = mp_ctx.Queue() def subprocess_target(que): exp = mlflow.set_experiment("test_experiment2") que.put(exp.experiment_id) subproc1 = mp_ctx.Process(target=subprocess_target, args=(queue,)) subproc1.start() subproc2 = mp_ctx.Process(target=subprocess_target, args=(queue,)) subproc2.start() subproc1.join() subproc2.join() assert subproc1.exitcode == 0 assert subproc2.exitcode == 0 exp_id1 = queue.get(block=False) exp_id2 = queue.get(block=False) assert exp_id1 == exp_id2 def test_subprocess_inherit_active_experiment(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) exp = mlflow.set_experiment("test_experiment") exp_id = exp.experiment_id stdout = subprocess.check_output( [ sys.executable, "-c", "import mlflow; print(mlflow.tracking.fluent._get_experiment_id())", ], text=True, ) assert exp_id in stdout def test_mlflow_active_run_thread_local(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) with mlflow.start_run(): thread_active_run = None def thread_target(): nonlocal thread_active_run thread_active_run = mlflow.active_run() thread1 = threading.Thread(name="test-fluent-active-run", target=thread_target) thread1.start() thread1.join() # assert in another thread, active run is None. assert thread_active_run is None if os.name == "posix": mp_ctx = multiprocessing.get_context("fork") def subprocess_target(): # assert in subprocess, active run is None. assert mlflow.active_run() is None subproc = mp_ctx.Process(target=subprocess_target) subproc.start() subproc.join() assert subproc.exitcode == 0 def test_mlflow_last_active_run_thread_local(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) with mlflow.start_run() as run: pass assert mlflow.last_active_run().info.run_id == run.info.run_id thread_last_active_run = None def thread_target(): nonlocal thread_last_active_run thread_last_active_run = mlflow.last_active_run() thread1 = threading.Thread(name="test-fluent-last-active-run", target=thread_target) thread1.start() thread1.join() # assert in another thread, active run is None. assert thread_last_active_run is None if os.name == "posix": mp_ctx = multiprocessing.get_context("fork") def subprocess_target(): # assert in subprocess, active run is None. assert mlflow.last_active_run() is None subproc = mp_ctx.Process(target=subprocess_target) subproc.start() subproc.join() assert subproc.exitcode == 0 def test_subprocess_inherit_tracking_uri(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_tracking_uri(sqlite_uri) stdout = subprocess.check_output( [ sys.executable, "-c", "import mlflow; print(mlflow.get_tracking_uri())", ], text=True, ) assert sqlite_uri in stdout def test_subprocess_inherit_registry_uri(tmp_path): sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db")) mlflow.set_registry_uri(sqlite_uri) stdout = subprocess.check_output( [ sys.executable, "-c", "import mlflow; print(mlflow.get_registry_uri())", ], text=True, ) assert sqlite_uri in stdout def test_end_run_inside_start_run_context_manager(): client = MlflowClient() with mlflow.start_run() as parent_run: with mlflow.start_run(nested=True) as child_run: mlflow.end_run("FAILED") assert client.get_run(child_run.info.run_id).info.status == RunStatus.to_string( RunStatus.FAILED ) assert client.get_run(parent_run.info.run_id).info.status == RunStatus.to_string( RunStatus.RUNNING ) assert client.get_run(parent_run.info.run_id).info.status == RunStatus.to_string( RunStatus.FINISHED ) def test_runs_are_ended_by_run_id(): with mlflow.start_run() as run: # end run and start it again with the same id # to make sure it's not referentially equal mlflow.end_run() mlflow.start_run(run_id=run.info.run_id) assert mlflow.active_run() is None def test_initialize_logged_model_active_run(): with mlflow.start_run() as run: model = mlflow.initialize_logged_model() assert model.source_run_id == run.info.run_id assert model.experiment_id == run.info.experiment_id exp_id = mlflow.create_experiment("exp") with mlflow.start_run(experiment_id=exp_id) as run: model = mlflow.initialize_logged_model() assert model.source_run_id == run.info.run_id assert model.experiment_id == run.info.experiment_id model = mlflow.initialize_logged_model() assert model.source_run_id is None def test_initialize_logged_model_tags_from_context(): expected_tags = { mlflow_tags.MLFLOW_SOURCE_NAME: "source_name", mlflow_tags.MLFLOW_SOURCE_TYPE: SourceType.to_string(SourceType.NOTEBOOK), mlflow_tags.MLFLOW_GIT_COMMIT: "1234", } with ( mock.patch( "mlflow.tracking.context.default_context._get_source_name", return_value=expected_tags[mlflow_tags.MLFLOW_SOURCE_NAME], ) as m_get_source_name, mock.patch( "mlflow.tracking.context.default_context._get_source_type", return_value=SourceType.from_string(expected_tags[mlflow_tags.MLFLOW_SOURCE_TYPE]), ) as m_get_source_type, mock.patch( "mlflow.tracking.context.git_context._resolve_git_info", return_value={ mlflow_tags.MLFLOW_GIT_COMMIT: expected_tags[mlflow_tags.MLFLOW_GIT_COMMIT] }, ) as m_get_source_version, ): model = mlflow.initialize_logged_model() assert expected_tags.items() <= model.tags.items() m_get_source_name.assert_called_once() m_get_source_type.assert_called_once() m_get_source_version.assert_called_once() def test_log_model_params(): model = mlflow.initialize_logged_model() large_params = {f"param_{i}": f"value_{i}" for i in range(150)} mlflow.log_model_params(large_params, model_id=model.model_id) logged_model = mlflow.get_logged_model(model.model_id) for key, value in large_params.items(): assert logged_model.params.get(key) == value def test_log_model_params_active_model(): model = mlflow.create_external_model() with mlflow.set_active_model(model_id=model.model_id): large_params = {f"param_{i}": f"value_{i}" for i in range(150)} mlflow.log_model_params(large_params) logged_model = mlflow.get_logged_model(model.model_id) assert logged_model.params == large_params def test_finalized_logged_model(): model = mlflow.initialize_logged_model() finalized_model = mlflow.finalize_logged_model( model_id=model.model_id, status=LoggedModelStatus.READY ) assert finalized_model.status == LoggedModelStatus.READY finalized_model = mlflow.finalize_logged_model(model_id=model.model_id, status="READY") assert finalized_model.status == LoggedModelStatus.READY def test_create_external_model(tmp_path): model = mlflow.create_external_model() assert model.status == LoggedModelStatus.READY assert model.tags.get(mlflow_tags.MLFLOW_MODEL_IS_EXTERNAL) == "true" # Verify that an MLmodel file is created with metadata indicating that the model's artifacts # are stored externally mlflow.artifacts.download_artifacts(f"models:/{model.model_id}", dst_path=tmp_path) mlflow_model: Model = Model.load(os.path.join(tmp_path, MLMODEL_FILE_NAME)) assert mlflow_model.metadata is not None assert mlflow_model.metadata.get(mlflow_tags.MLFLOW_MODEL_IS_EXTERNAL) is True exp_id = mlflow.create_experiment("test") with mlflow.start_run(experiment_id=exp_id) as run: pass with mock.patch("mlflow.tracking.fluent._get_experiment_id", return_value=None) as m: model = mlflow.create_external_model(source_run_id=run.info.run_id) m.assert_called_once() assert model.experiment_id == exp_id def test_last_logged_model(): _reset_last_logged_model_id() assert mlflow.last_logged_model() is None model = mlflow.initialize_logged_model() assert mlflow.last_logged_model().model_id == model.model_id client = MlflowClient() client.set_logged_model_tags(model.model_id, {"tag": "value"}) assert mlflow.last_logged_model().tags.get("tag") == "value" client.delete_logged_model_tag(model.model_id, "tag") assert "tag" not in mlflow.last_logged_model().tags external_model = mlflow.create_external_model() assert mlflow.last_logged_model().model_id == external_model.model_id another_model = mlflow.initialize_logged_model() assert mlflow.last_logged_model().model_id == another_model.model_id # model created by client should be ignored client.create_logged_model(experiment_id="0") assert mlflow.last_logged_model().model_id == another_model.model_id # model created by another thread should be ignored t = threading.Thread( name="test-fluent-last-logged-model", daemon=True, target=lambda: mlflow.initialize_logged_model(), ) t.start() t.join() assert mlflow.last_logged_model().model_id == another_model.model_id def test_last_logged_model_log_model(): class Model(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): return model_input model = mlflow.pyfunc.log_model(name="model", python_model=Model()) assert mlflow.last_logged_model().model_id == model.model_id def test_last_logged_model_autolog(): try: from sklearn.linear_model import LinearRegression mlflow.sklearn.autolog(log_models=True) with mlflow.start_run() as run: lr = LinearRegression() lr.fit([[1], [2]], [3, 4]) model = mlflow.last_logged_model() assert model is not None assert model.source_run_id == run.info.run_id finally: mlflow.sklearn.autolog(disable=True) def test_set_and_delete_model_tag(): _reset_last_logged_model_id() model = mlflow.initialize_logged_model() assert mlflow.last_logged_model().model_id == model.model_id mlflow.set_logged_model_tags(model.model_id, {"tag": "value"}) assert mlflow.last_logged_model().tags.get("tag") == "value" mlflow.delete_logged_model_tag(model.model_id, "tag") assert "tag" not in mlflow.last_logged_model().tags def test_search_logged_models(): with mock.patch("mlflow.tracking.fluent.MlflowClient") as MockClient: mock_client = MockClient.return_value mock_client.search_logged_models.return_value = PagedList([], None) experiment_ids = ["123"] filter_string = "name = 'model'" datasets = [{"dataset_name": "dataset"}] max_results = 50 order_by = [{"field_name": "metrics.accuracy", "ascending": False}] mlflow.search_logged_models( experiment_ids=experiment_ids, filter_string=filter_string, datasets=datasets, max_results=max_results, order_by=order_by, output_format="list", ) mock_client.search_logged_models.assert_called_once_with( experiment_ids=experiment_ids, filter_string=filter_string, datasets=datasets, max_results=max_results, order_by=order_by, page_token=None, ) def make_mock_search_logged_model_page(models, token): page = mock.Mock() page.to_list.return_value = models page.token = token return page def test_search_logged_models_pagination(): with mock.patch("mlflow.tracking.fluent.MlflowClient") as MockClient: mock_client = MockClient.return_value page_1 = make_mock_search_logged_model_page(["model_1", "model_2"], "token_1") page_2 = make_mock_search_logged_model_page(["model_3"], None) mock_client.search_logged_models.side_effect = [page_1, page_2] experiment_ids = ["123"] result = mlflow.search_logged_models(experiment_ids=experiment_ids, output_format="list") assert result == [f"model_{i + 1}" for i in range(3)] expected_calls = [ mock.call( experiment_ids=experiment_ids, filter_string=None, datasets=None, max_results=None, order_by=None, page_token=None, ), mock.call( experiment_ids=experiment_ids, filter_string=None, datasets=None, max_results=None, order_by=None, page_token="token_1", ), ] mock_client.search_logged_models.assert_has_calls(expected_calls) def test_search_logged_models_max_results(): with mock.patch("mlflow.tracking.fluent.MlflowClient") as MockClient: mock_client = MockClient.return_value page = make_mock_search_logged_model_page(["model_1", "model_2"], "token_1") mock_client.search_logged_models.side_effect = [page] experiment_ids = ["123"] max_results = 1 result = mlflow.search_logged_models( experiment_ids=experiment_ids, max_results=max_results, output_format="list" ) assert result == ["model_1"] mock_client.search_logged_models.assert_called_once_with( experiment_ids=experiment_ids, filter_string=None, datasets=None, max_results=max_results, order_by=None, page_token=None, ) def test_set_active_model(): assert mlflow.get_active_model_id() is None model = mlflow.create_external_model(name="test_model") set_active_model(name=model.name) assert mlflow.get_active_model_id() == model.model_id set_active_model(model_id=model.model_id) assert mlflow.get_active_model_id() == model.model_id model2 = mlflow.create_external_model(name="test_model") set_active_model(name="test_model") assert mlflow.get_active_model_id() == model2.model_id set_active_model(name="new_model") logged_model = mlflow.search_logged_models( filter_string="name='new_model'", output_format="list" )[0] assert logged_model.name == "new_model" assert mlflow.get_active_model_id() == logged_model.model_id with set_active_model(model_id=model.model_id) as active_model: assert active_model.model_id == model.model_id assert mlflow.get_active_model_id() == model.model_id with set_active_model(name="new_model"): assert mlflow.get_active_model_id() == logged_model.model_id assert mlflow.get_active_model_id() == model.model_id assert mlflow.get_active_model_id() == logged_model.model_id def test_set_active_model_error(): with pytest.raises(MlflowException, match=r"Either name or model_id must be provided"): set_active_model() model = mlflow.create_external_model(name="test_model") with pytest.raises(MlflowException, match=r"does not match the provided name"): set_active_model(name="abc", model_id=model.model_id) with pytest.raises(MlflowException, match=r"Logged model with ID '1234' not found"): set_active_model(model_id="1234") def test_set_active_model_env_var(monkeypatch): monkeypatch.setenv(_MLFLOW_ACTIVE_MODEL_ID.name, "1234") # mimic the behavior when mlflow is imported _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() == "1234" monkeypatch.delenv(_MLFLOW_ACTIVE_MODEL_ID.name) _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() is None assert _MLFLOW_ACTIVE_MODEL_ID.get() is None @pytest.mark.parametrize("is_in_databricks_serving", [False, True]) def test_set_active_model_public_env_var(monkeypatch, is_in_databricks_serving): with mock.patch( "mlflow.tracking.fluent.is_in_databricks_model_serving_environment", return_value=is_in_databricks_serving, ) as mock_is_in_databricks: assert mlflow.get_active_model_id() is None assert _get_active_model_id_global() is None assert MLFLOW_ACTIVE_MODEL_ID.get() is None monkeypatch.setenv(MLFLOW_ACTIVE_MODEL_ID.name, "public-model-id") # mimic the behavior when mlflow is imported _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() == "public-model-id" assert _get_active_model_id_global() == "public-model-id" # In Databricks Model Serving, the active model ID is stored in the # _MLFLOW_ACTIVE_MODEL environment variable. Deleting the MLFLOW_ACTIVE_MODEL # environment variable is insufficient to clear the active model ID. This is # acceptable, since the guidance for users is to call clear_active_model() to clear # the active model ID clear_active_model() assert mlflow.get_active_model_id() is None assert _get_active_model_id_global() is None assert MLFLOW_ACTIVE_MODEL_ID.get() is None # Verify that Databricks model serving environment state was checked assert mock_is_in_databricks.call_count >= 1 def test_set_active_model_env_var_precedence(monkeypatch): # Set both environment variables monkeypatch.setenv(_MLFLOW_ACTIVE_MODEL_ID.name, "legacy-model-id") monkeypatch.setenv(MLFLOW_ACTIVE_MODEL_ID.name, "public-model-id") # mimic the behavior when mlflow is imported _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) # Public variable should take precedence assert mlflow.get_active_model_id() == "public-model-id" # Clean up public variable, should fallback to legacy variable monkeypatch.delenv(MLFLOW_ACTIVE_MODEL_ID.name) _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() == "legacy-model-id" # Clean up legacy variable monkeypatch.delenv(_MLFLOW_ACTIVE_MODEL_ID.name) _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() is None def test_clear_active_model_clears_env_vars(monkeypatch): # Set both environment variables monkeypatch.setenv(_MLFLOW_ACTIVE_MODEL_ID.name, "legacy-model-id") monkeypatch.setenv(MLFLOW_ACTIVE_MODEL_ID.name, "public-model-id") # mimic the behavior when mlflow is imported - should pick up public variable _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() == "public-model-id" # Clear the active model - should disregard environment variables mlflow.clear_active_model() assert mlflow.get_active_model_id() is None # Verify that environment variables are unset by clear_active_model assert MLFLOW_ACTIVE_MODEL_ID.get() is None assert _MLFLOW_ACTIVE_MODEL_ID.get() is None # Even after creating a new context, should remain None _ACTIVE_MODEL_CONTEXT.set(ActiveModelContext()) assert mlflow.get_active_model_id() is None def test_set_active_model_link_traces(): set_active_model(name="test_model") model_id = mlflow.get_active_model_id() assert model_id is not None @mlflow.trace def predict(model_input): return model_input for i in range(3): predict(model_input=i) traces = get_traces() assert len(traces) == 3 for trace in traces: assert trace.info.request_metadata[TraceMetadataKey.MODEL_ID] == model_id # manual start span without model_id with mlflow.start_span(): predict(model_input=1) traces = get_traces() assert len(traces) == 4 assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_id with set_active_model(name="new_model") as new_model: predict(model_input=1) traces = get_traces() assert len(traces) == 5 assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == new_model.model_id assert new_model.model_id != model_id def test_set_active_model_in_databricks_serving(): with mock.patch( "mlflow.tracking.fluent.is_in_databricks_model_serving_environment", return_value=True, ): model = set_active_model(name="test_model") assert mlflow.get_active_model_id() == model.model_id assert _MLFLOW_ACTIVE_MODEL_ID.get() == model.model_id with set_active_model(name="new_model") as new_model: assert mlflow.get_active_model_id() == new_model.model_id assert _MLFLOW_ACTIVE_MODEL_ID.get() == new_model.model_id assert mlflow.get_active_model_id() == model.model_id assert _MLFLOW_ACTIVE_MODEL_ID.get() == model.model_id def test_get_active_model_id_global(): model = mlflow.create_external_model() with ThreadPoolExecutor( max_workers=4, thread_name_prefix="test-fluent-active-model-id" ) as executor: futures = [executor.submit(set_active_model, model_id=model.model_id) for i in range(4)] for f in futures: f.result() assert mlflow.get_active_model_id() is None assert _get_active_model_id_global() == model.model_id with ThreadPoolExecutor( max_workers=4, thread_name_prefix="test-fluent-active-model-name" ) as executor: futures = [executor.submit(set_active_model, name=f"test_model_{i}") for i in range(4)] for f in futures: f.result() with mock.patch("mlflow.tracking.fluent._logger.debug") as mock_debug: assert _get_active_model_id_global() is None assert any( "Failed to get one active model id from all threads" in call_args[0][0] for call_args in mock_debug.call_args_list ) def test_active_model_set_in_threads_can_be_fetched_from_main_process(monkeypatch): monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true") class TestModel(mlflow.pyfunc.PythonModel): @mlflow.trace def predict(self, model_input: list[str]) -> list[str]: return model_input model_info = mlflow.pyfunc.log_model( name="test_model", python_model=TestModel(), input_example=["a", "b", "c"], ) def _load_model(model_uri): pyfunc_model = mlflow.pyfunc.load_model(model_uri) _update_active_model_id_based_on_mlflow_model(pyfunc_model._model_meta) return pyfunc_model with ThreadPoolExecutor(max_workers=4, thread_name_prefix="test-fluent-load-model") as executor: futures = [executor.submit(_load_model, model_info.model_uri) for i in range(4)] for f in futures: f.result() assert mlflow.get_active_model_id() is None assert _get_active_model_id_global() == model_info.model_id def test_log_metric_link_to_active_model(): model = mlflow.create_external_model(name="test_model") set_active_model(name=model.name) with mlflow.start_run(): mlflow.log_metric("metric", 1) logged_model = mlflow.get_logged_model(model_id=model.model_id) assert logged_model.name == model.name assert logged_model.model_id == model.model_id assert logged_model.metrics[0].key == "metric" assert logged_model.metrics[0].value == 1 def test_log_metrics_link_to_active_model(): model = mlflow.create_external_model(name="test_model") set_active_model(name=model.name) with mlflow.start_run(): mlflow.log_metrics({"metric1": 1, "metric2": 2}) logged_model = mlflow.get_logged_model(model_id=model.model_id) assert logged_model.name == model.name assert logged_model.model_id == model.model_id assert len(logged_model.metrics) == 2 assert {m.key: m.value for m in logged_model.metrics} == {"metric1": 1, "metric2": 2} def test_clear_active_model(): @mlflow.trace def predict(model_input): return model_input model = mlflow.create_external_model(name="test_model") set_active_model(name=model.name) assert mlflow.get_active_model_id() == model.model_id predict(1) traces = get_traces() assert len(traces) == 1 assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model.model_id clear_active_model() assert mlflow.get_active_model_id() is None with mlflow.start_run(): mlflow.log_metric("metric", 1) logged_model = mlflow.get_logged_model(model_id=model.model_id) assert logged_model.metrics is None predict(1) traces = get_traces() assert len(traces) == 2 assert TraceMetadataKey.MODEL_ID not in traces[0].info.request_metadata # load model sets the active model again model_info = mlflow.pyfunc.log_model( name="test_model", python_model=predict, input_example=["a", "b", "c"], ) loaded_model = mlflow.pyfunc.load_model(model_info.model_uri) loaded_model.predict(["a", "b", "c"]) traces = get_traces() assert len(traces) == 3 assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id clear_active_model() assert mlflow.get_active_model_id() is None # ensure clear_active_model works when no model is set clear_active_model() assert mlflow.get_active_model_id() is None def test_set_logged_model_tags_error(): with pytest.raises(MlflowException, match="You may not have access to the logged model"): mlflow.set_logged_model_tags("non-existing-model-id", {"tag": "value"}) def test_log_metrics_not_fetching_run_if_active(): with mlflow.start_run(): with mock.patch("mlflow.tracking.fluent.MlflowClient.get_run") as mock_client_get_run: mlflow.log_metrics({"metric": 1}) mock_client_get_run.assert_not_called() def test_log_metrics_with_active_model_log_model_once(): mlflow.set_active_model(name="test_model") with mlflow.start_run(): with ( mock.patch("mlflow.tracking.fluent.MlflowClient.get_run") as mock_client_get_run, mock.patch("mlflow.tracking.fluent.MlflowClient.log_inputs") as mock_client_log_inputs, ): mlflow.log_metrics({"metric": 1}) mlflow.log_metrics({"metric": 2}) mock_client_get_run.assert_not_called() mock_client_log_inputs.assert_called_once() def test_log_metric_with_dataset_entity(): """Test that log_metric works with both mlflow.entities.Dataset and mlflow.data.dataset.Dataset. Regression test for issue https://github.com/mlflow/mlflow/issues/18573. """ # Test with mlflow.entities.Dataset (retrieved from run.inputs) with mlflow.start_run() as run: dataset_source = HTTPDatasetSource(url="some_uri") dataset = MetaDataset(source=dataset_source, name="my_dataset", digest="12345678") mlflow.log_input(dataset, context="eval") run_data = mlflow.get_run(run.info.run_id) dataset_entity = run_data.inputs.dataset_inputs[0].dataset mlflow.log_metric("accuracy", 0.95, dataset=dataset_entity) run_data = mlflow.get_run(run.info.run_id) assert "accuracy" in run_data.data.metrics assert run_data.data.metrics["accuracy"] == 0.95 # Test with mlflow.data.dataset.Dataset (backward compatibility) with mlflow.start_run() as run: dataset_source = HTTPDatasetSource(url="another_uri") dataset = MetaDataset(source=dataset_source, name="my_dataset2", digest="87654321") mlflow.log_metric("precision", 0.92, dataset=dataset) run_data = mlflow.get_run(run.info.run_id) assert "precision" in run_data.data.metrics assert run_data.data.metrics["precision"] == 0.92 def test_get_sgc_mlflow_run_id_for_resumption_with_tag(empty_active_run_stack): # Create an experiment with a tag experiment_id = mlflow.create_experiment("test_sgc_experiment") client = MlflowClient() # Create a run and store its ID in experiment tag run = client.create_run(experiment_id) run_id = run.info.run_id sgc_tag_key = f"{mlflow_tags.MLFLOW_DATABRICKS_SGC_RESUME_RUN_JOB_RUN_ID_PREFIX}.12345" client.set_experiment_tag(experiment_id, sgc_tag_key, run_id) # Test retrieval retrieved_run_id = _get_sgc_mlflow_run_id_for_resumption(client, experiment_id, sgc_tag_key) assert retrieved_run_id == run_id def test_get_sgc_mlflow_run_id_for_resumption_without_tag(empty_active_run_stack): experiment_id = mlflow.create_experiment("test_sgc_no_tag") client = MlflowClient() sgc_tag_key = f"{mlflow_tags.MLFLOW_DATABRICKS_SGC_RESUME_RUN_JOB_RUN_ID_PREFIX}.nonexistent" # Test retrieval when tag doesn't exist retrieved_run_id = _get_sgc_mlflow_run_id_for_resumption(client, experiment_id, sgc_tag_key) assert retrieved_run_id is None def test_get_sgc_mlflow_run_id_for_resumption_with_default_experiment(empty_active_run_stack): # Use default experiment client = MlflowClient() default_exp_id = _get_experiment_id() # Create a run and store its ID in experiment tag run = client.create_run(default_exp_id) run_id = run.info.run_id sgc_tag_key = f"{mlflow_tags.MLFLOW_DATABRICKS_SGC_RESUME_RUN_JOB_RUN_ID_PREFIX}.default" client.set_experiment_tag(default_exp_id, sgc_tag_key, run_id) # Test retrieval with None experiment_id retrieved_run_id = _get_sgc_mlflow_run_id_for_resumption(client, None, sgc_tag_key) assert retrieved_run_id == run_id def test_get_sgc_mlflow_run_id_for_resumption_handles_exception(): client = MlflowClient() # Test with non-existent experiment ID sgc_tag_key = f"{mlflow_tags.MLFLOW_DATABRICKS_SGC_RESUME_RUN_JOB_RUN_ID_PREFIX}.error" retrieved_run_id = _get_sgc_mlflow_run_id_for_resumption( client, "nonexistent_exp_id", sgc_tag_key ) assert retrieved_run_id is None def test_start_run_sgc_resumption_creates_tag(empty_active_run_stack, monkeypatch): experiment_id = mlflow.create_experiment("test_sgc_create_tag") sgc_job_run_id = "12345" # Mock get_sgc_job_run_id to return a job run ID with mock.patch( "mlflow.tracking.fluent.get_sgc_job_run_id", return_value=sgc_job_run_id ) as mock_get_sgc: with mlflow.start_run(experiment_id=experiment_id) as run: run_id = run.info.run_id mock_get_sgc.assert_called_once() # Check that the experiment tag was set client = MlflowClient() exp = client.get_experiment(experiment_id) expected_tag_key = ( f"{mlflow_tags.MLFLOW_DATABRICKS_SGC_RESUME_RUN_JOB_RUN_ID_PREFIX}.{sgc_job_run_id}" ) assert expected_tag_key in exp.tags assert exp.tags[expected_tag_key] == run_id def test_start_run_sgc_resumption_resumes_run(empty_active_run_stack, monkeypatch): experiment_id = mlflow.create_experiment("test_sgc_resume") client = MlflowClient() sgc_job_run_id = "67890" # Create an initial run and set the experiment tag with mock.patch( "mlflow.tracking.fluent.get_sgc_job_run_id", return_value=sgc_job_run_id ) as mock_get_sgc: with mlflow.start_run(experiment_id=experiment_id) as first_run: first_run_id = first_run.info.run_id mlflow.log_param("initial_param", "value1") mock_get_sgc.assert_called() # Start a new run with the same SGC job run ID - should resume the previous run with mock.patch( "mlflow.tracking.fluent.get_sgc_job_run_id", return_value=sgc_job_run_id ) as mock_get_sgc: with mlflow.start_run(experiment_id=experiment_id) as resumed_run: resumed_run_id = resumed_run.info.run_id mlflow.log_param("resumed_param", "value2") mock_get_sgc.assert_called() # Verify it's the same run assert resumed_run_id == first_run_id # Verify both params are present run_data = client.get_run(resumed_run_id) assert run_data.data.params["initial_param"] == "value1" assert run_data.data.params["resumed_param"] == "value2" def test_start_run_sgc_resumption_disabled(empty_active_run_stack, monkeypatch): experiment_id = mlflow.create_experiment("test_sgc_disabled") sgc_job_run_id = "11111" # Disable SGC resumption feature monkeypatch.setenv(_MLFLOW_ENABLE_SGC_RUN_RESUMPTION_FOR_DATABRICKS_JOBS.name, "false") # Mock get_sgc_job_run_id (but won't be used since feature is disabled) with mock.patch("mlflow.tracking.fluent.get_sgc_job_run_id", return_value=sgc_job_run_id): # Create first run with mlflow.start_run(experiment_id=experiment_id) as first_run: first_run_id = first_run.info.run_id # Create second run - should be a new run since feature is disabled with mlflow.start_run(experiment_id=experiment_id) as second_run: second_run_id = second_run.info.run_id # Verify they are different runs assert second_run_id != first_run_id def test_start_run_sgc_resumption_no_job_run_id(empty_active_run_stack, monkeypatch): experiment_id = mlflow.create_experiment("test_sgc_no_job_id") # Mock get_sgc_job_run_id to return None with mock.patch("mlflow.tracking.fluent.get_sgc_job_run_id", return_value=None) as mock_get_sgc: with mlflow.start_run(experiment_id=experiment_id): pass mock_get_sgc.assert_called_once() # No tag should be set since job_run_id is None client = MlflowClient() exp = client.get_experiment(experiment_id) sgc_tags = [key for key in exp.tags.keys() if "sgc" in key.lower()] assert len(sgc_tags) == 0 def test_start_run_sgc_resumption_explicit_run_id_takes_precedence(empty_active_run_stack): experiment_id = mlflow.create_experiment("test_sgc_precedence") client = MlflowClient() # Create a run run1 = client.create_run(experiment_id) run1_id = run1.info.run_id # Start run with explicit run_id, should resume the specified run # SGC logic is bypassed when explicit run_id is provided with mlflow.start_run(run_id=run1_id, experiment_id=experiment_id) as resumed_run: assert resumed_run.info.run_id == run1_id def test_start_run_sgc_resumption_handles_tag_set_error(empty_active_run_stack, monkeypatch): experiment_id = mlflow.create_experiment("test_sgc_tag_error") sgc_job_run_id = "error123" # Mock get_sgc_job_run_id and set_experiment_tag with ( mock.patch( "mlflow.tracking.fluent.get_sgc_job_run_id", return_value=sgc_job_run_id ) as mock_get_sgc, mock.patch.object( MlflowClient, "set_experiment_tag", side_effect=Exception("Tag error") ) as mock_set_tag, ): # Should still create run successfully despite tag error with mlflow.start_run(experiment_id=experiment_id) as run: assert run.info.run_id is not None mock_get_sgc.assert_called() mock_set_tag.assert_called_once() def test_import_checkpoints_overwrite(): exp_id = mlflow.create_experiment("test_import_checkpoints_overwrite") mlflow.set_experiment(experiment_id=exp_id) ws = mock.MagicMock() def patched_list_directory_contents(dir_path): return [ SimpleNamespace(path=f"{dir_path}/ckpt1/"), SimpleNamespace(path=f"{dir_path}/ckpt2"), ] ws.files.list_directory_contents = patched_list_directory_contents with mock.patch("databricks.sdk.WorkspaceClient", return_value=ws): with mlflow.start_run() as run: logged_models = mlflow.import_checkpoints( "/Volumes/checkpoints", model_prefix="model1_", ) assert logged_models[0].name == "model1_ckpt1" assert logged_models[0].source_run_id == run.info.run_id assert logged_models[0].tags["original_artifact_path"] == "/Volumes/checkpoints/ckpt1" assert logged_models[1].name == "model1_ckpt2" assert logged_models[1].tags["original_artifact_path"] == "/Volumes/checkpoints/ckpt2" assert logged_models[1].source_run_id == run.info.run_id ckpt1_model_id = logged_models[0].model_id ckpt2_model_id = logged_models[1].model_id # assert the models are actually logged searched_models = mlflow.search_logged_models( experiment_ids=[exp_id], filter_string=f"model_id IN ('{ckpt1_model_id}', '{ckpt2_model_id}')", output_format="list", ) assert len(searched_models) == 2 # test disabling overwrite logged_models2 = mlflow.import_checkpoints( "/Volumes/checkpoints", model_prefix="model1_", overwrite_checkpoints=False, ) assert len(logged_models2) == 2 assert logged_models[0].model_id == ckpt1_model_id assert logged_models[1].model_id == ckpt2_model_id # check the existing models are not overwritten searched_models2 = mlflow.search_logged_models( experiment_ids=[exp_id], filter_string=f"model_id IN ('{ckpt1_model_id}', '{ckpt2_model_id}')", output_format="list", ) assert len(searched_models2) == 2 # test enabling overwrite overwritten_logged_models = mlflow.import_checkpoints( "/Volumes/checkpoints2", model_prefix="model1_", overwrite_checkpoints=True, ) assert len(overwritten_logged_models) == 2 assert ( overwritten_logged_models[0].tags["original_artifact_path"] == "/Volumes/checkpoints2/ckpt1" ) assert ( overwritten_logged_models[1].tags["original_artifact_path"] == "/Volumes/checkpoints2/ckpt2" ) new_ckpt1_model_id = overwritten_logged_models[0].model_id new_ckpt2_model_id = overwritten_logged_models[1].model_id assert ( len( mlflow.search_logged_models( experiment_ids=[exp_id], filter_string=f"model_id IN ('{ckpt1_model_id}', '{ckpt2_model_id}')", output_format="list", ) ) == 0 ) assert ( len( mlflow.search_logged_models( experiment_ids=[exp_id], filter_string=( f"model_id IN ('{new_ckpt1_model_id}', '{new_ckpt2_model_id}')" ), output_format="list", ) ) == 2 ) def test_import_checkpoints_skip_name_with_invalid_char(): exp_id = mlflow.create_experiment("test_import_checkpoints_skip_name_with_invalid_char") mlflow.set_experiment(experiment_id=exp_id) ws = mock.MagicMock() def patched_list_directory_contents(dir_path): return [ SimpleNamespace(path=os.path.join(dir_path, "ckpt1.a")), SimpleNamespace(path=os.path.join(dir_path, "ckpt2")), ] ws.files.list_directory_contents = patched_list_directory_contents with ( mock.patch("databricks.sdk.WorkspaceClient", return_value=ws), mock.patch("mlflow.tracking.fluent._logger.warning") as mock_warning, ): with mlflow.start_run(): logged_models = mlflow.import_checkpoints( "/Volumes/checkpoints", ) assert len(logged_models) == 1 assert logged_models[0].name == "ckpt2" warn_msg = mock_warning.call_args[0][0] assert "The model name is invalid" in warn_msg assert "ckpt1.a" in warn_msg def test_import_checkpoints_without_run(): exp_id = mlflow.create_experiment("test_import_checkpoints_without_run") mlflow.set_experiment(experiment_id=exp_id) ws = mock.MagicMock() def patched_list_directory_contents(dir_path): return [ SimpleNamespace(path=os.path.join(dir_path, "ckpt")), ] ws.files.list_directory_contents = patched_list_directory_contents mlflow.end_run() with mock.patch("databricks.sdk.WorkspaceClient", return_value=ws): with pytest.raises( MlflowException, match=( "Please set 'source_run_id' or start an active run before " "calling 'import_checkpoints'" ), ): mlflow.import_checkpoints("/Volumes/checkpoints")