2778 lines
103 KiB
Python
2778 lines
103 KiB
Python
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")
|