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

5530 lines
197 KiB
Python

import json
import logging
import math
import os
import pathlib
import posixpath
import subprocess
import sys
import time
import urllib.parse
from dataclasses import asdict
from io import StringIO
from pathlib import Path
from unittest import mock
import flask
import pandas as pd
import pytest
import requests
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
import mlflow.experiments
import mlflow.pyfunc
import mlflow.tracing.trace_archival_config as trace_archival_config_module
from mlflow import MlflowClient
from mlflow.artifacts import download_artifacts
from mlflow.data.pandas_dataset import from_pandas
from mlflow.entities import (
Dataset,
DatasetInput,
FallbackConfig,
FallbackStrategy,
GatewayEndpointModelConfig,
GatewayModelLinkageType,
GatewayResourceType,
InputTag,
IssueSeverity,
IssueStatus,
Metric,
Param,
RoutingStrategy,
RunInputs,
RunTag,
Span,
SpanEvent,
SpanStatusCode,
ViewType,
)
from mlflow.entities.logged_model_input import LoggedModelInput
from mlflow.entities.logged_model_output import LoggedModelOutput
from mlflow.entities.logged_model_status import LoggedModelStatus
from mlflow.entities.span import SpanAttributeKey
from mlflow.entities.trace_data import TraceData
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_location import TraceLocation
from mlflow.entities.trace_metrics import (
AggregationType,
MetricAggregation,
MetricViewType,
)
from mlflow.entities.trace_state import TraceState
from mlflow.entities.trace_status import TraceStatus
from mlflow.environment_variables import (
_MLFLOW_GO_STORE_TESTING,
MLFLOW_SERVER_GRAPHQL_MAX_ALIASES,
MLFLOW_SERVER_GRAPHQL_MAX_ROOT_FIELDS,
MLFLOW_SUPPRESS_PRINTING_URL_TO_STDOUT,
MLFLOW_TRACE_ARCHIVAL_CONFIG,
)
from mlflow.exceptions import MlflowException, RestException
from mlflow.genai.datasets import (
add_dataset_to_experiments,
create_dataset,
remove_dataset_from_experiments,
)
from mlflow.models import Model
from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST, ErrorCode
from mlflow.server import handlers
from mlflow.server.fastapi_app import app
from mlflow.server.handlers import initialize_backend_stores
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.tracing.analysis import TraceFilterCorrelationResult
from mlflow.tracing.client import TracingClient
from mlflow.tracing.constant import (
TRACE_SCHEMA_VERSION_KEY,
TraceMetricDimensionKey,
TraceMetricKey,
)
from mlflow.tracing.utils import build_otel_context
from mlflow.utils import mlflow_tags
from mlflow.utils.file_utils import TempDir, path_to_local_file_uri
from mlflow.utils.mlflow_tags import (
MLFLOW_DATASET_CONTEXT,
MLFLOW_GIT_COMMIT,
MLFLOW_PARENT_RUN_ID,
MLFLOW_PROJECT_ENTRY_POINT,
MLFLOW_SOURCE_NAME,
MLFLOW_SOURCE_TYPE,
MLFLOW_USER,
)
from mlflow.utils.os import is_windows
from mlflow.utils.proto_json_utils import message_to_json
from mlflow.utils.time import get_current_time_millis
from tests.helper_functions import get_safe_port
from tests.integration.utils import invoke_cli_runner
from tests.tracking.integration_test_utils import (
ServerThread,
_init_server,
_send_rest_tracking_post_request,
)
_logger = logging.getLogger(__name__)
@pytest.fixture(params=["file", "sqlalchemy"])
def store_type(request):
"""Provides the store type for parameterized tests."""
if request.param == "file":
pytest.skip("FileStore is no longer supported.")
return request.param
@pytest.fixture
def mlflow_client(store_type: str, tmp_path: Path, db_uri: str, monkeypatch):
"""Provides an MLflow Tracking API client pointed at the local tracking server."""
# Set passphrase for secrets management (required for encryption)
monkeypatch.setenv(
"MLFLOW_CRYPTO_KEK_PASSPHRASE", "test-passphrase-at-least-32-characters-long"
)
monkeypatch.delenv(MLFLOW_TRACE_ARCHIVAL_CONFIG.name, raising=False)
monkeypatch.setattr(trace_archival_config_module, "_TRACE_ARCHIVAL_SERVER_CONFIG_CACHE", None)
if store_type == "file":
backend_uri = tmp_path.joinpath("file").as_uri()
elif store_type == "sqlalchemy":
backend_uri = db_uri
# Force-reset backend stores before each test.
handlers._tracking_store = None
handlers._model_registry_store = None
initialize_backend_stores(backend_uri, default_artifact_root=tmp_path.as_uri())
with ServerThread(app, get_safe_port()) as url:
yield MlflowClient(url)
@pytest.fixture
def mlflow_client_with_secrets(tmp_path: Path, monkeypatch):
"""Provides an MLflow Tracking API client with fresh database for secrets management.
Creates a fresh SQLite database for each test to avoid encryption state pollution.
This is necessary because the KEK encryption state can persist across tests when
using a shared cached database.
"""
# Set passphrase for secrets management (required for encryption)
monkeypatch.setenv(
"MLFLOW_CRYPTO_KEK_PASSPHRASE", "test-passphrase-at-least-32-characters-long"
)
# Create fresh database for this test (not using cached_db)
backend_uri = f"sqlite:///{tmp_path}/mlflow.db"
artifact_uri = (tmp_path / "artifacts").as_uri()
# Initialize the store (which creates tables)
store = SqlAlchemyStore(backend_uri, artifact_uri)
store.engine.dispose()
# Force-reset backend stores before each test
handlers._tracking_store = None
handlers._model_registry_store = None
initialize_backend_stores(backend_uri, default_artifact_root=artifact_uri)
with ServerThread(app, get_safe_port()) as url:
yield MlflowClient(url)
@pytest.fixture
def cli_env(mlflow_client):
"""Provides an environment for the MLflow CLI pointed at the local tracking server."""
return {
"LC_ALL": "en_US.UTF-8",
"LANG": "en_US.UTF-8",
"MLFLOW_TRACKING_URI": mlflow_client.tracking_uri,
}
def create_experiments(client, names):
return [client.create_experiment(n) for n in names]
def test_create_get_search_experiment(mlflow_client):
experiment_id = mlflow_client.create_experiment(
"My Experiment",
artifact_location="my_location",
tags={"key1": "val1", "key2": "val2"},
)
exp = mlflow_client.get_experiment(experiment_id)
assert exp.name == "My Experiment"
if is_windows():
assert exp.artifact_location == pathlib.Path.cwd().joinpath("my_location").as_uri()
else:
assert exp.artifact_location == str(pathlib.Path.cwd().joinpath("my_location"))
assert len(exp.tags) == 2
assert exp.tags["key1"] == "val1"
assert exp.tags["key2"] == "val2"
experiments = mlflow_client.search_experiments()
assert {e.name for e in experiments} == {"My Experiment", "Default"}
mlflow_client.delete_experiment(experiment_id)
assert {e.name for e in mlflow_client.search_experiments()} == {"Default"}
assert {e.name for e in mlflow_client.search_experiments(view_type=ViewType.ACTIVE_ONLY)} == {
"Default"
}
assert {e.name for e in mlflow_client.search_experiments(view_type=ViewType.DELETED_ONLY)} == {
"My Experiment"
}
assert {e.name for e in mlflow_client.search_experiments(view_type=ViewType.ALL)} == {
"My Experiment",
"Default",
}
active_exps_paginated = mlflow_client.search_experiments(max_results=1)
assert {e.name for e in active_exps_paginated} == {"Default"}
assert active_exps_paginated.token is None
all_exps_paginated = mlflow_client.search_experiments(max_results=1, view_type=ViewType.ALL)
first_page_names = {e.name for e in all_exps_paginated}
all_exps_second_page = mlflow_client.search_experiments(
max_results=1, view_type=ViewType.ALL, page_token=all_exps_paginated.token
)
second_page_names = {e.name for e in all_exps_second_page}
assert len(first_page_names) == 1
assert len(second_page_names) == 1
assert first_page_names.union(second_page_names) == {"Default", "My Experiment"}
def test_create_experiment_validation(mlflow_client):
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/experiments/create",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
assert_bad_request(
{
"name": 123,
},
"Invalid value 123 for parameter 'name'",
)
assert_bad_request({}, "Missing value for required parameter 'name'.")
assert_bad_request(
{
"name": "experiment name",
"artifact_location": 9.0,
"tags": [{"key": "key", "value": "value"}],
},
"Invalid value 9.0 for parameter 'artifact_location'",
)
assert_bad_request(
{
"name": "experiment name",
"artifact_location": "my_location",
"tags": "5",
},
"Invalid value \\\"5\\\" for parameter 'tags'",
)
def test_delete_restore_experiment(mlflow_client):
experiment_id = mlflow_client.create_experiment("Deleterious")
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "active"
mlflow_client.delete_experiment(experiment_id)
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "deleted"
mlflow_client.restore_experiment(experiment_id)
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "active"
def test_delete_restore_experiment_cli(mlflow_client, cli_env):
experiment_name = "DeleteriousCLI"
invoke_cli_runner(
mlflow.experiments.commands,
["create", "--experiment-name", experiment_name],
env=cli_env,
)
experiment_id = mlflow_client.get_experiment_by_name(experiment_name).experiment_id
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "active"
invoke_cli_runner(
mlflow.experiments.commands, ["delete", "-x", str(experiment_id)], env=cli_env
)
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "deleted"
invoke_cli_runner(
mlflow.experiments.commands, ["restore", "-x", str(experiment_id)], env=cli_env
)
assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "active"
def test_rename_experiment(mlflow_client):
experiment_id = mlflow_client.create_experiment("BadName")
assert mlflow_client.get_experiment(experiment_id).name == "BadName"
mlflow_client.rename_experiment(experiment_id, "GoodName")
assert mlflow_client.get_experiment(experiment_id).name == "GoodName"
def test_rename_experiment_cli(mlflow_client, cli_env):
bad_experiment_name = "CLIBadName"
good_experiment_name = "CLIGoodName"
invoke_cli_runner(
mlflow.experiments.commands, ["create", "-n", bad_experiment_name], env=cli_env
)
experiment_id = mlflow_client.get_experiment_by_name(bad_experiment_name).experiment_id
assert mlflow_client.get_experiment(experiment_id).name == bad_experiment_name
invoke_cli_runner(
mlflow.experiments.commands,
[
"rename",
"--experiment-id",
str(experiment_id),
"--new-name",
good_experiment_name,
],
env=cli_env,
)
assert mlflow_client.get_experiment(experiment_id).name == good_experiment_name
@pytest.mark.parametrize("parent_run_id_kwarg", [None, "my-parent-id"])
def test_create_run_all_args(mlflow_client, parent_run_id_kwarg):
user = "username"
source_name = "Hello"
entry_point = "entry"
source_version = "abc"
create_run_kwargs = {
"start_time": 456,
"run_name": "my name",
"tags": {
MLFLOW_USER: user,
MLFLOW_SOURCE_TYPE: "LOCAL",
MLFLOW_SOURCE_NAME: source_name,
MLFLOW_PROJECT_ENTRY_POINT: entry_point,
MLFLOW_GIT_COMMIT: source_version,
MLFLOW_PARENT_RUN_ID: "7",
"my": "tag",
"other": "tag",
},
}
experiment_id = mlflow_client.create_experiment(
f"Run A Lot (parent_run_id={parent_run_id_kwarg})"
)
created_run = mlflow_client.create_run(experiment_id, **create_run_kwargs)
run_id = created_run.info.run_id
_logger.info(f"Run id={run_id}")
fetched_run = mlflow_client.get_run(run_id)
for run in [created_run, fetched_run]:
assert run.info.run_id == run_id
assert run.info.experiment_id == experiment_id
assert run.info.user_id == user
assert run.info.start_time == create_run_kwargs["start_time"]
assert run.info.run_name == "my name"
for tag in create_run_kwargs["tags"]:
assert tag in run.data.tags
assert run.data.tags.get(MLFLOW_USER) == user
assert run.data.tags.get(MLFLOW_PARENT_RUN_ID) == parent_run_id_kwarg or "7"
assert [run.info for run in mlflow_client.search_runs([experiment_id])] == [run.info]
def test_create_run_defaults(mlflow_client):
experiment_id = mlflow_client.create_experiment("Run A Little")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
run = mlflow_client.get_run(run_id)
assert run.info.run_id == run_id
assert run.info.experiment_id == experiment_id
assert run.info.user_id == "unknown"
def test_log_metrics_params_tags(mlflow_client):
experiment_id = mlflow_client.create_experiment("Oh My")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.log_metric(run_id, key="metric", value=123.456, timestamp=789, step=2)
mlflow_client.log_metric(run_id, key="nan_metric", value=float("nan"))
mlflow_client.log_metric(run_id, key="inf_metric", value=float("inf"))
mlflow_client.log_metric(run_id, key="-inf_metric", value=-float("inf"))
mlflow_client.log_metric(run_id, key="stepless-metric", value=987.654, timestamp=321)
mlflow_client.log_param(run_id, "param", "value")
mlflow_client.set_tag(run_id, "taggity", "do-dah")
run = mlflow_client.get_run(run_id)
assert run.data.metrics.get("metric") == 123.456
assert math.isnan(run.data.metrics.get("nan_metric"))
assert run.data.metrics.get("inf_metric") >= 1.7976931348623157e308
assert run.data.metrics.get("-inf_metric") <= -1.7976931348623157e308
assert run.data.metrics.get("stepless-metric") == 987.654
assert run.data.params.get("param") == "value"
assert run.data.tags.get("taggity") == "do-dah"
metric_history0 = mlflow_client.get_metric_history(run_id, "metric")
assert len(metric_history0) == 1
metric0 = metric_history0[0]
assert metric0.key == "metric"
assert metric0.value == 123.456
assert metric0.timestamp == 789
assert metric0.step == 2
metric_history1 = mlflow_client.get_metric_history(run_id, "stepless-metric")
assert len(metric_history1) == 1
metric1 = metric_history1[0]
assert metric1.key == "stepless-metric"
assert metric1.value == 987.654
assert metric1.timestamp == 321
assert metric1.step == 0
metric_history = mlflow_client.get_metric_history(run_id, "a_test_accuracy")
assert metric_history == []
def test_log_metric_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("metrics validation")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/log-metric",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
assert_bad_request(
{
"run_id": 31,
"key": "metric",
"value": 41,
"timestamp": 59,
"step": 26,
},
"Invalid value 31 for parameter 'run_id' supplied",
)
assert_bad_request(
{
"run_id": run_id,
"key": 31,
"value": 41,
"timestamp": 59,
"step": 26,
},
"Invalid value 31 for parameter 'key' supplied",
)
assert_bad_request(
{
"run_id": run_id,
"key": "foo",
"value": 31,
"timestamp": 59,
"step": "foo",
},
"Invalid value \\\"foo\\\" for parameter 'step' supplied",
)
assert_bad_request(
{
"run_id": run_id,
"key": "foo",
"value": 31,
"timestamp": "foo",
"step": 41,
},
"Invalid value \\\"foo\\\" for parameter 'timestamp' supplied",
)
assert_bad_request(
{
"run_id": None,
"key": "foo",
"value": 31,
"timestamp": 59,
"step": 41,
},
"Missing value for required parameter 'run_id'",
)
assert_bad_request(
{
"run_id": run_id,
# Missing key
"value": 31,
"timestamp": 59,
"step": 41,
},
"Missing value for required parameter 'key'",
)
assert_bad_request(
{
"run_id": run_id,
"key": None,
"value": 31,
"timestamp": 59,
"step": 41,
},
"Missing value for required parameter 'key'",
)
def test_log_metric_model(mlflow_client: MlflowClient):
experiment_id = mlflow_client.create_experiment("metrics validation")
run = mlflow_client.create_run(experiment_id)
model = mlflow_client.create_logged_model(experiment_id)
mlflow_client.log_metric(
run.info.run_id,
key="metric",
value=0.5,
timestamp=123456789,
step=1,
dataset_name="name",
dataset_digest="digest",
model_id=model.model_id,
)
model = mlflow_client.get_logged_model(model.model_id)
assert model.metrics == [
Metric(
key="metric",
value=0.5,
timestamp=123456789,
step=1,
model_id=model.model_id,
dataset_name="name",
dataset_digest="digest",
run_id=run.info.run_id,
)
]
def test_log_param_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("params validation")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/log-parameter",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
assert_bad_request(
{
"run_id": 31,
"key": "param",
"value": 41,
},
"Invalid value 31 for parameter 'run_id' supplied",
)
assert_bad_request(
{
"run_id": run_id,
"key": 31,
"value": 41,
},
"Invalid value 31 for parameter 'key' supplied",
)
def test_log_param_with_empty_string_as_value(mlflow_client):
experiment_id = mlflow_client.create_experiment(
test_log_param_with_empty_string_as_value.__name__
)
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.log_param(run_id, "param_key", "")
assert {"param_key": ""}.items() <= mlflow_client.get_run(run_id).data.params.items()
def test_set_tag_with_empty_string_as_value(mlflow_client):
experiment_id = mlflow_client.create_experiment(
test_set_tag_with_empty_string_as_value.__name__
)
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.set_tag(run_id, "tag_key", "")
assert {"tag_key": ""}.items() <= mlflow_client.get_run(run_id).data.tags.items()
def test_log_batch_containing_params_and_tags_with_empty_string_values(mlflow_client):
experiment_id = mlflow_client.create_experiment(
test_log_batch_containing_params_and_tags_with_empty_string_values.__name__
)
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.log_batch(
run_id=run_id,
params=[Param("param_key", "")],
tags=[RunTag("tag_key", "")],
)
assert {"param_key": ""}.items() <= mlflow_client.get_run(run_id).data.params.items()
assert {"tag_key": ""}.items() <= mlflow_client.get_run(run_id).data.tags.items()
def test_set_tag_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("tags validation")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/set-tag",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
assert_bad_request(
{
"run_id": 31,
"key": "tag",
"value": 41,
},
"Invalid value 31 for parameter 'run_id' supplied",
)
assert_bad_request(
{
"run_id": run_id,
"key": "param",
"value": 41,
},
"Invalid value 41 for parameter 'value' supplied",
)
assert_bad_request(
{
"run_id": run_id,
# Missing key
"value": "value",
},
"Missing value for required parameter 'key'",
)
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/set-tag",
{
"run_uuid": run_id,
"key": "key",
"value": "value",
},
)
assert response.status_code == 200
def test_path_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("tags validation")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
invalid_path = "../path"
def assert_response(resp):
assert resp.status_code == 400
body = response.json()
assert body["error_code"] == "INVALID_PARAMETER_VALUE"
assert body["message"] == "Invalid path"
response = requests.get(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/artifacts/list",
params={"run_id": run_id, "path": invalid_path},
)
assert_response(response)
response = requests.get(
f"{mlflow_client.tracking_uri}/get-artifact",
params={"run_id": run_id, "path": invalid_path},
)
assert_response(response)
response = requests.get(
f"{mlflow_client.tracking_uri}//model-versions/get-artifact",
params={"name": "model", "version": 1, "path": invalid_path},
)
assert_response(response)
def test_set_experiment_tag(mlflow_client):
experiment_id = mlflow_client.create_experiment("SetExperimentTagTest")
mlflow_client.set_experiment_tag(experiment_id, "dataset", "imagenet1K")
experiment = mlflow_client.get_experiment(experiment_id)
assert "dataset" in experiment.tags
assert experiment.tags["dataset"] == "imagenet1K"
# test that updating a tag works
mlflow_client.set_experiment_tag(experiment_id, "dataset", "birdbike")
experiment = mlflow_client.get_experiment(experiment_id)
assert "dataset" in experiment.tags
assert experiment.tags["dataset"] == "birdbike"
# test that setting a tag on 1 experiment does not impact another experiment.
experiment_id_2 = mlflow_client.create_experiment("SetExperimentTagTest2")
experiment2 = mlflow_client.get_experiment(experiment_id_2)
assert len(experiment2.tags) == 0
# test that setting a tag on different experiments maintain different values across experiments
mlflow_client.set_experiment_tag(experiment_id_2, "dataset", "birds200")
experiment = mlflow_client.get_experiment(experiment_id)
experiment2 = mlflow_client.get_experiment(experiment_id_2)
assert "dataset" in experiment.tags
assert experiment.tags["dataset"] == "birdbike"
assert "dataset" in experiment2.tags
assert experiment2.tags["dataset"] == "birds200"
# test can set multi-line tags
mlflow_client.set_experiment_tag(experiment_id, "multiline tag", "value2\nvalue2\nvalue2")
experiment = mlflow_client.get_experiment(experiment_id)
assert "multiline tag" in experiment.tags
assert experiment.tags["multiline tag"] == "value2\nvalue2\nvalue2"
def test_set_experiment_tag_with_empty_string_as_value(mlflow_client):
experiment_id = mlflow_client.create_experiment(
test_set_experiment_tag_with_empty_string_as_value.__name__
)
mlflow_client.set_experiment_tag(experiment_id, "tag_key", "")
assert {"tag_key": ""}.items() <= mlflow_client.get_experiment(experiment_id).tags.items()
def test_delete_experiment_tag(mlflow_client):
experiment_id = mlflow_client.create_experiment("DeleteExperimentTagTest")
mlflow_client.set_experiment_tag(experiment_id, "dataset", "imagenet1K")
experiment = mlflow_client.get_experiment(experiment_id)
assert experiment.tags["dataset"] == "imagenet1K"
# test that deleting a tag works
mlflow_client.delete_experiment_tag(experiment_id, "dataset")
experiment = mlflow_client.get_experiment(experiment_id)
assert "dataset" not in experiment.tags
def test_delete_tag(mlflow_client):
experiment_id = mlflow_client.create_experiment("DeleteTagExperiment")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.log_metric(run_id, key="metric", value=123.456, timestamp=789, step=2)
mlflow_client.log_metric(run_id, key="stepless-metric", value=987.654, timestamp=321)
mlflow_client.log_param(run_id, "param", "value")
mlflow_client.set_tag(run_id, "taggity", "do-dah")
run = mlflow_client.get_run(run_id)
assert "taggity" in run.data.tags
assert run.data.tags["taggity"] == "do-dah"
mlflow_client.delete_tag(run_id, "taggity")
run = mlflow_client.get_run(run_id)
assert "taggity" not in run.data.tags
with pytest.raises(MlflowException, match=r"Run .+ not found"):
mlflow_client.delete_tag("fake_run_id", "taggity")
with pytest.raises(MlflowException, match="No tag with name: fakeTag"):
mlflow_client.delete_tag(run_id, "fakeTag")
mlflow_client.delete_run(run_id)
with pytest.raises(MlflowException, match=f"The run {run_id} must be in"):
mlflow_client.delete_tag(run_id, "taggity")
def test_log_batch(mlflow_client):
experiment_id = mlflow_client.create_experiment("Batch em up")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.log_batch(
run_id=run_id,
metrics=[Metric("metric", 123.456, 789, 3)],
params=[Param("param", "value")],
tags=[RunTag("taggity", "do-dah")],
)
run = mlflow_client.get_run(run_id)
assert run.data.metrics.get("metric") == 123.456
assert run.data.params.get("param") == "value"
assert run.data.tags.get("taggity") == "do-dah"
metric_history = mlflow_client.get_metric_history(run_id, "metric")
assert len(metric_history) == 1
metric = metric_history[0]
assert metric.key == "metric"
assert metric.value == 123.456
assert metric.timestamp == 789
assert metric.step == 3
def test_log_batch_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("log_batch validation")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/log-batch",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
for request_parameter in ["metrics", "params", "tags"]:
assert_bad_request(
{
"run_id": run_id,
request_parameter: "foo",
},
f"Invalid value \\\"foo\\\" for parameter '{request_parameter}' supplied",
)
## Should 400 if missing timestamp
assert_bad_request(
{"run_id": run_id, "metrics": [{"key": "mae", "value": 2.5}]},
"Missing value for required parameter 'metrics[0].timestamp'",
)
## Should 200 if timestamp provided but step is not
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/log-batch",
{
"run_id": run_id,
"metrics": [{"key": "mae", "value": 2.5, "timestamp": 123456789}],
},
)
assert response.status_code == 200
@pytest.mark.xfail(reason="Tracking server does not support logged-model endpoints yet")
@pytest.mark.allow_infer_pip_requirements_fallback
def test_log_model(mlflow_client):
experiment_id = mlflow_client.create_experiment("Log models")
with TempDir(chdr=True):
model_paths = [f"model/path/{i}" for i in range(3)]
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
with mlflow.start_run(experiment_id=experiment_id) as run:
for i, m in enumerate(model_paths):
mlflow.pyfunc.log_model(name=m, loader_module="mlflow.pyfunc")
mlflow.pyfunc.save_model(
m,
mlflow_model=Model(artifact_path=m, run_id=run.info.run_id),
loader_module="mlflow.pyfunc",
)
model = Model.load(os.path.join(m, "MLmodel"))
run = mlflow.get_run(run.info.run_id)
tag = run.data.tags["mlflow.log-model.history"]
models = json.loads(tag)
model.utc_time_created = models[i]["utc_time_created"]
history_model_meta = models[i].copy()
original_model_uuid = history_model_meta.pop("model_uuid")
model_meta = model.get_tags_dict().copy()
new_model_uuid = model_meta.pop("model_uuid")
assert history_model_meta == model_meta
assert original_model_uuid != new_model_uuid
assert len(models) == i + 1
for j in range(0, i + 1):
assert models[j]["artifact_path"] == model_paths[j]
def test_set_terminated_defaults(mlflow_client):
experiment_id = mlflow_client.create_experiment("Terminator 1")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
assert mlflow_client.get_run(run_id).info.status == "RUNNING"
assert mlflow_client.get_run(run_id).info.end_time is None
mlflow_client.set_terminated(run_id)
assert mlflow_client.get_run(run_id).info.status == "FINISHED"
assert mlflow_client.get_run(run_id).info.end_time <= get_current_time_millis()
def test_set_terminated_status(mlflow_client):
experiment_id = mlflow_client.create_experiment("Terminator 2")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
assert mlflow_client.get_run(run_id).info.status == "RUNNING"
assert mlflow_client.get_run(run_id).info.end_time is None
mlflow_client.set_terminated(run_id, "FAILED")
assert mlflow_client.get_run(run_id).info.status == "FAILED"
assert mlflow_client.get_run(run_id).info.end_time <= get_current_time_millis()
def test_artifacts(mlflow_client, tmp_path):
experiment_id = mlflow_client.create_experiment("Art In Fact")
experiment_info = mlflow_client.get_experiment(experiment_id)
assert experiment_info.artifact_location.startswith(path_to_local_file_uri(str(tmp_path)))
artifact_path = urllib.parse.urlparse(experiment_info.artifact_location).path
assert posixpath.split(artifact_path)[-1] == experiment_id
created_run = mlflow_client.create_run(experiment_id)
assert created_run.info.artifact_uri.startswith(experiment_info.artifact_location)
run_id = created_run.info.run_id
src_dir = tmp_path.joinpath("test_artifacts_src")
src_dir.mkdir()
src_file = os.path.join(src_dir, "my.file")
with open(src_file, "w") as f:
f.write("Hello, World!")
mlflow_client.log_artifact(run_id, src_file, None)
mlflow_client.log_artifacts(run_id, src_dir, "dir")
root_artifacts_list = mlflow_client.list_artifacts(run_id)
assert {a.path for a in root_artifacts_list} == {"my.file", "dir"}
dir_artifacts_list = mlflow_client.list_artifacts(run_id, "dir")
assert {a.path for a in dir_artifacts_list} == {"dir/my.file"}
all_artifacts = download_artifacts(
run_id=run_id, artifact_path=".", tracking_uri=mlflow_client.tracking_uri
)
with open(f"{all_artifacts}/my.file") as f:
assert f.read() == "Hello, World!"
with open(f"{all_artifacts}/dir/my.file") as f:
assert f.read() == "Hello, World!"
dir_artifacts = download_artifacts(
run_id=run_id, artifact_path="dir", tracking_uri=mlflow_client.tracking_uri
)
with open(f"{dir_artifacts}/my.file") as f:
assert f.read() == "Hello, World!"
def test_search_pagination(mlflow_client):
experiment_id = mlflow_client.create_experiment("search_pagination")
runs = [mlflow_client.create_run(experiment_id, start_time=1).info.run_id for _ in range(0, 10)]
runs = sorted(runs)
result = mlflow_client.search_runs([experiment_id], max_results=4, page_token=None)
assert [r.info.run_id for r in result] == runs[0:4]
assert result.token is not None
result = mlflow_client.search_runs([experiment_id], max_results=4, page_token=result.token)
assert [r.info.run_id for r in result] == runs[4:8]
assert result.token is not None
result = mlflow_client.search_runs([experiment_id], max_results=4, page_token=result.token)
assert [r.info.run_id for r in result] == runs[8:]
assert result.token is None
def test_search_validation(mlflow_client):
experiment_id = mlflow_client.create_experiment("search_validation")
with pytest.raises(
MlflowException,
match=r"Invalid value 123456789 for parameter 'max_results' supplied",
):
mlflow_client.search_runs([experiment_id], max_results=123456789)
def test_get_experiment_by_name(mlflow_client):
name = "test_get_experiment_by_name"
experiment_id = mlflow_client.create_experiment(name)
res = mlflow_client.get_experiment_by_name(name)
assert res.experiment_id == experiment_id
assert res.name == name
assert mlflow_client.get_experiment_by_name("idontexist") is None
def test_get_experiment(mlflow_client):
name = "test_get_experiment"
experiment_id = mlflow_client.create_experiment(name)
res = mlflow_client.get_experiment(experiment_id)
assert res.experiment_id == experiment_id
assert res.name == name
def test_search_experiments(mlflow_client):
# To ensure the default experiment and non-default experiments have different creation_time
# for deterministic search results, send a request to the server and initialize the tracking
# store.
assert mlflow_client.search_experiments()[0].name == "Default"
experiments = [
("a", {"key": "value"}),
("ab", {"key": "vaLue"}),
("Abc", None),
]
experiment_ids = []
for name, tags in experiments:
# sleep for windows file system current_time precision in Python to enforce
# deterministic ordering based on last_update_time (creation_time due to no
# mutation of experiment state)
time.sleep(0.001)
experiment_ids.append(mlflow_client.create_experiment(name, tags=tags))
# filter_string
experiments = mlflow_client.search_experiments(filter_string="attribute.name = 'a'")
assert [e.name for e in experiments] == ["a"]
experiments = mlflow_client.search_experiments(filter_string="attribute.name != 'a'")
assert [e.name for e in experiments] == ["Abc", "ab", "Default"]
experiments = mlflow_client.search_experiments(filter_string="name LIKE 'a%'")
assert [e.name for e in experiments] == ["ab", "a"]
experiments = mlflow_client.search_experiments(filter_string="tag.key = 'value'")
assert [e.name for e in experiments] == ["a"]
experiments = mlflow_client.search_experiments(filter_string="tag.key != 'value'")
assert [e.name for e in experiments] == ["ab"]
experiments = mlflow_client.search_experiments(filter_string="tag.key ILIKE '%alu%'")
assert [e.name for e in experiments] == ["ab", "a"]
# order_by
experiments = mlflow_client.search_experiments(order_by=["name DESC"])
assert [e.name for e in experiments] == ["ab", "a", "Default", "Abc"]
# max_results
experiments = mlflow_client.search_experiments(max_results=2)
assert [e.name for e in experiments] == ["Abc", "ab"]
# page_token
experiments = mlflow_client.search_experiments(page_token=experiments.token)
assert [e.name for e in experiments] == ["a", "Default"]
# view_type
time.sleep(0.001)
mlflow_client.delete_experiment(experiment_ids[1])
experiments = mlflow_client.search_experiments(view_type=ViewType.ACTIVE_ONLY)
assert [e.name for e in experiments] == ["Abc", "a", "Default"]
experiments = mlflow_client.search_experiments(view_type=ViewType.DELETED_ONLY)
assert [e.name for e in experiments] == ["ab"]
experiments = mlflow_client.search_experiments(view_type=ViewType.ALL)
assert [e.name for e in experiments] == ["Abc", "ab", "a", "Default"]
def test_get_metric_history_bulk_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
response_no_run_ids_field = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"metric_key": "key"},
)
assert_response(
response_no_run_ids_field,
"GetMetricHistoryBulk request must specify at least one run_id",
)
response_empty_run_ids = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [], "metric_key": "key"},
)
assert_response(
response_empty_run_ids,
"GetMetricHistoryBulk request must specify at least one run_id",
)
response_too_many_run_ids = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [f"id_{i}" for i in range(1000)], "metric_key": "key"},
)
assert_response(
response_too_many_run_ids,
"GetMetricHistoryBulk request cannot specify more than",
)
response_no_metric_key_field = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": ["123"]},
)
assert_response(
response_no_metric_key_field,
"GetMetricHistoryBulk request must specify a metric_key",
)
def test_get_metric_history_bulk_returns_expected_metrics_in_expected_order(
mlflow_client,
):
experiment_id = mlflow_client.create_experiment("get metric history bulk")
created_run1 = mlflow_client.create_run(experiment_id)
run_id1 = created_run1.info.run_id
created_run2 = mlflow_client.create_run(experiment_id)
run_id2 = created_run2.info.run_id
created_run3 = mlflow_client.create_run(experiment_id)
run_id3 = created_run3.info.run_id
metricA_history = [
{"key": "metricA", "timestamp": 1, "step": 2, "value": 10.0},
{"key": "metricA", "timestamp": 1, "step": 3, "value": 11.0},
{"key": "metricA", "timestamp": 1, "step": 3, "value": 12.0},
{"key": "metricA", "timestamp": 2, "step": 3, "value": 12.0},
]
for metric in metricA_history:
mlflow_client.log_metric(run_id1, **metric)
metric_for_run2 = dict(metric)
metric_for_run2["value"] += 1.0
mlflow_client.log_metric(run_id2, **metric_for_run2)
metricB_history = [
{"key": "metricB", "timestamp": 7, "step": -2, "value": -100.0},
{"key": "metricB", "timestamp": 8, "step": 0, "value": 0.0},
{"key": "metricB", "timestamp": 8, "step": 0, "value": 1.0},
{"key": "metricB", "timestamp": 9, "step": 1, "value": 12.0},
]
for metric in metricB_history:
mlflow_client.log_metric(run_id1, **metric)
metric_for_run2 = dict(metric)
metric_for_run2["value"] += 1.0
mlflow_client.log_metric(run_id2, **metric_for_run2)
response_run1_metricA = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [run_id1], "metric_key": "metricA"},
)
assert response_run1_metricA.status_code == 200
assert response_run1_metricA.json().get("metrics") == [
{**metric, "run_id": run_id1} for metric in metricA_history
]
response_run2_metricB = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [run_id2], "metric_key": "metricB"},
)
assert response_run2_metricB.status_code == 200
assert response_run2_metricB.json().get("metrics") == [
{**metric, "run_id": run_id2, "value": metric["value"] + 1.0} for metric in metricB_history
]
response_run1_run2_metricA = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [run_id1, run_id2], "metric_key": "metricA"},
)
assert response_run1_run2_metricA.status_code == 200
assert response_run1_run2_metricA.json().get("metrics") == sorted(
[{**metric, "run_id": run_id1} for metric in metricA_history]
+ [
{**metric, "run_id": run_id2, "value": metric["value"] + 1.0}
for metric in metricA_history
],
key=lambda metric: metric["run_id"],
)
response_run1_run2_run_3_metricB = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={"run_id": [run_id1, run_id2, run_id3], "metric_key": "metricB"},
)
assert response_run1_run2_run_3_metricB.status_code == 200
assert response_run1_run2_run_3_metricB.json().get("metrics") == sorted(
[{**metric, "run_id": run_id1} for metric in metricB_history]
+ [
{**metric, "run_id": run_id2, "value": metric["value"] + 1.0}
for metric in metricB_history
],
key=lambda metric: metric["run_id"],
)
def test_get_metric_history_bulk_respects_max_results(mlflow_client):
experiment_id = mlflow_client.create_experiment("get metric history bulk")
run_id = mlflow_client.create_run(experiment_id).info.run_id
max_results = 2
metricA_history = [
{"key": "metricA", "timestamp": 1, "step": 2, "value": 10.0},
{"key": "metricA", "timestamp": 1, "step": 3, "value": 11.0},
{"key": "metricA", "timestamp": 1, "step": 3, "value": 12.0},
{"key": "metricA", "timestamp": 2, "step": 3, "value": 12.0},
]
for metric in metricA_history:
mlflow_client.log_metric(run_id, **metric)
response_limited = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk",
params={
"run_id": [run_id],
"metric_key": "metricA",
"max_results": max_results,
},
)
assert response_limited.status_code == 200
assert response_limited.json().get("metrics") == [
{**metric, "run_id": run_id} for metric in metricA_history[:max_results]
]
def test_get_metric_history_bulk_calls_optimized_impl_when_expected(tmp_path):
from mlflow.server.handlers import get_metric_history_bulk_handler
path = path_to_local_file_uri(str(tmp_path.joinpath("sqlalchemy.db")))
uri = ("sqlite://" if sys.platform == "win32" else "sqlite:////") + path[len("file://") :]
mock_store = mock.Mock(wraps=SqlAlchemyStore(uri, str(tmp_path)))
flask_app = flask.Flask("test_flask_app")
class MockRequestArgs:
def __init__(self, args_dict):
self.args_dict = args_dict
def to_dict(
self,
flat,
):
return self.args_dict
def get(self, key, default=None):
return self.args_dict.get(key, default)
with (
mock.patch("mlflow.server.handlers._get_tracking_store", return_value=mock_store),
flask_app.test_request_context() as mock_context,
):
run_ids = [str(i) for i in range(10)]
mock_context.request.args = MockRequestArgs({
"run_id": run_ids,
"metric_key": "mock_key",
})
get_metric_history_bulk_handler()
mock_store.get_metric_history_bulk.assert_called_once_with(
run_ids=run_ids,
metric_key="mock_key",
max_results=25000,
)
def test_get_metric_history_respects_max_results(mlflow_client):
experiment_id = mlflow_client.create_experiment("test max_results")
run = mlflow_client.create_run(experiment_id)
run_id = run.info.run_id
metric_history = [
{"key": "test_metric", "value": float(i), "step": i, "timestamp": 1000 + i}
for i in range(5)
]
for metric in metric_history:
mlflow_client.log_metric(run_id, **metric)
# Test without max_results - should return all metrics
all_metrics = mlflow_client.get_metric_history(run_id, "test_metric")
assert len(all_metrics) == 5
# Test with max_results=3 - should return only 3 metrics
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={"run_id": run_id, "metric_key": "test_metric", "max_results": 3},
)
assert response.status_code == 200
response_data = response.json()
assert len(response_data["metrics"]) == 3
returned_metrics = response_data["metrics"]
for i, metric in enumerate(returned_metrics):
assert metric["key"] == "test_metric"
assert metric["value"] == float(i)
if _MLFLOW_GO_STORE_TESTING.get():
assert int(metric["step"]) == i
else:
assert metric["step"] == i
def test_get_metric_history_with_page_token(mlflow_client):
experiment_id = mlflow_client.create_experiment("test page_token")
run = mlflow_client.create_run(experiment_id)
run_id = run.info.run_id
metric_history = [
{"key": "test_metric", "value": float(i), "step": i, "timestamp": 1000 + i}
for i in range(10)
]
for metric in metric_history:
mlflow_client.log_metric(run_id, **metric)
page_size = 4
first_response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={
"run_id": run_id,
"metric_key": "test_metric",
"max_results": page_size,
},
)
assert first_response.status_code == 200
first_data = first_response.json()
first_metrics = first_data["metrics"]
first_token = first_data.get("next_page_token")
assert first_token is not None
assert len(first_metrics) == 4
second_response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={
"run_id": run_id,
"metric_key": "test_metric",
"max_results": page_size,
"page_token": first_token,
},
)
assert second_response.status_code == 200
second_data = second_response.json()
second_metrics = second_data["metrics"]
second_token = second_data.get("next_page_token")
assert second_token is not None
assert len(second_metrics) == 4
third_response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={
"run_id": run_id,
"metric_key": "test_metric",
"max_results": page_size,
"page_token": second_token,
},
)
assert third_response.status_code == 200
third_data = third_response.json()
third_metrics = third_data["metrics"]
third_token = third_data.get("next_page_token")
assert third_token is None
assert len(third_metrics) == 2
all_paginated_metrics = first_metrics + second_metrics + third_metrics
assert len(all_paginated_metrics) == 10
for i, metric in enumerate(all_paginated_metrics):
assert metric["key"] == "test_metric"
assert metric["value"] == float(i)
if _MLFLOW_GO_STORE_TESTING.get():
assert int(metric["step"]) == i
else:
assert metric["step"] == i
if _MLFLOW_GO_STORE_TESTING.get():
assert int(metric["timestamp"]) == 1000 + i
else:
assert metric["timestamp"] == 1000 + i
# Test with invalid page_token
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={
"run_id": run_id,
"metric_key": "test_metric",
"page_token": "invalid_token",
},
)
assert response.status_code == 400
response_data = response.json()
assert "INVALID_PARAMETER_VALUE" in response_data.get("error_code", "")
def test_get_metric_history_without_max_results_returns_full_history(mlflow_client):
# Regression test: an unset proto2 `max_results` reads as 0, which previously became
# a `LIMIT 1` query that returned an empty page with a never-advancing next_page_token
experiment_id = mlflow_client.create_experiment("test no max_results")
run = mlflow_client.create_run(experiment_id)
run_id = run.info.run_id
for i in range(10):
mlflow_client.log_metric(run_id, key="test_metric", value=float(i), step=i)
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={"run_id": run_id, "metric_key": "test_metric"},
)
assert response.status_code == 200
data = response.json()
assert len(data["metrics"]) == 10
assert data.get("next_page_token") is None
@pytest.mark.parametrize("max_results", [0, -5])
def test_get_metric_history_rejects_non_positive_max_results(mlflow_client, max_results):
experiment_id = mlflow_client.create_experiment(f"test max_results {max_results}")
run = mlflow_client.create_run(experiment_id)
run_id = run.info.run_id
mlflow_client.log_metric(run_id, key="test_metric", value=1.0, step=0)
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history",
params={"run_id": run_id, "metric_key": "test_metric", "max_results": max_results},
)
assert response.status_code == 400
assert "max_results" in response.text
def test_get_metric_history_bulk_interval_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
url = f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk-interval"
assert_response(
requests.get(url, params={"metric_key": "key"}),
"Missing value for required parameter 'run_ids'.",
)
assert_response(
requests.get(url, params={"run_ids": [], "metric_key": "key"}),
"Missing value for required parameter 'run_ids'.",
)
assert_response(
requests.get(
url,
params={"run_ids": [f"id_{i}" for i in range(1000)], "metric_key": "key"},
),
"GetMetricHistoryBulkInterval request must specify at most 100 run_ids.",
)
assert_response(
requests.get(url, params={"run_ids": ["123"], "metric_key": "key", "max_results": 0}),
"max_results must be between 1 and 2500",
)
assert_response(
requests.get(url, params={"run_ids": ["123"], "metric_key": ""}),
"Missing value for required parameter 'metric_key'",
)
assert_response(
requests.get(url, params={"run_ids": ["123"], "max_results": 5}),
"Missing value for required parameter 'metric_key'",
)
assert_response(
requests.get(
url,
params={
"run_ids": ["123"],
"metric_key": "key",
"start_step": 1,
"end_step": 0,
"max_results": 5,
},
),
"end_step must be greater than start_step. ",
)
assert_response(
requests.get(
url,
params={
"run_ids": ["123"],
"metric_key": "key",
"start_step": 1,
"max_results": 5,
},
),
"If either start step or end step are specified, both must be specified.",
)
def test_get_metric_history_bulk_interval_respects_max_results(mlflow_client):
experiment_id = mlflow_client.create_experiment("get metric history bulk")
run_id1 = mlflow_client.create_run(experiment_id).info.run_id
metric_history = [
{"key": "metricA", "timestamp": 1, "step": i, "value": 10.0} for i in range(10)
]
for metric in metric_history:
mlflow_client.log_metric(run_id1, **metric)
url = f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/metrics/get-history-bulk-interval"
response_limited = requests.get(
url,
params={"run_ids": [run_id1], "metric_key": "metricA", "max_results": 5},
)
assert response_limited.status_code == 200
expected_steps = [0, 2, 4, 6, 8, 9]
expected_metrics = [
{**metric, "run_id": run_id1}
for metric in metric_history
if metric["step"] in expected_steps
]
assert response_limited.json().get("metrics") == expected_metrics
# with start_step and end_step
response_limited = requests.get(
url,
params={
"run_ids": [run_id1],
"metric_key": "metricA",
"start_step": 0,
"end_step": 4,
"max_results": 5,
},
)
assert response_limited.status_code == 200
assert response_limited.json().get("metrics") == [
{**metric, "run_id": run_id1} for metric in metric_history[:5]
]
# multiple runs
run_id2 = mlflow_client.create_run(experiment_id).info.run_id
metric_history2 = [
{"key": "metricA", "timestamp": 1, "step": i, "value": 10.0} for i in range(20)
]
for metric in metric_history2:
mlflow_client.log_metric(run_id2, **metric)
response_limited = requests.get(
url,
params={
"run_ids": [run_id1, run_id2],
"metric_key": "metricA",
"max_results": 5,
},
)
expected_steps = [0, 4, 8, 9, 12, 16, 19]
expected_metrics = []
for run_id, metric_history in [
(run_id1, metric_history),
(run_id2, metric_history2),
]:
expected_metrics.extend([
{**metric, "run_id": run_id}
for metric in metric_history
if metric["step"] in expected_steps
])
assert response_limited.json().get("metrics") == expected_metrics
# test metrics with same steps
metric_history_timestamp2 = [
{"key": "metricA", "timestamp": 2, "step": i, "value": 10.0} for i in range(10)
]
for metric in metric_history_timestamp2:
mlflow_client.log_metric(run_id1, **metric)
response_limited = requests.get(
url,
params={"run_ids": [run_id1], "metric_key": "metricA", "max_results": 5},
)
assert response_limited.status_code == 200
expected_steps = [0, 2, 4, 6, 8, 9]
expected_metrics = [
{"key": "metricA", "timestamp": j, "step": i, "value": 10.0, "run_id": run_id1}
for i in expected_steps
for j in [1, 2]
]
assert response_limited.json().get("metrics") == expected_metrics
def test_search_dataset_handler_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
response_no_experiment_id_field = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/experiments/search-datasets",
json={},
)
assert_response(
response_no_experiment_id_field,
"SearchDatasets request must specify at least one experiment_id.",
)
response_empty_experiment_id_field = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/experiments/search-datasets",
json={"experiment_ids": []},
)
assert_response(
response_empty_experiment_id_field,
"SearchDatasets request must specify at least one experiment_id.",
)
response_too_many_experiment_ids = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/experiments/search-datasets",
json={"experiment_ids": [f"id_{i}" for i in range(1000)]},
)
assert_response(
response_too_many_experiment_ids,
"SearchDatasets request cannot specify more than",
)
def test_search_dataset_handler_returns_expected_results(mlflow_client):
experiment_id = mlflow_client.create_experiment("log inputs test")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
dataset1 = Dataset(
name="name1",
digest="digest1",
source_type="source_type1",
source="source1",
)
dataset_inputs1 = [
DatasetInput(
dataset=dataset1,
tags=[InputTag(key=MLFLOW_DATASET_CONTEXT, value="training")],
)
]
mlflow_client.log_inputs(run_id, dataset_inputs1)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/experiments/search-datasets",
json={"experiment_ids": [experiment_id]},
)
expected = {
"experiment_id": experiment_id,
"name": "name1",
"digest": "digest1",
"context": "training",
}
assert response.status_code == 200
assert response.json().get("dataset_summaries") == [expected]
def test_create_model_version_with_path_source(mlflow_client):
name = "model"
mlflow_client.create_registered_model(name)
exp_id = mlflow_client.create_experiment("test")
run = mlflow_client.create_run(experiment_id=exp_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": run.info.artifact_uri[len("file://") :],
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# run_id is not specified
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": run.info.artifact_uri[len("file://") :],
},
)
assert response.status_code == 400
assert "To use a local path as a model version" in response.json()["message"]
# run_id is specified but source is not in the run's artifact directory
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "/tmp",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "To use a local path as a model version" in response.json()["message"]
def test_create_model_version_with_non_local_source(mlflow_client):
name = "model"
mlflow_client.create_registered_model(name)
exp_id = mlflow_client.create_experiment("test")
run = mlflow_client.create_run(experiment_id=exp_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": run.info.artifact_uri[len("file://") :],
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# Test that remote uri's supplied as a source with absolute paths work fine
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts:/models",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# A single trailing slash
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts:/models/",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# Multiple trailing slashes
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts:/models///",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# Multiple slashes
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts:/models/foo///bar",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts://host:9000/models",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# Multiple dots
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts://host:9000/models/artifact/..../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# Test that invalid remote uri's cannot be created
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts://host:9000/models/../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "http://host:9000/models/../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "https://host/api/2.0/mlflow-artifacts/artifacts/../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "s3a://my_bucket/api/2.0/mlflow-artifacts/artifacts/../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "ftp://host:8888/api/2.0/mlflow-artifacts/artifacts/../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts://host:9000/models/..%2f..%2fartifacts",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "mlflow-artifacts://host:9000/models/artifact%00",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "If supplying a source as an http, https," in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": f"dbfs:/{run.info.run_id}/artifacts/a%3f/../../../../../../../../../../",
"run_id": run.info.run_id,
},
)
assert response.status_code == 400
assert "Invalid model version source" in response.json()["message"]
model = mlflow_client.create_logged_model(experiment_id=exp_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": model.artifact_location,
"model_id": model.model_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": model.model_uri,
"model_id": model.model_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "file:///path/to/model",
"model_id": model.model_id,
},
)
assert response.status_code == 400
def test_create_model_version_with_file_uri(mlflow_client):
name = "test"
mlflow_client.create_registered_model(name)
exp_id = mlflow_client.create_experiment("test")
run = mlflow_client.create_run(experiment_id=exp_id)
assert run.info.artifact_uri.startswith("file://")
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": run.info.artifact_uri,
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": f"{run.info.artifact_uri}/model",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": f"{run.info.artifact_uri}/.",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": f"{run.info.artifact_uri}/model/..",
"run_id": run.info.run_id,
},
)
assert response.status_code == 200
# run_id is not specified
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": run.info.artifact_uri,
},
)
assert response.status_code == 400
assert "To use a local path as a model version" in response.json()["message"]
# run_id is specified but source is not in the run's artifact directory
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "file:///tmp",
},
)
assert response.status_code == 400
assert "To use a local path as a model version" in response.json()["message"]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/2.0/mlflow/model-versions/create",
json={
"name": name,
"source": "file://123.456.789.123/path/to/source",
"run_id": run.info.run_id,
},
)
assert response.status_code == 500, response.json()
assert "is not a valid remote uri" in response.json()["message"]
def test_create_model_version_with_validation_regex(db_uri: str):
port = get_safe_port()
with subprocess.Popen(
[
sys.executable,
"-m",
"mlflow",
"server",
"--port",
str(port),
"--backend-store-uri",
db_uri,
],
env=(
os.environ.copy()
| {
"MLFLOW_CREATE_MODEL_VERSION_SOURCE_VALIDATION_REGEX": r"^mlflow-artifacts:/.*$",
"MLFLOW_SERVER_ENABLE_JOB_EXECUTION": "false",
}
),
) as proc:
try:
# Wait for the server to start
for _ in range(30):
try:
if requests.get(f"http://localhost:{port}/health").ok:
break
except requests.ConnectionError:
time.sleep(1)
else:
raise RuntimeError("Failed to connect to the MLflow server")
# Test that the validation regex works as expected
client = MlflowClient(f"http://localhost:{port}")
name = "test"
client.create_registered_model(name)
# Invalid source
with pytest.raises(MlflowException, match="Invalid model version source"):
client.create_model_version(name, source="s3://path/to/model")
# Valid source
experiment_id = client.create_experiment("test")
run = client.create_run(experiment_id=experiment_id)
assert run.info.artifact_uri.startswith("mlflow-artifacts:/")
client.create_model_version(
name, source=f"{run.info.artifact_uri}/model", run_id=run.info.run_id
)
finally:
proc.terminate()
proc.wait()
@pytest.mark.xfail(reason="Tracking server does not support logged-model endpoints yet")
def test_logging_model_with_local_artifact_uri(mlflow_client):
from sklearn.linear_model import LogisticRegression
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
with mlflow.start_run() as run:
assert run.info.artifact_uri.startswith("file://")
mlflow.sklearn.log_model(LogisticRegression(), name="model", registered_model_name="rmn")
mlflow.pyfunc.load_model("models:/rmn/1")
def test_log_input(mlflow_client, 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)
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
with mlflow.start_run() as run:
mlflow.log_input(dataset, "train", {"foo": "baz"})
dataset_inputs = mlflow_client.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"
def test_create_model_version_model_id(mlflow_client):
name = "model"
mlflow_client.create_registered_model(name)
exp_id = mlflow_client.create_experiment("test")
model = mlflow_client.create_logged_model(experiment_id=exp_id)
mlflow_client.create_model_version(
name=name,
source=model.artifact_location,
model_id=model.model_id,
)
model = mlflow_client.get_logged_model(model.model_id)
assert model.tags["mlflow.modelVersions"] == '[{"name": "model", "version": 1}]'
mlflow_client.create_model_version(
name=name,
source=model.artifact_location,
model_id=model.model_id,
)
model = mlflow_client.get_logged_model(model.model_id)
assert (
model.tags["mlflow.modelVersions"]
== '[{"name": "model", "version": 1}, {"name": "model", "version": 2}]'
)
def test_log_inputs(mlflow_client):
experiment_id = mlflow_client.create_experiment("log inputs test")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
dataset1 = Dataset(
name="name1",
digest="digest1",
source_type="source_type1",
source="source1",
)
dataset_inputs1 = [DatasetInput(dataset=dataset1, tags=[InputTag(key="tag1", value="value1")])]
mlflow_client.log_inputs(run_id, dataset_inputs1)
run = mlflow_client.get_run(run_id)
assert len(run.inputs.dataset_inputs) == 1
assert isinstance(run.inputs, RunInputs)
assert isinstance(run.inputs.dataset_inputs[0], DatasetInput)
assert isinstance(run.inputs.dataset_inputs[0].dataset, Dataset)
assert run.inputs.dataset_inputs[0].dataset.name == "name1"
assert run.inputs.dataset_inputs[0].dataset.digest == "digest1"
assert run.inputs.dataset_inputs[0].dataset.source_type == "source_type1"
assert run.inputs.dataset_inputs[0].dataset.source == "source1"
assert len(run.inputs.dataset_inputs[0].tags) == 1
assert run.inputs.dataset_inputs[0].tags[0].key == "tag1"
assert run.inputs.dataset_inputs[0].tags[0].value == "value1"
def test_log_inputs_validation(mlflow_client):
def assert_bad_request(payload, expected_error_message):
response = _send_rest_tracking_post_request(
mlflow_client.tracking_uri,
"/api/2.0/mlflow/runs/log-inputs",
payload,
)
assert response.status_code == 400
assert expected_error_message in response.text
dataset = Dataset(
name="name1",
digest="digest1",
source_type="source_type1",
source="source1",
)
tags = [InputTag(key="tag1", value="value1")]
dataset_inputs = [
json.loads(message_to_json(DatasetInput(dataset=dataset, tags=tags).to_proto()))
]
assert_bad_request(
{
"datasets": dataset_inputs,
},
"Missing value for required parameter 'run_id'",
)
def test_log_inputs_model(mlflow_client):
experiment_id = mlflow_client.create_experiment("log inputs test")
run = mlflow_client.create_run(experiment_id)
model = mlflow_client.create_logged_model(experiment_id=experiment_id)
dataset = Dataset(
name="name1",
digest="digest1",
source_type="source_type1",
source="source1",
)
dataset_inputs = [
DatasetInput(
dataset=dataset,
tags=[InputTag(key=MLFLOW_DATASET_CONTEXT, value="training")],
)
]
mlflow_client.log_inputs(
run.info.run_id,
models=[LoggedModelInput(model_id=model.model_id)],
datasets=dataset_inputs,
)
run = mlflow_client.get_run(run.info.run_id)
assert len(run.inputs.model_inputs) == 1
def test_update_run_name_without_changing_status(mlflow_client):
experiment_id = mlflow_client.create_experiment("update run name")
created_run = mlflow_client.create_run(experiment_id)
mlflow_client.set_terminated(created_run.info.run_id, "FINISHED")
mlflow_client.update_run(created_run.info.run_id, name="name_abc")
updated_run_info = mlflow_client.get_run(created_run.info.run_id).info
assert updated_run_info.run_name == "name_abc"
assert updated_run_info.status == "FINISHED"
def test_create_promptlab_run_handler_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={},
)
assert_response(
response,
"CreatePromptlabRun request must specify experiment_id.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={"experiment_id": "123"},
)
assert_response(
response,
"CreatePromptlabRun request must specify prompt_template.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={"experiment_id": "123", "prompt_template": "my_prompt_template"},
)
assert_response(
response,
"CreatePromptlabRun request must specify prompt_parameters.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={
"experiment_id": "123",
"prompt_template": "my_prompt_template",
"prompt_parameters": [{"key": "my_key", "value": "my_value"}],
},
)
assert_response(
response,
"CreatePromptlabRun request must specify model_route.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={
"experiment_id": "123",
"prompt_template": "my_prompt_template",
"prompt_parameters": [{"key": "my_key", "value": "my_value"}],
"model_route": "my_route",
},
)
assert_response(
response,
"CreatePromptlabRun request must specify model_input.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={
"experiment_id": "123",
"prompt_template": "my_prompt_template",
"prompt_parameters": [{"key": "my_key", "value": "my_value"}],
"model_route": "my_route",
"model_input": "my_input",
},
)
assert_response(
response,
"CreatePromptlabRun request must specify mlflow_version.",
)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={
"experiment_id": "123",
"prompt_template": "my_prompt_template",
"prompt_parameters": [{"key": "my_key", "value": "my_value"}],
"model_route": "my_route",
"model_input": "my_input",
"mlflow_version": "1.0.0",
},
)
def test_create_promptlab_run_handler_returns_expected_results(mlflow_client):
experiment_id = mlflow_client.create_experiment("log inputs test")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/runs/create-promptlab-run",
json={
"experiment_id": experiment_id,
"run_name": "my_run_name",
"prompt_template": "my_prompt_template",
"prompt_parameters": [{"key": "my_key", "value": "my_value"}],
"model_route": "my_route",
"model_parameters": [{"key": "temperature", "value": "0.1"}],
"model_input": "my_input",
"model_output": "my_output",
"model_output_parameters": [{"key": "latency", "value": "100"}],
"mlflow_version": "1.0.0",
"user_id": "username",
"start_time": 456,
},
)
assert response.status_code == 200
run_json = response.json()
assert run_json["run"]["info"]["run_name"] == "my_run_name"
assert run_json["run"]["info"]["experiment_id"] == experiment_id
assert run_json["run"]["info"]["user_id"] == "username"
assert run_json["run"]["info"]["status"] == "FINISHED"
assert run_json["run"]["info"]["start_time"] == 456
assert {"key": "model_route", "value": "my_route"} in run_json["run"]["data"]["params"]
assert {"key": "prompt_template", "value": "my_prompt_template"} in run_json["run"]["data"][
"params"
]
assert {"key": "temperature", "value": "0.1"} in run_json["run"]["data"]["params"]
assert {
"key": "mlflow.loggedArtifacts",
"value": '[{"path": "eval_results_table.json", "type": "table"}]',
} in run_json["run"]["data"]["tags"]
assert {"key": "mlflow.runSourceType", "value": "PROMPT_ENGINEERING"} in run_json["run"][
"data"
]["tags"]
def test_gateway_proxy_handler_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
with _init_server(
backend_uri=mlflow_client.tracking_uri,
root_artifact_uri=mlflow_client.tracking_uri,
extra_env={"MLFLOW_DEPLOYMENTS_TARGET": "http://localhost:5001"},
server_type="flask",
) as url:
patched_client = MlflowClient(url)
response = requests.post(
f"{patched_client.tracking_uri}/ajax-api/2.0/mlflow/gateway-proxy",
json={},
)
assert_response(
response,
"Deployments proxy request must specify a gateway_path.",
)
response = requests.post(
f"{patched_client.tracking_uri}/ajax-api/2.0/mlflow/gateway-proxy",
json={"gateway_path": "foo/bar"},
)
assert_response(
response,
"Invalid gateway_path: foo/bar for method: POST",
)
response = requests.post(
f"{patched_client.tracking_uri}/ajax-api/2.0/mlflow/gateway-proxy",
json={"gateway_path": "foo/bar/baz"},
)
assert_response(
response,
"Invalid gateway_path: foo/bar/baz for method: POST",
)
response = requests.get(
f"{patched_client.tracking_uri}/ajax-api/2.0/mlflow/gateway-proxy",
params={"gateway_path": "hello/world"},
)
assert_response(
response,
"Invalid gateway_path: hello/world for method: GET",
)
# Unsupported method
response = requests.delete(
f"{patched_client.tracking_uri}/ajax-api/2.0/mlflow/gateway-proxy",
)
assert response.status_code == 405
def test_upload_artifact_handler_rejects_invalid_requests(mlflow_client):
def assert_response(resp, message_part):
assert resp.status_code == 400
response_json = resp.json()
assert response_json.get("error_code") == "INVALID_PARAMETER_VALUE"
assert message_part in response_json.get("message", "")
experiment_id = mlflow_client.create_experiment("upload_artifacts_test")
created_run = mlflow_client.create_run(experiment_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact", params={}
)
assert_response(response, "Request must specify run_uuid.")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={
"run_uuid": created_run.info.run_id,
},
)
assert_response(response, "Request must specify path.")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={"run_uuid": created_run.info.run_id, "path": ""},
)
assert_response(response, "Request must specify path.")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={"run_uuid": created_run.info.run_id, "path": "../test.txt"},
)
assert_response(response, "Invalid path")
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={
"run_uuid": created_run.info.run_id,
"path": "test.txt",
},
)
assert_response(response, "Request must specify data.")
large_data = b"x" * (10 * 1024 * 1024 + 1)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={
"run_uuid": created_run.info.run_id,
"path": "test.txt",
},
data=large_data,
)
assert_response(response, "Artifact size is too large")
def test_upload_artifact_handler(mlflow_client):
experiment_id = mlflow_client.create_experiment("upload_artifacts_test")
created_run = mlflow_client.create_run(experiment_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/upload-artifact",
params={
"run_uuid": created_run.info.run_id,
"path": "test.txt",
},
data="hello world",
)
assert response.status_code == 200
response = requests.get(
f"{mlflow_client.tracking_uri}/get-artifact",
params={
"run_uuid": created_run.info.run_id,
"path": "test.txt",
},
)
assert response.status_code == 200
assert response.text == "hello world"
def test_graphql_handler(mlflow_client):
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": 'query testQuery {test(inputString: "abc") { output }}',
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
def test_graphql_handler_batching_raise_error(mlflow_client):
# Test max root fields limit
batch_query = (
"query testQuery {"
+ " ".join([
f"key_{i}: " + 'test(inputString: "abc") { output }'
for i in range(int(MLFLOW_SERVER_GRAPHQL_MAX_ROOT_FIELDS.get()) + 2)
])
+ "}"
)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": batch_query,
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
assert (
f"GraphQL queries should have at most {MLFLOW_SERVER_GRAPHQL_MAX_ROOT_FIELDS.get()}"
in response.json()["errors"][0]
)
# Test max aliases limit
batch_query = (
'query testQuery {mlflowGetExperiment(input: {experimentId: "123"}) {'
+ " ".join(
f"experiment_{i}: " + "experiment { name }"
for i in range(int(MLFLOW_SERVER_GRAPHQL_MAX_ALIASES.get()) + 2)
)
+ "}}"
)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": batch_query,
"operationName": "testQuery",
},
)
assert response.status_code == 200
assert (
f"queries should have at most {MLFLOW_SERVER_GRAPHQL_MAX_ALIASES.get()} aliases"
in response.json()["errors"][0]
)
# Test max depth limit
inner = "name"
for _ in range(12):
inner = f"name {{ {inner} }}"
deep_query = (
'query testQuery { mlflowGetExperiment(input: {experimentId: "123"}) { experiment { '
+ inner
+ " } } }"
)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": deep_query,
"operationName": "testQuery",
},
)
assert response.status_code == 200
assert "Query exceeds maximum depth of 10" in response.json()["errors"][0]
# Test max selections limit
# Exceed the 1000 selection limit
selections = [f"field_{i} {{ name }}" for i in range(1002)]
selections_query = (
'query testQuery { mlflowGetExperiment(input: {experimentId: "123"}) { experiment { '
+ " ".join(selections)
+ " } } }"
)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": selections_query,
"operationName": "testQuery",
},
)
assert response.status_code == 200
assert "Query exceeds maximum total selections of 1000" in response.json()["errors"][0]
def test_get_experiment_graphql(mlflow_client):
experiment_id = mlflow_client.create_experiment("GraphqlTest")
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": 'query testQuery {mlflowGetExperiment(input: {experimentId: "'
+ experiment_id
+ '"}) { experiment { name effectiveTraceArchivalRetention } }}',
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
assert json["data"]["mlflowGetExperiment"]["experiment"]["name"] == "GraphqlTest"
assert json["data"]["mlflowGetExperiment"]["experiment"]["effectiveTraceArchivalRetention"] in (
None,
"",
)
def test_get_run_and_experiment_graphql(mlflow_client):
name = "GraphqlTest"
mlflow_client.create_registered_model(name)
experiment_id = mlflow_client.create_experiment(name)
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
mlflow_client.create_model_version("GraphqlTest", "runs:/graphql_test/model", run_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
query testQuery @component(name: "Test") {{
mlflowGetRun(input: {{runId: "{run_id}"}}) {{
run {{
info {{
status
}}
experiment {{
name
}}
modelVersions {{
name
}}
}}
}}
}}
""",
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
assert json["errors"] is None
assert json["data"]["mlflowGetRun"]["run"]["info"]["status"] == created_run.info.status
assert json["data"]["mlflowGetRun"]["run"]["experiment"]["name"] == name
assert json["data"]["mlflowGetRun"]["run"]["modelVersions"][0]["name"] == name
def test_legacy_start_and_end_trace_v2(mlflow_client):
experiment_id = mlflow_client.create_experiment("start end trace")
# Trace CRUD APIs are not directly exposed as public API of MlflowClient,
# so we use the underlying tracking client to test them.
store = mlflow_client._tracing_client.store
# Helper function to remove auto-added system tags (mlflow.xxx) from testing
def _exclude_system_tags(tags: dict[str, str]):
return {k: v for k, v in tags.items() if not k.startswith("mlflow.")}
trace_info = store.deprecated_start_trace_v2(
experiment_id=experiment_id,
timestamp_ms=1000,
request_metadata={
"meta1": "apple",
"meta2": "grape",
},
tags={
"tag1": "football",
"tag2": "basketball",
},
)
assert trace_info.request_id is not None
assert trace_info.experiment_id == experiment_id
assert trace_info.timestamp_ms == 1000
assert trace_info.execution_time_ms == 0
assert trace_info.status == TraceStatus.IN_PROGRESS
assert trace_info.request_metadata == {
"meta1": "apple",
"meta2": "grape",
}
assert _exclude_system_tags(trace_info.tags) == {
"tag1": "football",
"tag2": "basketball",
}
trace_info = store.deprecated_end_trace_v2(
request_id=trace_info.request_id,
timestamp_ms=3000,
status=TraceStatus.OK,
request_metadata={
"meta1": "orange",
"meta3": "banana",
},
tags={
"tag1": "soccer",
"tag3": "tennis",
},
)
assert trace_info.request_id is not None
assert trace_info.experiment_id == experiment_id
assert trace_info.timestamp_ms == 1000
assert trace_info.execution_time_ms == 2000
assert trace_info.status == TraceStatus.OK
assert trace_info.request_metadata == {
"meta1": "orange",
"meta2": "grape",
"meta3": "banana",
}
assert _exclude_system_tags(trace_info.tags) == {
"tag1": "soccer",
"tag2": "basketball",
"tag3": "tennis",
}
def test_start_trace(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow.set_experiment("start end trace").experiment_id
# Helper function to remove auto-added system tags (mlflow.xxx) from testing
def _exclude_system_keys(d: dict[str, str]):
return {k: v for k, v in d.items() if not k.startswith("mlflow.")}
with mock.patch("mlflow.tracing.export.mlflow_v3._logger.warning") as mock_warning:
with mlflow.start_span(name="test") as span:
mlflow.update_current_trace(
tags={
"tag1": "football",
"tag2": "basketball",
},
metadata={
"meta1": "apple",
"meta2": "grape",
},
)
trace = mlflow_client.get_trace(span.trace_id, flush=True)
assert trace.info.trace_id == span.trace_id
assert trace.info.experiment_id == experiment_id
assert trace.info.request_time > 0
assert trace.info.execution_duration is not None
assert trace.info.state == TraceState.OK
assert _exclude_system_keys(trace.info.trace_metadata) == {
"meta1": "apple",
"meta2": "grape",
}
assert trace.info.trace_metadata[TRACE_SCHEMA_VERSION_KEY] == "3"
assert _exclude_system_keys(trace.info.tags) == {
"tag1": "football",
"tag2": "basketball",
}
# No "Failed to log span to MLflow backend" warning should be issued
for call in mock_warning.call_args_list:
assert "Failed to log span to MLflow backend" not in str(call)
def test_get_trace(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("get trace")
span = mlflow_client.start_trace(name="test", experiment_id=experiment_id)
mlflow_client.end_trace(request_id=span.request_id, status=TraceStatus.OK)
trace = mlflow_client.get_trace(span.request_id, flush=True)
assert trace is not None
assert trace.info.request_id == span.request_id
assert trace.info.experiment_id == experiment_id
assert trace.info.state == TraceState.OK
assert len(trace.data.spans) == 1
assert trace.data.spans[0].name == "test"
assert trace.data.spans[0].status.status_code == SpanStatusCode.OK
assert trace.data.spans[0].status.description == ""
def test_search_traces(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("search traces")
# Create test traces
def _create_trace(name, status):
span = mlflow_client.start_trace(name=name, experiment_id=experiment_id)
mlflow_client.end_trace(request_id=span.request_id, status=status)
return span.request_id
# Flush between creations to ensure distinct timestamps. Without this, all three traces
# can land in the same millisecond on a fast local server, making max_results ordering
# non-deterministic.
request_id_1 = _create_trace(name="trace1", status=TraceStatus.OK)
mlflow.flush_trace_async_logging()
request_id_2 = _create_trace(name="trace2", status=TraceStatus.OK)
mlflow.flush_trace_async_logging()
request_id_3 = _create_trace(name="trace3", status=TraceStatus.ERROR)
mlflow.flush_trace_async_logging()
def _get_request_ids(traces):
return [t.info.request_id for t in traces]
# Validate search
traces = mlflow_client.search_traces(locations=[experiment_id])
assert set(_get_request_ids(traces)) == {request_id_3, request_id_2, request_id_1}
assert traces.token is None
traces = mlflow_client.search_traces(
locations=[experiment_id],
filter_string="status = 'OK'",
order_by=["timestamp ASC"],
)
assert set(_get_request_ids(traces)) == {request_id_1, request_id_2}
assert traces.token is None
traces = mlflow_client.search_traces(
locations=[experiment_id],
max_results=2,
)
assert set(_get_request_ids(traces)) == {request_id_3, request_id_2}
assert traces.token is not None
traces = mlflow_client.search_traces(
locations=[experiment_id],
page_token=traces.token,
)
assert _get_request_ids(traces) == [request_id_1]
assert traces.token is None
def test_search_traces_parameter_validation(mlflow_client):
with pytest.raises(
MlflowException,
match="Locations must be a list of experiment IDs",
):
mlflow_client.search_traces(locations=["catalog.schema"])
def test_search_traces_match_text(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support full text search")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("search traces full text")
# Create test traces
def _create_trace(name, attributes):
span = mlflow_client.start_trace(name=name, experiment_id=experiment_id)
span.set_attributes(attributes)
mlflow_client.end_trace(request_id=span.trace_id, status=TraceStatus.OK)
return span.trace_id
trace_id_1 = _create_trace(name="trace1", attributes={"test": "value1"})
trace_id_2 = _create_trace(name="trace2", attributes={"test": "value2"})
trace_id_3 = _create_trace(name="trace3", attributes={"test3": "I like it"})
traces = mlflow_client.search_traces(locations=[experiment_id], flush=True)
assert len([t.info.trace_id for t in traces]) == 3
assert traces.token is None
traces = mlflow_client.search_traces(
locations=[experiment_id], filter_string="trace.text LIKE '%trace%'"
)
assert len([t.info.trace_id for t in traces]) == 3
assert traces.token is None
traces = mlflow_client.search_traces(
locations=[experiment_id], filter_string="trace.text LIKE '%value%'"
)
assert {t.info.trace_id for t in traces} == {trace_id_1, trace_id_2}
traces = mlflow_client.search_traces(
locations=[experiment_id], filter_string="trace.text LIKE '%I like it%'"
)
assert [t.info.trace_id for t in traces] == [trace_id_3]
def test_delete_traces(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("delete traces")
def _create_trace(name, status):
span = mlflow_client.start_trace(name=name, experiment_id=experiment_id)
mlflow_client.end_trace(request_id=span.request_id, status=status)
return span.request_id
def _is_trace_exists(request_id):
try:
trace_info = mlflow_client._tracing_client.get_trace_info(request_id)
return trace_info is not None
except RestException as e:
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
return False
raise
# Case 1: Delete all traces under experiment ID
request_id_1 = _create_trace(name="trace1", status=TraceStatus.OK)
request_id_2 = _create_trace(name="trace2", status=TraceStatus.OK)
mlflow.flush_trace_async_logging()
assert _is_trace_exists(request_id_1)
assert _is_trace_exists(request_id_2)
deleted_count = mlflow_client.delete_traces(experiment_id, max_timestamp_millis=int(1e15))
assert deleted_count == 2
assert not _is_trace_exists(request_id_1)
assert not _is_trace_exists(request_id_2)
# Case 2: Delete with max_traces limit
request_id_1 = _create_trace(name="trace1", status=TraceStatus.OK)
time.sleep(0.1) # Add some time gap to avoid timestamp collision
request_id_2 = _create_trace(name="trace2", status=TraceStatus.OK)
mlflow.flush_trace_async_logging()
deleted_count = mlflow_client.delete_traces(
experiment_id, max_traces=1, max_timestamp_millis=int(1e15)
)
assert deleted_count == 1
# TODO: Currently the deletion order in the file store is random (based on
# the order of the trace files in the directory), so we don't validate which
# one is deleted. Uncomment the following lines once the deletion order is fixed.
# assert not _is_trace_exists(request_id_1) # Old created trace should be deleted
# assert _is_trace_exists(request_id_2)
# Case 3: Delete with explicit request ID
request_id_1 = _create_trace(name="trace1", status=TraceStatus.OK)
request_id_2 = _create_trace(name="trace2", status=TraceStatus.OK)
mlflow.flush_trace_async_logging()
deleted_count = mlflow_client.delete_traces(experiment_id, trace_ids=[request_id_1])
assert deleted_count == 1
assert not _is_trace_exists(request_id_1)
assert _is_trace_exists(request_id_2)
def test_calculate_trace_filter_correlation(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support calculate_trace_filter_correlation")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("correlation test")
def _create_trace(name, tags):
span = mlflow_client.start_trace(name=name, experiment_id=experiment_id, tags=tags)
mlflow_client.end_trace(request_id=span.request_id, status=TraceStatus.OK)
return span.request_id
for i in range(6):
_create_trace(f"trace-prod-tool-{i}", {"env": "prod", "span_type": "TOOL"})
for i in range(4):
_create_trace(f"trace-dev-{i}", {"env": "dev", "span_type": "LLM" if i >= 1 else "TOOL"})
client = TracingClient(tracking_uri=mlflow_client.tracking_uri)
mlflow.flush_trace_async_logging()
result = client.calculate_trace_filter_correlation(
experiment_ids=[experiment_id],
filter_string1="tags.env = 'prod'",
filter_string2="tags.span_type = 'TOOL'",
)
assert isinstance(result, TraceFilterCorrelationResult)
assert result.total_count == 10
assert result.filter1_count == 6
assert result.filter2_count == 7
assert result.joint_count == 6
assert 0.6 < result.npmi < 0.8
assert result.npmi_smoothed is not None
result2 = client.calculate_trace_filter_correlation(
experiment_ids=[experiment_id],
filter_string1="tags.env = 'dev'",
filter_string2="tags.span_type = 'LLM'",
)
assert result2.total_count == 10
assert result2.filter1_count == 4
assert result2.filter2_count == 3
assert result2.joint_count == 3
assert result2.npmi > 0.5
result3 = client.calculate_trace_filter_correlation(
experiment_ids=[experiment_id],
filter_string1="tags.env = 'staging'",
filter_string2="tags.span_type = 'TOOL'",
)
assert result3.total_count == 10
assert result3.filter1_count == 0
assert result3.filter2_count == 7
assert result3.joint_count == 0
assert math.isnan(result3.npmi)
with pytest.raises(MlflowException, match="Invalid"):
client.calculate_trace_filter_correlation(
experiment_ids=[experiment_id],
filter_string1="invalid.filter = 'test'",
filter_string2="tags.span_type = 'TOOL'",
)
def test_set_and_delete_trace_tag(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("set delete tag")
# Create test trace
trace_info = mlflow_client._tracing_client.start_trace(
TraceInfo(
trace_id="tr-1234",
trace_location=TraceLocation.from_experiment_id(experiment_id),
request_time=1000,
execution_duration=2000,
state=TraceState.OK,
tags={
"tag1": "red",
"tag2": "blue",
},
)
)
# Validate set tag
mlflow_client.set_trace_tag(trace_info.request_id, "tag1", "green")
trace_info = mlflow_client._tracing_client.get_trace_info(trace_info.request_id)
assert trace_info.tags["tag1"] == "green"
# Validate delete tag
mlflow_client.delete_trace_tag(trace_info.request_id, "tag2")
trace_info = mlflow_client._tracing_client.get_trace_info(trace_info.request_id)
assert "tag2" not in trace_info.tags
def test_query_trace_metrics(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support query trace metrics")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("query trace metrics")
# Create test traces
def _create_trace(name, status):
span = mlflow_client.start_trace(name=name, experiment_id=experiment_id)
mlflow_client.end_trace(request_id=span.request_id, status=status)
return span.request_id
_create_trace(name="trace1", status=TraceStatus.OK)
_create_trace(name="trace2", status=TraceStatus.OK)
_create_trace(name="trace3", status=TraceStatus.ERROR)
mlflow.flush_trace_async_logging()
metrics = mlflow_client._tracing_client.store.query_trace_metrics(
experiment_ids=[experiment_id],
view_type=MetricViewType.TRACES,
metric_name=TraceMetricKey.TRACE_COUNT,
aggregations=[MetricAggregation(aggregation_type=AggregationType.COUNT)],
dimensions=[TraceMetricDimensionKey.TRACE_STATUS],
)
assert len(metrics) == 2
assert asdict(metrics[0]) == {
"metric_name": TraceMetricKey.TRACE_COUNT,
"dimensions": {TraceMetricDimensionKey.TRACE_STATUS: "ERROR"},
"values": {"COUNT": 1},
}
assert asdict(metrics[1]) == {
"metric_name": TraceMetricKey.TRACE_COUNT,
"dimensions": {TraceMetricDimensionKey.TRACE_STATUS: "OK"},
"values": {"COUNT": 2},
}
@pytest.mark.parametrize("allow_partial", [True, False])
def test_get_trace_handler(mlflow_client, allow_partial: bool, store_type):
if store_type == "file":
pytest.skip("File store doesn't support get trace handler")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
with mlflow.start_span(name="test") as span:
span.set_attributes({"fruit": "apple"})
mlflow.flush_trace_async_logging()
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/3.0/mlflow/traces/get",
params={"trace_id": span.trace_id, "allow_partial": allow_partial},
)
assert response.status_code == 200
trace = response.json()["trace"]
assert trace["trace_info"]["trace_id"] == span.trace_id
assert len(trace["spans"]) == 1
assert trace["spans"][0]["name"] == "test"
attributes = trace["spans"][0]["attributes"]
assert {"key": "fruit", "value": {"string_value": "apple"}} in attributes
def test_get_trace_artifact_handler(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
with mlflow.start_span(name="test") as span:
span.set_attributes({"fruit": "apple"})
span.add_event(SpanEvent("test_event", timestamp=99999, attributes={"foo": "bar"}))
mlflow.flush_trace_async_logging()
response = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/get-trace-artifact",
params={"request_id": span.trace_id},
)
assert response.status_code == 200
assert response.headers["Content-Disposition"] == "attachment; filename=traces.json"
# Validate content
trace_data = TraceData.from_dict(json.loads(response.text))
assert trace_data.spans[0].to_dict() == span.to_dict()
def test_link_traces_to_run_and_search_traces(mlflow_client, store_type):
# Skip file store because it doesn't support linking traces to runs
if store_type == "file":
pytest.skip("File store doesn't support linking traces to runs")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow.set_experiment("link traces to run test").experiment_id
run = mlflow_client.create_run(experiment_id)
run_id = run.info.run_id
# 1. Trace created under a run
with mlflow.start_run(run_id=run_id):
with mlflow.start_span(name="trace1") as span1:
span1.set_attributes({"test": "value1"})
trace_id_1 = span1.trace_id
# 2. Trace associated with a run
with mlflow.start_span(name="trace2") as span2:
span2.set_attributes({"test": "value2"})
trace_id_2 = span2.trace_id
mlflow_client.link_traces_to_run(trace_ids=[trace_id_2], run_id=run_id)
# 3. Trace not associated with a run
with mlflow.start_span(name="trace3") as span3:
span3.set_attributes({"test": "value3"})
trace_id_3 = span3.trace_id
# Search traces without run_id filter - should return all traces in experiment
all_traces = mlflow_client.search_traces(locations=[experiment_id], flush=True)
assert {t.info.trace_id for t in all_traces} == {trace_id_1, trace_id_2, trace_id_3}
# Search traces with run_id filter - should return only linked traces
linked_traces = mlflow_client.search_traces(
locations=[experiment_id], filter_string=f"attribute.run_id = '{run_id}'"
)
linked_trace_ids = [t.info.trace_id for t in linked_traces]
assert len(linked_trace_ids) == 2
assert set(linked_trace_ids) == {trace_id_1, trace_id_2}
def test_get_metric_history_bulk_interval_graphql(mlflow_client):
name = "GraphqlTest"
mlflow_client.create_registered_model(name)
experiment_id = mlflow_client.create_experiment(name)
created_run = mlflow_client.create_run(experiment_id)
metric_name = "metric_0"
for i in range(10):
mlflow_client.log_metric(created_run.info.run_id, metric_name, i, step=i)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
query testQuery {{
mlflowGetMetricHistoryBulkInterval(input: {{
runIds: ["{created_run.info.run_id}"],
metricKey: "{metric_name}",
}}) {{
metrics {{
key
timestamp
value
}}
}}
}}
""",
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
expected = [{"key": metric_name, "timestamp": mock.ANY, "value": i} for i in range(10)]
assert json["data"]["mlflowGetMetricHistoryBulkInterval"]["metrics"] == expected
def test_search_runs_graphql(mlflow_client):
name = "GraphqlTest"
mlflow_client.create_registered_model(name)
experiment_id = mlflow_client.create_experiment(name)
created_run_1 = mlflow_client.create_run(experiment_id)
created_run_2 = mlflow_client.create_run(experiment_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
mutation testMutation {{
mlflowSearchRuns(input: {{ experimentIds: ["{experiment_id}"] }}) {{
runs {{
info {{
runId
}}
}}
}}
}}
""",
"operationName": "testMutation",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
expected = [
{"info": {"runId": created_run_2.info.run_id}},
{"info": {"runId": created_run_1.info.run_id}},
]
assert json["data"]["mlflowSearchRuns"]["runs"] == expected
def test_list_artifacts_graphql(mlflow_client, tmp_path):
name = "GraphqlTest"
experiment_id = mlflow_client.create_experiment(name)
created_run_id = mlflow_client.create_run(experiment_id).info.run_id
file_path = tmp_path / "test.txt"
file_path.write_text("hello world")
mlflow_client.log_artifact(created_run_id, file_path.absolute().as_posix())
mlflow_client.log_artifact(created_run_id, file_path.absolute().as_posix(), "testDir")
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
query testQuery {{
files: mlflowListArtifacts(input: {{
runId: "{created_run_id}",
}}) {{
files {{
path
isDir
fileSize
}}
}}
}}
""",
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
file_expected = [
{"path": "test.txt", "isDir": False, "fileSize": "11"},
{"path": "testDir", "isDir": True, "fileSize": "0"},
]
assert json["data"]["files"]["files"] == file_expected
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
query testQuery {{
subdir: mlflowListArtifacts(input: {{
runId: "{created_run_id}",
path: "testDir",
}}) {{
files {{
path
isDir
fileSize
}}
}}
}}
""",
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
subdir_expected = [
{"path": "testDir/test.txt", "isDir": False, "fileSize": "11"},
]
assert json["data"]["subdir"]["files"] == subdir_expected
def test_search_datasets_graphql(mlflow_client):
name = "GraphqlTest"
experiment_id = mlflow_client.create_experiment(name)
created_run_id = mlflow_client.create_run(experiment_id).info.run_id
dataset1 = Dataset(
name="test-dataset-1",
digest="12345",
source_type="script",
source="test",
)
dataset_input1 = DatasetInput(dataset=dataset1, tags=[])
dataset2 = Dataset(
name="test-dataset-2",
digest="12346",
source_type="script",
source="test",
)
dataset_input2 = DatasetInput(
dataset=dataset2, tags=[InputTag(key=MLFLOW_DATASET_CONTEXT, value="training")]
)
mlflow_client.log_inputs(created_run_id, [dataset_input1, dataset_input2])
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
mutation testMutation {{
mlflowSearchDatasets(input:{{experimentIds: ["{experiment_id}"]}}) {{
datasetSummaries {{
experimentId
name
digest
context
}}
}}
}}
""",
"operationName": "testMutation",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json = response.json()
def sort_dataset_summaries(l1):
return sorted(l1, key=lambda x: x["digest"])
expected = sort_dataset_summaries([
{
"experimentId": experiment_id,
"name": "test-dataset-2",
"digest": "12346",
"context": "training",
},
{
"experimentId": experiment_id,
"name": "test-dataset-1",
"digest": "12345",
"context": "",
},
])
assert (
sort_dataset_summaries(json["data"]["mlflowSearchDatasets"]["datasetSummaries"]) == expected
)
def test_create_logged_model(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model = mlflow_client.create_logged_model(exp_id)
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.model_id == loaded_model.model_id
model = mlflow_client.create_logged_model(exp_id, name="my_model")
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.name == "my_model"
model = mlflow_client.create_logged_model(exp_id, model_type="LLM")
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.model_type == "LLM"
model = mlflow_client.create_logged_model(exp_id, source_run_id="123")
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.source_run_id == "123"
model = mlflow_client.create_logged_model(exp_id, params={"param": "value"})
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.params == {"param": "value"}
model = mlflow_client.create_logged_model(exp_id, tags={"tag": "value"})
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert model.tags == {"tag": "value"}
def test_log_logged_model_params(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model = mlflow_client.create_logged_model(exp_id)
mlflow_client.log_model_params(model.model_id, {"param": "value"})
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert loaded_model.params == {"param": "value"}
def test_finalize_logged_model(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model = mlflow_client.create_logged_model(exp_id)
finalized_model = mlflow_client.finalize_logged_model(model.model_id, LoggedModelStatus.READY)
assert finalized_model.status == LoggedModelStatus.READY
finalized_model = mlflow_client.finalize_logged_model(model.model_id, LoggedModelStatus.FAILED)
assert finalized_model.status == LoggedModelStatus.FAILED
def test_delete_logged_model(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("delete_logged_model")
model = mlflow_client.create_logged_model(experiment_id=exp_id)
mlflow_client.delete_logged_model(model.model_id)
with pytest.raises(MlflowException, match="not found"):
mlflow_client.get_logged_model(model.model_id)
models = mlflow_client.search_logged_models(experiment_ids=[exp_id])
assert len(models) == 0
def test_set_logged_model_tags(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model = mlflow_client.create_logged_model(exp_id)
mlflow_client.set_logged_model_tags(model.model_id, {"tag1": "value1", "tag2": "value2"})
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert loaded_model.tags == {"tag1": "value1", "tag2": "value2"}
mlflow_client.set_logged_model_tags(model.model_id, {"tag1": "value3"})
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert loaded_model.tags == {"tag1": "value3", "tag2": "value2"}
def test_delete_logged_model_tag(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model = mlflow_client.create_logged_model(exp_id)
mlflow_client.set_logged_model_tags(model.model_id, {"tag1": "value1", "tag2": "value2"})
mlflow_client.delete_logged_model_tag(model.model_id, "tag1")
loaded_model = mlflow_client.get_logged_model(model.model_id)
assert loaded_model.tags == {"tag2": "value2"}
with pytest.raises(MlflowException, match="No tag with key"):
mlflow_client.delete_logged_model_tag(model.model_id, "tag1")
def test_search_logged_models(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("create_logged_model")
model_1 = mlflow_client.create_logged_model(exp_id)
time.sleep(0.001) # to ensure different created time
models = mlflow_client.search_logged_models(experiment_ids=[exp_id])
assert [m.name for m in models] == [model_1.name]
# max_results
model_2 = mlflow_client.create_logged_model(exp_id)
page_1 = mlflow_client.search_logged_models(experiment_ids=[exp_id], max_results=1)
assert [m.name for m in page_1] == [model_2.name]
assert page_1.token is not None
# pagination
page_2 = mlflow_client.search_logged_models(
experiment_ids=[exp_id], max_results=1, page_token=page_1.token
)
assert [m.name for m in page_2] == [model_1.name]
assert page_2.token is None
# filter_string
models = mlflow_client.search_logged_models(
experiment_ids=[exp_id], filter_string=f"name = {model_1.name!r}"
)
assert [m.name for m in models] == [model_1.name]
# datasets
run_1 = mlflow_client.create_run(exp_id)
mlflow_client.log_metric(
run_1.info.run_id,
key="metric",
value=1,
dataset_name="dataset",
dataset_digest="123",
model_id=model_1.model_id,
)
models = mlflow_client.search_logged_models(
experiment_ids=[exp_id],
datasets=[{"dataset_name": "dataset", "dataset_digest": "123"}],
)
assert [m.name for m in models] == [model_1.name]
# order_by
models = mlflow_client.search_logged_models(
experiment_ids=[exp_id],
order_by=[{"field_name": "creation_timestamp", "ascending": False}],
)
assert [m.name for m in models] == [model_2.name, model_1.name]
def test_log_outputs(mlflow_client: MlflowClient):
exp_id = mlflow_client.create_experiment("log_outputs")
run = mlflow_client.create_run(experiment_id=exp_id)
model = mlflow_client.create_logged_model(experiment_id=exp_id)
model_outputs = [LoggedModelOutput(model.model_id, 1)]
mlflow_client.log_outputs(run.info.run_id, model_outputs)
run = mlflow_client.get_run(run.info.run_id)
assert run.outputs.model_outputs == model_outputs
def test_list_logged_model_artifacts(mlflow_client: MlflowClient):
class Model(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input):
return model_input
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
model_info = mlflow.pyfunc.log_model(name="model", python_model=Model())
resp = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/logged-models/{model_info.model_id}/artifacts/directories"
)
assert resp.status_code == 200
data = resp.json()
paths = [f["path"] for f in data["files"]]
assert "MLmodel" in paths
def test_get_logged_model_artifact(mlflow_client: MlflowClient):
class Model(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input):
return model_input
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
model_info = mlflow.pyfunc.log_model(name="model", python_model=Model())
resp = requests.get(
f"{mlflow_client.tracking_uri}/ajax-api/2.0/mlflow/logged-models/{model_info.model_id}/artifacts/files",
params={"artifact_file_path": "MLmodel"},
)
assert resp.status_code == 200
assert model_info.model_id in resp.text
def test_suppress_url_printing(mlflow_client: MlflowClient, monkeypatch):
monkeypatch.setenv(MLFLOW_SUPPRESS_PRINTING_URL_TO_STDOUT.name, "true")
exp_id = mlflow_client.create_experiment("test_suppress_url_printing")
run = mlflow_client.create_run(experiment_id=exp_id)
captured_output = StringIO()
monkeypatch.setattr(sys, "stdout", captured_output)
mlflow_client._tracking_client._log_url(run.info.run_id)
assert captured_output.getvalue() == ""
def test_log_url_includes_workspace_when_set(mlflow_client: MlflowClient, monkeypatch):
exp_id = mlflow_client.create_experiment("test_log_url_workspace")
run = mlflow_client.create_run(experiment_id=exp_id)
captured_output = StringIO()
monkeypatch.setattr(sys, "stdout", captured_output)
monkeypatch.setattr(
"mlflow.tracking._tracking_service.client.get_workspace_url", lambda: "http://localhost"
)
monkeypatch.setattr(
"mlflow.tracking._tracking_service.client.get_request_workspace", lambda: "team-space"
)
mlflow_client._tracking_client._log_url(run.info.run_id)
out = captured_output.getvalue()
expected_fragment = f"/#/experiments/{exp_id}/runs/{run.info.run_id}?workspace=team-space"
assert expected_fragment in out
def test_assessments_end_to_end(mlflow_client):
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
# Set up experiment and trace
experiment_id = mlflow_client.create_experiment("assessment_crud_test")
trace_info = mlflow_client.start_trace(name="test_trace", experiment_id=experiment_id)
mlflow_client.end_trace(request_id=trace_info.request_id)
mlflow.flush_trace_async_logging()
# CREATE initial feedback assessment
feedback_payload = {
"assessment": {
"assessment_name": "quality_score",
"feedback": {"value": {"rating": 4, "comments": "Good response"}},
"source": {"source_type": "HUMAN", "source_id": "evaluator@company.com"},
"rationale": "Response was accurate and helpful",
"metadata": {"model": "gpt-4", "version": "1.0"},
}
}
# CREATE assessment
create_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments",
json=feedback_payload,
)
assert create_response.status_code == 200
assessment = create_response.json()["assessment"]
assessment_id = assessment["assessment_id"]
# Verify creation
assert assessment["assessment_name"] == "quality_score"
assert assessment["feedback"]["value"]["rating"] == 4
assert assessment["source"]["source_type"] == "HUMAN"
assert assessment["valid"] is True
# GET assessment
get_response = requests.get(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{assessment_id}"
)
assert get_response.status_code == 200
retrieved = get_response.json()["assessment"]
assert retrieved["assessment_id"] == assessment_id
assert retrieved["feedback"]["value"]["rating"] == 4
# UPDATE assessment
update_payload = {
"assessment": {
"assessment_id": assessment_id,
"trace_id": trace_info.request_id,
"assessment_name": "updated_quality_score",
"feedback": {"value": {"rating": 5, "comments": "Excellent response"}},
"rationale": "Actually, the response was excellent",
"metadata": {"model": "gpt-4", "version": "2.0"},
},
"update_mask": "assessmentName,feedback,rationale,metadata",
}
update_response = requests.patch(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{assessment_id}",
json=update_payload,
)
assert update_response.status_code == 200
updated = update_response.json()["assessment"]
assert updated["assessment_name"] == "updated_quality_score"
assert updated["feedback"]["value"]["rating"] == 5
assert updated["rationale"] == "Actually, the response was excellent"
# CREATE override assessment
override_payload = {
"assessment": {
"assessment_name": "corrected_quality_score",
"feedback": {"value": {"rating": 3, "comments": "Actually needs improvement"}},
"source": {"source_type": "HUMAN", "source_id": "senior_evaluator@company.com"},
"overrides": assessment_id,
}
}
override_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments",
json=override_payload,
)
assert override_response.status_code == 200
override_assessment = override_response.json()["assessment"]
override_id = override_assessment["assessment_id"]
# Verify original is now invalid
get_original = requests.get(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{assessment_id}"
)
assert get_original.status_code == 200
assert get_original.json()["assessment"]["valid"] is False
# Verify override is valid
get_override = requests.get(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{override_id}"
)
assert get_override.status_code == 200
assert get_override.json()["assessment"]["valid"] is True
assert get_override.json()["assessment"]["overrides"] == assessment_id
# DELETE override assessment (should restore original)
delete_response = requests.delete(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{override_id}"
)
assert delete_response.status_code == 200
# Verify override is deleted
get_deleted = requests.get(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{override_id}"
)
assert get_deleted.status_code == 404
# Verify original is restored to valid
get_restored = requests.get(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{assessment_id}"
)
assert get_restored.status_code == 200
assert get_restored.json()["assessment"]["valid"] is True
# CREATE expectation assessment to test different type
expectation_payload = {
"assessment": {
"assessment_name": "response_time_check",
"expectation": {"value": {"threshold_ms": 1000, "actual_ms": 750, "passed": True}},
"source": {"source_type": "CODE", "source_id": "automated_test"},
}
}
expectation_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments",
json=expectation_payload,
)
assert expectation_response.status_code == 200
expectation = expectation_response.json()["assessment"]
expectation_id = expectation["assessment_id"]
# Verify expectation was created correctly
expectation_value = json.loads(expectation["expectation"]["serialized_value"]["value"])
assert expectation_value["passed"] is True
assert expectation_value["threshold_ms"] == 1000
assert expectation_value["actual_ms"] == 750
assert expectation["source"]["source_type"] == "CODE"
# Clean up - delete remaining assessments
for aid in [assessment_id, expectation_id]:
delete_resp = requests.delete(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/traces/{trace_info.request_id}/assessments/{aid}"
)
assert delete_resp.status_code == 200
def test_graphql_nan_metric_handling(mlflow_client):
experiment_id = mlflow_client.create_experiment("test_graphql_nan_metrics")
created_run = mlflow_client.create_run(experiment_id)
run_id = created_run.info.run_id
# Log a normal metric and a NaN metric
mlflow_client.log_metric(run_id, key="normal_metric", value=123, timestamp=1, step=1)
mlflow_client.log_metric(run_id, key="nan_metric", value=math.nan, timestamp=2, step=2)
response = requests.post(
f"{mlflow_client.tracking_uri}/graphql",
json={
"query": f"""
query testQuery {{
mlflowGetRun(input: {{runId: "{run_id}"}}) {{
run {{
data {{
metrics {{
key
value
timestamp
step
}}
}}
}}
}}
}}
""",
"operationName": "testQuery",
},
headers={"content-type": "application/json; charset=utf-8"},
)
assert response.status_code == 200
json_response = response.json()
assert json_response["errors"] is None
metrics = json_response["data"]["mlflowGetRun"]["run"]["data"]["metrics"]
# Find the normal metric and nan metric
normal_metric = None
nan_metric = None
for metric in metrics:
if metric["key"] == "normal_metric":
normal_metric = metric
elif metric["key"] == "nan_metric":
nan_metric = metric
# Verify normal metric has a numeric value
assert normal_metric is not None
assert normal_metric["key"] == "normal_metric"
assert normal_metric["value"] == 123
assert normal_metric["timestamp"] == "1"
assert normal_metric["step"] == "1"
# Verify NaN metric has null value
assert nan_metric is not None
assert nan_metric["key"] == "nan_metric"
assert nan_metric["value"] is None
assert nan_metric["timestamp"] == "2"
assert nan_metric["step"] == "2"
def test_create_and_get_evaluation_dataset(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Evaluation datasets not supported for FileStore")
experiment_id = mlflow_client.create_experiment("eval_dataset_test")
dataset = mlflow_client.create_dataset(
name="test_eval_dataset",
experiment_id=experiment_id,
tags={"environment": "test", "version": "1.0"},
)
assert dataset.name == "test_eval_dataset"
assert dataset.experiment_ids == [experiment_id]
assert dataset.tags["environment"] == "test"
assert dataset.tags["version"] == "1.0"
assert dataset.dataset_id is not None
retrieved = mlflow_client.get_dataset(dataset.dataset_id)
assert retrieved.name == dataset.name
assert retrieved.dataset_id == dataset.dataset_id
assert retrieved.tags == dataset.tags
def test_search_evaluation_datasets(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Evaluation datasets not supported for FileStore")
exp1 = mlflow_client.create_experiment("eval_search_exp1")
exp2 = mlflow_client.create_experiment("eval_search_exp2")
mlflow_client.create_dataset(
name="search_dataset_1", experiment_id=exp1, tags={"team": "ml", "status": "active"}
)
mlflow_client.create_dataset(
name="search_dataset_2",
experiment_id=[exp1, exp2],
tags={"team": "data", "status": "active"},
)
mlflow_client.create_dataset(
name="search_dataset_3", experiment_id=exp2, tags={"team": "ml", "status": "archived"}
)
all_datasets = mlflow_client.search_datasets()
assert len(all_datasets) >= 3
exp1_datasets = mlflow_client.search_datasets(experiment_ids=exp1)
dataset_names = [d.name for d in exp1_datasets]
assert "search_dataset_1" in dataset_names
assert "search_dataset_2" in dataset_names
ml_datasets = mlflow_client.search_datasets(filter_string="tags.team = 'ml'")
ml_names = [d.name for d in ml_datasets]
assert "search_dataset_1" in ml_names
assert "search_dataset_3" in ml_names
assert "search_dataset_2" not in ml_names
ordered_datasets = mlflow_client.search_datasets(order_by=["name ASC"])
names = [d.name for d in ordered_datasets]
assert names == sorted(names)
def test_evaluation_dataset_tag_operations(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Evaluation datasets not supported for FileStore")
experiment_id = mlflow_client.create_experiment("eval_tags_test")
dataset = mlflow_client.create_dataset(
name="tag_test_dataset",
experiment_id=experiment_id,
tags={"initial": "value", "env": "dev"},
)
mlflow_client.set_dataset_tags(dataset.dataset_id, {"env": "staging", "new_tag": "new_value"})
updated = mlflow_client.get_dataset(dataset.dataset_id)
assert updated.tags["initial"] == "value" # Original tag preserved
assert updated.tags["env"] == "staging" # Updated tag
assert updated.tags["new_tag"] == "new_value" # New tag added
mlflow_client.delete_dataset_tag(dataset.dataset_id, "new_tag")
final = mlflow_client.get_dataset(dataset.dataset_id)
assert "new_tag" not in final.tags
assert final.tags["env"] == "staging" # Other tags preserved
def test_evaluation_dataset_delete(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Evaluation datasets not supported for FileStore")
experiment_id = mlflow_client.create_experiment("eval_delete_test")
dataset = mlflow_client.create_dataset(
name="delete_test_dataset", experiment_id=experiment_id, tags={"to_delete": "yes"}
)
retrieved = mlflow_client.get_dataset(dataset.dataset_id)
assert retrieved.name == "delete_test_dataset"
mlflow_client.delete_dataset(dataset.dataset_id)
with pytest.raises(MlflowException, match="not found"):
mlflow_client.get_dataset(dataset.dataset_id)
def test_evaluation_dataset_upsert_records(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Evaluation datasets not supported for FileStore")
experiment_id = mlflow_client.create_experiment("upsert_records_test")
dataset = mlflow_client.create_dataset(
name="test_upsert_dataset",
experiment_id=experiment_id,
tags={"test": "upsert"},
)
initial_records = [
{
"inputs": {"question": "What is MLflow?"},
"expectations": {"answer": "MLflow is an ML platform"},
"tags": {"difficulty": "easy"},
},
{
"inputs": {"question": "What is Python?"},
"expectations": {"answer": "Python is a programming language"},
"tags": {"difficulty": "easy"},
},
]
# NB: MlflowClient doesn't have upsert_dataset_records method - merge_records() calls
# the store directly. We make HTTP requests here to test the REST API handler end-to-end.
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/datasets/{dataset.dataset_id}/records",
json={"records": json.dumps(initial_records)},
)
assert response.status_code == 200
result = response.json()
assert result["inserted_count"] == 2
assert result["updated_count"] == 0
update_records = [
{
"inputs": {"question": "What is MLflow?"},
"expectations": {"answer": "MLflow is an open-source ML platform"},
"tags": {"difficulty": "easy", "updated": "true"},
},
{
"inputs": {"question": "What is Docker?"},
"expectations": {"answer": "Docker is a containerization platform"},
"tags": {"difficulty": "medium"},
},
]
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/datasets/{dataset.dataset_id}/records",
json={"records": json.dumps(update_records)},
)
assert response.status_code == 200
result = response.json()
assert result["inserted_count"] == 1
assert result["updated_count"] == 1
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/datasets/invalid-id/records",
json={"records": json.dumps(initial_records)},
)
assert response.status_code != 200
def test_add_dataset_to_experiments_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("dataset_exp_1")
exp2 = mlflow_client.create_experiment("dataset_exp_2")
exp3 = mlflow_client.create_experiment("dataset_exp_3")
dataset = create_dataset(
name="test_multi_exp_dataset",
experiment_id=[exp1],
tags={"test": "multi_exp"},
)
assert len(dataset.experiment_ids) == 1
assert exp1 in dataset.experiment_ids
updated_dataset = add_dataset_to_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp2, exp3],
)
assert len(updated_dataset.experiment_ids) == 3
assert exp1 in updated_dataset.experiment_ids
assert exp2 in updated_dataset.experiment_ids
assert exp3 in updated_dataset.experiment_ids
retrieved = mlflow_client.get_dataset(dataset.dataset_id)
assert len(retrieved.experiment_ids) == 3
assert exp1 in retrieved.experiment_ids
assert exp2 in retrieved.experiment_ids
assert exp3 in retrieved.experiment_ids
def test_remove_dataset_from_experiments_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("dataset_remove_exp_1")
exp2 = mlflow_client.create_experiment("dataset_remove_exp_2")
exp3 = mlflow_client.create_experiment("dataset_remove_exp_3")
dataset = create_dataset(
name="test_remove_exp_dataset",
experiment_id=[exp1, exp2, exp3],
tags={"test": "remove_exp"},
)
assert len(dataset.experiment_ids) == 3
updated_dataset = remove_dataset_from_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp2],
)
assert len(updated_dataset.experiment_ids) == 2
assert exp1 in updated_dataset.experiment_ids
assert exp2 not in updated_dataset.experiment_ids
assert exp3 in updated_dataset.experiment_ids
retrieved = mlflow_client.get_dataset(dataset.dataset_id)
assert len(retrieved.experiment_ids) == 2
updated_dataset = remove_dataset_from_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp1, exp3],
)
assert len(updated_dataset.experiment_ids) == 0
retrieved = mlflow_client.get_dataset(dataset.dataset_id)
assert len(retrieved.experiment_ids) == 0
def test_add_multiple_experiments_at_once_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exps = [mlflow_client.create_experiment(f"bulk_add_exp_{i}") for i in range(5)]
dataset = create_dataset(
name="test_bulk_add_dataset",
experiment_id=[exps[0]],
tags={"test": "bulk_add"},
)
updated_dataset = add_dataset_to_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=exps[1:],
)
assert len(updated_dataset.experiment_ids) == 5
for exp in exps:
assert exp in updated_dataset.experiment_ids
def test_dataset_experiment_association_error_cases_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("error_test_exp")
with pytest.raises(MlflowException, match="not found"):
add_dataset_to_experiments(
dataset_id="d-nonexistent1234567890abcdef1234",
experiment_ids=[exp1],
)
with pytest.raises(MlflowException, match="not found"):
remove_dataset_from_experiments(
dataset_id="d-nonexistent1234567890abcdef1234",
experiment_ids=[exp1],
)
def test_idempotent_add_experiments_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("idempotent_test_exp_1")
exp2 = mlflow_client.create_experiment("idempotent_test_exp_2")
dataset = create_dataset(
name="test_idempotent_dataset",
experiment_id=[exp1, exp2],
tags={"test": "idempotent"},
)
assert len(dataset.experiment_ids) == 2
updated_dataset = add_dataset_to_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp1],
)
assert len(updated_dataset.experiment_ids) == 2
assert exp1 in updated_dataset.experiment_ids
assert exp2 in updated_dataset.experiment_ids
def test_idempotent_remove_experiments_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("remove_idempotent_test_exp_1")
exp2 = mlflow_client.create_experiment("remove_idempotent_test_exp_2")
dataset = create_dataset(
name="test_remove_idempotent_dataset",
experiment_id=[exp1],
tags={"test": "remove_idempotent"},
)
assert len(dataset.experiment_ids) == 1
updated_dataset = remove_dataset_from_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp2],
)
assert len(updated_dataset.experiment_ids) == 1
assert exp1 in updated_dataset.experiment_ids
def test_client_api_add_remove_experiments_rest_tracking(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support dataset operations")
exp1 = mlflow_client.create_experiment("client_api_exp_1")
exp2 = mlflow_client.create_experiment("client_api_exp_2")
exp3 = mlflow_client.create_experiment("client_api_exp_3")
dataset = mlflow_client.create_dataset(
name="test_client_api_dataset",
experiment_id=[exp1],
tags={"test": "client_api"},
)
updated_dataset = mlflow_client.add_dataset_to_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp2, exp3],
)
assert len(updated_dataset.experiment_ids) == 3
updated_dataset = mlflow_client.remove_dataset_from_experiments(
dataset_id=dataset.dataset_id,
experiment_ids=[exp2],
)
assert len(updated_dataset.experiment_ids) == 2
assert exp1 in updated_dataset.experiment_ids
assert exp2 not in updated_dataset.experiment_ids
assert exp3 in updated_dataset.experiment_ids
def test_scorer_CRUD(mlflow_client, store_type):
if store_type == "file":
pytest.skip("File store doesn't support scorer CRUD operations")
experiment_id = mlflow_client.create_experiment("test_scorer_api_experiment")
# Get the RestStore object directly
store = mlflow_client._tracking_client.store
# Test register scorer
scorer_data = {"name": "test_scorer", "original_func_name": "test_func"}
serialized_scorer = json.dumps(scorer_data)
version = store.register_scorer(experiment_id, "test_scorer", serialized_scorer)
assert version.scorer_version == 1
# Test list scorers
scorers = store.list_scorers(experiment_id)
assert len(scorers) == 1
assert scorers[0].scorer_name == "test_scorer"
assert scorers[0].scorer_version == 1
# Test list scorer versions
versions = store.list_scorer_versions(str(experiment_id), "test_scorer")
assert len(versions) == 1
assert versions[0].scorer_name == "test_scorer"
assert versions[0].scorer_version == 1
# Test get scorer (latest version)
scorer = store.get_scorer(str(experiment_id), "test_scorer")
assert scorer.scorer_name == "test_scorer"
assert scorer.scorer_version == 1
# Test get scorer (specific version)
scorer_v1 = store.get_scorer(str(experiment_id), "test_scorer", version=1)
assert scorer_v1.scorer_name == "test_scorer"
assert scorer_v1.scorer_version == 1
# Test register second version
scorer_data_v2 = {
"name": "test_scorer_v2",
"original_func_name": "test_func_v2",
}
serialized_scorer_v2 = json.dumps(scorer_data_v2)
version_v2 = store.register_scorer(str(experiment_id), "test_scorer", serialized_scorer_v2)
assert version_v2.scorer_version == 2
# Verify list scorers returns latest version
scorers_after_v2 = store.list_scorers(str(experiment_id))
assert len(scorers_after_v2) == 1
assert scorers_after_v2[0].scorer_version == 2
# Verify list versions returns both versions
versions_after_v2 = store.list_scorer_versions(str(experiment_id), "test_scorer")
assert len(versions_after_v2) == 2
# Test delete specific version
store.delete_scorer(str(experiment_id), "test_scorer", version=1)
# Verify version 1 is deleted
versions_after_delete = store.list_scorer_versions(str(experiment_id), "test_scorer")
assert len(versions_after_delete) == 1
assert versions_after_delete[0].scorer_version == 2
# Test delete all versions
store.delete_scorer(str(experiment_id), "test_scorer")
# Verify all versions are deleted
scorers_after_delete_all = store.list_scorers(str(experiment_id))
assert len(scorers_after_delete_all) == 0
# Clean up
mlflow_client.delete_experiment(experiment_id)
@pytest.mark.parametrize(
"filter_string",
[
"status = 'OK'",
None,
],
)
def test_online_scoring_config(mlflow_client_with_secrets, filter_string):
"""
Smoke test for online scoring configuration REST APIs.
Tests upsert_online_scoring_config and get_online_scoring_configs with both
string and None filter values (None is sent by UI when filter field is blank).
"""
experiment_id = mlflow_client_with_secrets.create_experiment("test_online_scoring")
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-secret", secret_value={"api_key": "sk-test"}, provider="openai"
)
model_def = store.create_gateway_model_definition(
name="test-model", secret_id=secret.secret_id, provider="openai", model_name="gpt-4"
)
endpoint = store.create_gateway_endpoint(
name="test-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
)
],
)
scorer_data = {"instructions_judge_pydantic_data": {"model": f"gateway:/{endpoint.name}"}}
serialized_scorer = json.dumps(scorer_data)
scorer_version = store.register_scorer(experiment_id, "my_scorer", serialized_scorer)
scorer_id = scorer_version.scorer_id
config = store.upsert_online_scoring_config(
experiment_id=experiment_id,
scorer_name="my_scorer",
sample_rate=0.5,
filter_string=filter_string,
)
assert config.scorer_id == scorer_id
assert config.sample_rate == 0.5
assert config.filter_string == filter_string
assert config.experiment_id == experiment_id
configs = store.get_online_scoring_configs([scorer_id])
assert len(configs) == 1
assert configs[0].scorer_id == scorer_id
assert configs[0].sample_rate == 0.5
assert configs[0].filter_string == filter_string
# Update with different filter string to test update functionality
updated_filter = "status = 'COMPLETED'"
updated_config = store.upsert_online_scoring_config(
experiment_id=experiment_id,
scorer_name="my_scorer",
sample_rate=0.8,
filter_string=updated_filter,
)
assert updated_config.scorer_id == scorer_id
assert updated_config.sample_rate == 0.8
assert updated_config.filter_string == updated_filter
configs_after_update = store.get_online_scoring_configs([scorer_id])
assert len(configs_after_update) == 1
assert configs_after_update[0].sample_rate == 0.8
assert configs_after_update[0].filter_string == updated_filter
@pytest.mark.parametrize("use_async", [False, True])
@pytest.mark.asyncio
async def test_rest_store_logs_spans_via_otel_endpoint(mlflow_client, store_type, use_async):
"""
End-to-end test that verifies RestStore can log spans to a running server via OTLP endpoint.
This test:
1. Creates spans using MLflow's span entities
2. Uses RestStore.log_spans or log_spans_async to send them via OTLP protocol
3. Verifies the spans were stored and can be retrieved
"""
if store_type == "file":
pytest.skip("FileStore does not support OTLP span logging")
experiment_id = mlflow_client.create_experiment(f"rest_store_otel_test_{use_async}")
root_span = mlflow_client.start_trace(
f"rest_store_otel_trace_{use_async}", experiment_id=experiment_id
)
otel_span = OTelReadableSpan(
name=f"test-rest-store-span-{use_async}",
context=build_otel_context(
trace_id=int(root_span.trace_id[3:], 16), # Remove 'tr-' prefix and convert to int
span_id=0x1234567890ABCDEF,
),
parent=None,
start_time=1000000000,
end_time=2000000000,
attributes={
SpanAttributeKey.REQUEST_ID: root_span.trace_id,
"test.attribute": json.dumps(f"test-value-{use_async}"), # JSON-encoded string value
},
resource=None,
)
mlflow_span_to_log = Span(otel_span)
# Call either sync or async version based on parametrization
if use_async:
# Use await to execute the async method
result_spans = await mlflow_client._tracking_client.store.log_spans_async(
location=experiment_id, spans=[mlflow_span_to_log]
)
else:
result_spans = mlflow_client._tracking_client.store.log_spans(
location=experiment_id, spans=[mlflow_span_to_log]
)
# Verify the spans were returned (indicates successful logging)
assert len(result_spans) == 1
assert result_spans[0].name == f"test-rest-store-span-{use_async}"
# =============================================================================
# Secrets and Endpoints E2E Tests
# =============================================================================
def test_create_and_get_secret(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-api-key",
secret_value={"api_key": "sk-test-12345"},
provider="openai",
)
assert secret.secret_name == "test-api-key"
assert secret.provider == "openai"
assert secret.secret_id is not None
fetched = store.get_secret_info(secret.secret_id)
assert fetched.secret_name == "test-api-key"
assert fetched.provider == "openai"
assert fetched.secret_id == secret.secret_id
def test_update_secret(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-key",
secret_value={"api_key": "initial-value"},
provider="anthropic",
)
updated = store.update_gateway_secret(
secret_id=secret.secret_id,
secret_value={"api_key": "updated-value"},
)
assert updated.secret_id == secret.secret_id
assert updated.secret_name == "test-key"
def test_list_secret_infos(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret1 = store.create_gateway_secret(
secret_name="openai-key",
secret_value={"api_key": "sk-openai"},
provider="openai",
)
store.create_gateway_secret(
secret_name="anthropic-key",
secret_value={"api_key": "sk-ant"},
provider="anthropic",
)
all_secrets = store.list_secret_infos()
assert len(all_secrets) >= 2
openai_secrets = store.list_secret_infos(provider="openai")
assert len(openai_secrets) >= 1
assert any(s.secret_id == secret1.secret_id for s in openai_secrets)
def test_delete_secret(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="temp-key",
secret_value={"api_key": "temp-value"},
)
store.delete_gateway_secret(secret.secret_id)
all_secrets = store.list_secret_infos()
assert not any(s.secret_id == secret.secret_id for s in all_secrets)
def test_create_secret_with_dict_value(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="aws-creds",
secret_value={"aws_access_key_id": "AKIATEST1234", "aws_secret_access_key": "secret123abc"},
provider="bedrock",
)
assert secret.secret_name == "aws-creds"
assert secret.provider == "bedrock"
assert secret.secret_id is not None
assert isinstance(secret.masked_values, dict)
assert secret.masked_values == {
"aws_access_key_id": "AKI...1234",
"aws_secret_access_key": "sec...3abc",
}
def test_update_secret_with_dict_value(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="aws-creds-update",
secret_value={"api_key": "initial-value-1234"},
provider="bedrock",
)
assert isinstance(secret.masked_values, dict)
assert secret.masked_values == {"api_key": "ini...1234"}
updated = store.update_gateway_secret(
secret_id=secret.secret_id,
secret_value={
"aws_access_key_id": "NEWKEY123456",
"aws_secret_access_key": "newsecret1234",
},
)
assert updated.secret_id == secret.secret_id
assert updated.secret_name == "aws-creds-update"
assert isinstance(updated.masked_values, dict)
assert updated.masked_values == {
"aws_access_key_id": "NEW...3456",
"aws_secret_access_key": "new...1234",
}
def test_create_and_update_compound_secret_via_rest(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="bedrock-aws-creds",
secret_value={
"aws_access_key_id": "AKIAORIGINAL1234",
"aws_secret_access_key": "original-secret-key-1234",
},
provider="bedrock",
auth_config={"auth_mode": "access_keys", "aws_region_name": "us-east-1"},
)
assert secret.secret_name == "bedrock-aws-creds"
assert secret.provider == "bedrock"
assert isinstance(secret.masked_values, dict)
assert secret.masked_values == {
"aws_access_key_id": "AKI...1234",
"aws_secret_access_key": "ori...1234",
}
fetched = store.get_secret_info(secret_id=secret.secret_id)
assert fetched.secret_id == secret.secret_id
assert isinstance(fetched.masked_values, dict)
assert fetched.masked_values == secret.masked_values
updated = store.update_gateway_secret(
secret_id=secret.secret_id,
secret_value={
"aws_access_key_id": "AKIAROTATED5678",
"aws_secret_access_key": "rotated-secret-key-5678",
},
)
assert updated.secret_id == secret.secret_id
assert updated.last_updated_at > secret.created_at
assert isinstance(updated.masked_values, dict)
assert updated.masked_values == {
"aws_access_key_id": "AKI...5678",
"aws_secret_access_key": "rot...5678",
}
def test_create_and_get_endpoint(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-api-key",
secret_value={"api_key": "sk-test-12345"},
provider="openai",
)
secret2 = store.create_gateway_secret(
secret_name="test-api-key-fallback",
secret_value={"api_key": "sk-test-67890"},
provider="anthropic",
)
model_def = store.create_gateway_model_definition(
name="test-model-def",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
model_def_fallback = store.create_gateway_model_definition(
name="test-model-def-fallback",
secret_id=secret2.secret_id,
provider="anthropic",
model_name="claude-3-5-sonnet",
)
endpoint = store.create_gateway_endpoint(
name="test-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id=model_def_fallback.model_definition_id,
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=0,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT,
fallback_config=FallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL,
max_attempts=2,
),
)
assert endpoint.name == "test-endpoint"
assert endpoint.endpoint_id is not None
assert len(endpoint.model_mappings) == 2
assert endpoint.model_mappings[0].model_definition.model_name == "gpt-4"
assert endpoint.routing_strategy == RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT
assert endpoint.fallback_config is not None
assert endpoint.fallback_config.strategy == FallbackStrategy.SEQUENTIAL
assert endpoint.fallback_config.max_attempts == 2
fetched = store.get_gateway_endpoint(endpoint.endpoint_id)
assert fetched.name == "test-endpoint"
assert fetched.endpoint_id == endpoint.endpoint_id
assert len(fetched.model_mappings) == 2
assert fetched.routing_strategy == RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT
assert fetched.fallback_config is not None
assert fetched.fallback_config.strategy == FallbackStrategy.SEQUENTIAL
assert fetched.fallback_config.max_attempts == 2
def test_create_endpoint_with_usage_tracking(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="usage-tracking-test-key",
secret_value={"api_key": "sk-usage-tracking-test"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="usage-tracking-model-def",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
endpoint = store.create_gateway_endpoint(
name="usage-tracking-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
)
],
usage_tracking=True,
)
assert endpoint.usage_tracking is True
experiment_id = endpoint.experiment_id
# Experiment is automatically created with usage tracking enabled
experiment = mlflow_client_with_secrets.get_experiment(experiment_id)
assert experiment.name == "gateway/usage-tracking-endpoint"
def test_update_endpoint(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-api-key-2",
secret_value={"api_key": "sk-test-67890"},
provider="anthropic",
)
secret2 = store.create_gateway_secret(
secret_name="test-api-key-2-fallback",
secret_value={"api_key": "sk-test-99999"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="test-model-def-2",
secret_id=secret.secret_id,
provider="anthropic",
model_name="claude-3-5-sonnet",
)
model_def_fallback = store.create_gateway_model_definition(
name="test-model-def-2-fallback",
secret_id=secret2.secret_id,
provider="openai",
model_name="gpt-4",
)
endpoint = store.create_gateway_endpoint(
name="initial-name",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
updated = store.update_gateway_endpoint(
endpoint_id=endpoint.endpoint_id,
name="updated-name",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id=model_def_fallback.model_definition_id,
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=0,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT,
fallback_config=FallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL,
max_attempts=3,
),
)
assert updated.endpoint_id == endpoint.endpoint_id
assert updated.name == "updated-name"
assert updated.routing_strategy == RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT
assert updated.fallback_config is not None
assert updated.fallback_config.strategy == FallbackStrategy.SEQUENTIAL
assert updated.fallback_config.max_attempts == 3
assert len(updated.model_mappings) == 2
def test_list_endpoints(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret1 = store.create_gateway_secret(
secret_name="test-api-key-3",
secret_value={"api_key": "sk-test-11111"},
provider="openai",
)
secret2 = store.create_gateway_secret(
secret_name="test-api-key-4",
secret_value={"api_key": "sk-test-22222"},
provider="openai",
)
secret3 = store.create_gateway_secret(
secret_name="test-api-key-fallback-3",
secret_value={"api_key": "sk-test-44444"},
provider="anthropic",
)
model_def1 = store.create_gateway_model_definition(
name="test-model-def-3",
secret_id=secret1.secret_id,
provider="openai",
model_name="gpt-4",
)
model_def2 = store.create_gateway_model_definition(
name="test-model-def-4",
secret_id=secret2.secret_id,
provider="openai",
model_name="gpt-3.5-turbo",
)
model_def3 = store.create_gateway_model_definition(
name="test-model-def-fallback-3",
secret_id=secret3.secret_id,
provider="anthropic",
model_name="claude-3-5-sonnet",
)
# Create endpoint without fallback
endpoint1 = store.create_gateway_endpoint(
name="endpoint-1",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def1.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
# Create endpoint with fallback
endpoint2 = store.create_gateway_endpoint(
name="endpoint-2",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def2.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id=model_def3.model_definition_id,
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=0,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT,
fallback_config=FallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL,
max_attempts=2,
),
)
all_endpoints = store.list_gateway_endpoints()
assert len(all_endpoints) >= 2
endpoint_ids = {e.endpoint_id for e in all_endpoints}
assert endpoint1.endpoint_id in endpoint_ids
assert endpoint2.endpoint_id in endpoint_ids
# Find and verify endpoints
found_ep1 = next(e for e in all_endpoints if e.endpoint_id == endpoint1.endpoint_id)
found_ep2 = next(e for e in all_endpoints if e.endpoint_id == endpoint2.endpoint_id)
assert found_ep1.routing_strategy is None
assert found_ep1.fallback_config is None
assert found_ep2.routing_strategy == RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT
assert found_ep2.fallback_config is not None
assert found_ep2.fallback_config.strategy == FallbackStrategy.SEQUENTIAL
assert found_ep2.fallback_config.max_attempts == 2
def test_delete_endpoint(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="test-api-key-5",
secret_value={"api_key": "sk-test-33333"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="test-model-def-5",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
endpoint = store.create_gateway_endpoint(
name="temp-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
store.delete_gateway_endpoint(endpoint.endpoint_id)
all_endpoints = store.list_gateway_endpoints()
assert not any(e.endpoint_id == endpoint.endpoint_id for e in all_endpoints)
def test_model_definitions(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="model-secret",
secret_value={"api_key": "sk-test"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="test-model-def",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
assert model_def.name == "test-model-def"
assert model_def.secret_id == secret.secret_id
assert model_def.provider == "openai"
assert model_def.model_name == "gpt-4"
assert model_def.model_definition_id is not None
fetched = store.get_gateway_model_definition(model_def.model_definition_id)
assert fetched.model_definition_id == model_def.model_definition_id
assert fetched.name == "test-model-def"
updated = store.update_gateway_model_definition(
model_definition_id=model_def.model_definition_id,
model_name="gpt-4-turbo",
)
assert updated.model_definition_id == model_def.model_definition_id
assert updated.model_name == "gpt-4-turbo"
all_defs = store.list_gateway_model_definitions()
assert any(d.model_definition_id == model_def.model_definition_id for d in all_defs)
store.delete_gateway_model_definition(model_def.model_definition_id)
all_defs_after = store.list_gateway_model_definitions()
assert not any(d.model_definition_id == model_def.model_definition_id for d in all_defs_after)
def test_attach_detach_model_to_endpoint(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="attach-detach-secret",
secret_value={"api_key": "sk-test-attach"},
provider="openai",
)
model_def1 = store.create_gateway_model_definition(
name="attach-model-def-1",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
model_def2 = store.create_gateway_model_definition(
name="attach-model-def-2",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-3.5-turbo",
)
endpoint = store.create_gateway_endpoint(
name="attach-test-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def1.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
assert len(endpoint.model_mappings) == 1
assert endpoint.model_mappings[0].model_definition.model_name == "gpt-4"
mapping = store.attach_model_to_endpoint(
endpoint_id=endpoint.endpoint_id,
model_config=GatewayEndpointModelConfig(
model_definition_id=model_def2.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
)
assert mapping.endpoint_id == endpoint.endpoint_id
assert mapping.model_definition_id == model_def2.model_definition_id
fetched_endpoint = store.get_gateway_endpoint(endpoint.endpoint_id)
assert len(fetched_endpoint.model_mappings) == 2
store.detach_model_from_endpoint(
endpoint_id=endpoint.endpoint_id,
model_definition_id=model_def2.model_definition_id,
)
fetched_endpoint_after = store.get_gateway_endpoint(endpoint.endpoint_id)
assert len(fetched_endpoint_after.model_mappings) == 1
def test_endpoint_bindings(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="binding-secret",
secret_value={"api_key": "sk-test-44444"},
provider="openai",
)
model_def1 = store.create_gateway_model_definition(
name="binding-model-def-1",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
model_def2 = store.create_gateway_model_definition(
name="binding-model-def-2",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-3.5-turbo",
)
endpoint1 = store.create_gateway_endpoint(
name="binding-test-endpoint-1",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def1.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
endpoint2 = store.create_gateway_endpoint(
name="binding-test-endpoint-2",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def2.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
binding1 = store.create_endpoint_binding(
endpoint_id=endpoint1.endpoint_id,
resource_type=GatewayResourceType.SCORER,
resource_id="job-123",
)
binding2 = store.create_endpoint_binding(
endpoint_id=endpoint1.endpoint_id,
resource_type=GatewayResourceType.SCORER,
resource_id="job-456",
)
binding3 = store.create_endpoint_binding(
endpoint_id=endpoint2.endpoint_id,
resource_type=GatewayResourceType.SCORER,
resource_id="job-789",
)
assert binding1.endpoint_id == endpoint1.endpoint_id
assert binding1.resource_type == GatewayResourceType.SCORER
assert binding1.resource_id == "job-123"
bindings_endpoint1 = store.list_endpoint_bindings(endpoint_id=endpoint1.endpoint_id)
assert len(bindings_endpoint1) == 2
resource_ids = {b.resource_id for b in bindings_endpoint1}
assert binding1.resource_id in resource_ids
assert binding2.resource_id in resource_ids
assert binding3.resource_id not in resource_ids
bindings_by_type = store.list_endpoint_bindings(resource_type=GatewayResourceType.SCORER)
assert len(bindings_by_type) >= 3
bindings_by_resource = store.list_endpoint_bindings(resource_id="job-123")
assert len(bindings_by_resource) == 1
assert bindings_by_resource[0].resource_id == binding1.resource_id
bindings_multi = store.list_endpoint_bindings(
endpoint_id=endpoint1.endpoint_id,
resource_type=GatewayResourceType.SCORER,
)
assert len(bindings_multi) == 2
store.delete_endpoint_binding(
endpoint_id=binding1.endpoint_id,
resource_type=binding1.resource_type.value,
resource_id=binding1.resource_id,
)
bindings_after = store.list_endpoint_bindings(endpoint_id=endpoint1.endpoint_id)
assert len(bindings_after) == 1
assert not any(b.resource_id == binding1.resource_id for b in bindings_after)
def test_secrets_and_endpoints_integration(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="integration-test-key",
secret_value={"api_key": "sk-integration-test"},
provider="openai",
)
model_def1 = store.create_gateway_model_definition(
name="integration-model-def-1",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-3.5-turbo",
)
model_def2 = store.create_gateway_model_definition(
name="integration-model-def-2",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
endpoint = store.create_gateway_endpoint(
name="integration-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def1.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
mapping = store.attach_model_to_endpoint(
endpoint_id=endpoint.endpoint_id,
model_config=GatewayEndpointModelConfig(
model_definition_id=model_def2.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
)
binding = store.create_endpoint_binding(
endpoint_id=endpoint.endpoint_id,
resource_type=GatewayResourceType.SCORER,
resource_id="integration-job",
)
fetched_endpoint = store.get_gateway_endpoint(endpoint.endpoint_id)
assert len(fetched_endpoint.model_mappings) == 2
mapping_ids = {m.mapping_id for m in fetched_endpoint.model_mappings}
assert mapping.mapping_id in mapping_ids
bindings = store.list_endpoint_bindings(resource_id="integration-job")
assert len(bindings) == 1
assert bindings[0].resource_id == binding.resource_id
store.delete_endpoint_binding(
endpoint_id=binding.endpoint_id,
resource_type=binding.resource_type.value,
resource_id=binding.resource_id,
)
store.detach_model_from_endpoint(
endpoint_id=endpoint.endpoint_id,
model_definition_id=model_def2.model_definition_id,
)
store.delete_gateway_endpoint(endpoint.endpoint_id)
store.delete_gateway_model_definition(model_def1.model_definition_id)
store.delete_gateway_model_definition(model_def2.model_definition_id)
store.delete_gateway_secret(secret.secret_id)
def test_list_providers(mlflow_client_with_secrets):
import requests
base_url = mlflow_client_with_secrets._tracking_client.tracking_uri
response = requests.get(f"{base_url}/ajax-api/3.0/mlflow/gateway/supported-providers")
assert response.status_code == 200
data = response.json()
assert "providers" in data
assert isinstance(data["providers"], list)
assert len(data["providers"]) > 0
assert "openai" in data["providers"]
def test_list_models(mlflow_client_with_secrets):
import requests
base_url = mlflow_client_with_secrets._tracking_client.tracking_uri
response = requests.get(f"{base_url}/ajax-api/3.0/mlflow/gateway/supported-models")
assert response.status_code == 200
data = response.json()
assert "models" in data
assert isinstance(data["models"], list)
assert len(data["models"]) > 0
model = data["models"][0]
assert "model" in model
assert "provider" in model
assert "mode" in model
assert all(not m["model"].startswith("ft:") for m in data["models"])
response = requests.get(
f"{base_url}/ajax-api/3.0/mlflow/gateway/supported-models", params={"provider": "openai"}
)
assert response.status_code == 200
filtered_data = response.json()
assert all(m["provider"] == "openai" for m in filtered_data["models"])
def test_get_provider_config(mlflow_client_with_secrets):
import requests
base_url = mlflow_client_with_secrets._tracking_client.tracking_uri
# Test simple provider (openai) - should have single api_key auth mode
response = requests.get(
f"{base_url}/ajax-api/3.0/mlflow/gateway/provider-config",
params={"provider": "openai"},
)
assert response.status_code == 200
data = response.json()
assert "auth_modes" in data
assert "default_mode" in data
assert data["default_mode"] == "api_key"
assert len(data["auth_modes"]) >= 1
api_key_mode = data["auth_modes"][0]
assert api_key_mode["mode"] == "api_key"
# Test multi-mode provider (bedrock) - should have multiple auth modes
response = requests.get(
f"{base_url}/ajax-api/3.0/mlflow/gateway/provider-config",
params={"provider": "bedrock"},
)
assert response.status_code == 200
data = response.json()
assert "auth_modes" in data
assert data["default_mode"] == "api_key"
assert len(data["auth_modes"]) >= 2 # api_key, access_keys, iam_role
# Check access_keys mode structure
access_keys_mode = next(m for m in data["auth_modes"] if m["mode"] == "access_keys")
assert len(access_keys_mode["secret_fields"]) == 2 # access_key_id, secret_access_key
assert any(f["name"] == "aws_secret_access_key" for f in access_keys_mode["secret_fields"])
assert any(f["name"] == "aws_region_name" for f in access_keys_mode["config_fields"])
# Check iam_role mode exists
iam_role_mode = next(m for m in data["auth_modes"] if m["mode"] == "iam_role")
assert any(f["name"] == "aws_role_name" for f in iam_role_mode["config_fields"])
# Unknown providers get a generic fallback
response = requests.get(
f"{base_url}/ajax-api/3.0/mlflow/gateway/provider-config",
params={"provider": "unknown_provider"},
)
assert response.status_code == 200
data = response.json()
assert data["default_mode"] == "api_key"
assert data["auth_modes"][0]["mode"] == "api_key"
assert data["auth_modes"][0]["config_fields"][0]["name"] == "api_base"
# Missing provider parameter returns 400
response = requests.get(f"{base_url}/ajax-api/3.0/mlflow/gateway/provider-config")
assert response.status_code == 400
def test_get_secrets_config_with_custom_passphrase(mlflow_client_with_secrets):
base_url = mlflow_client_with_secrets._tracking_client.tracking_uri
response = requests.get(f"{base_url}/ajax-api/3.0/mlflow/gateway/secrets/config")
assert response.status_code == 200
data = response.json()
assert data["secrets_available"] is True
assert data["using_default_passphrase"] is False
def test_get_secrets_config_with_default_passphrase(tmp_path: Path, monkeypatch):
from tests.tracking.integration_test_utils import ServerThread, get_safe_port
monkeypatch.delenv("MLFLOW_CRYPTO_KEK_PASSPHRASE", raising=False)
backend_uri = f"sqlite:///{tmp_path}/mlflow.db"
artifact_uri = (tmp_path / "artifacts").as_uri()
store = SqlAlchemyStore(backend_uri, artifact_uri)
store.engine.dispose()
handlers._tracking_store = None
handlers._model_registry_store = None
initialize_backend_stores(backend_uri, default_artifact_root=artifact_uri)
with ServerThread(app, get_safe_port()) as url:
response = requests.get(f"{url}/ajax-api/3.0/mlflow/gateway/secrets/config")
assert response.status_code == 200
data = response.json()
assert data["secrets_available"] is True
assert data["using_default_passphrase"] is True
def test_endpoint_with_orphaned_model_definition(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="orphan-test-key",
secret_value={"api_key": "sk-orphan-test"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="orphan-model-def",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
endpoint = store.create_gateway_endpoint(
name="orphan-test-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id=model_def.model_definition_id,
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
],
)
assert len(endpoint.model_mappings) == 1
assert endpoint.model_mappings[0].model_definition.secret_id == secret.secret_id
assert endpoint.model_mappings[0].model_definition.secret_name == "orphan-test-key"
store.delete_gateway_secret(secret.secret_id)
fetched_endpoint = store.get_gateway_endpoint(endpoint.endpoint_id)
assert len(fetched_endpoint.model_mappings) == 1
assert fetched_endpoint.model_mappings[0].model_definition.secret_id is None
assert fetched_endpoint.model_mappings[0].model_definition.secret_name is None
def test_update_model_definition_provider(mlflow_client_with_secrets):
store = mlflow_client_with_secrets._tracking_client.store
secret = store.create_gateway_secret(
secret_name="provider-update-secret",
secret_value={"api_key": "sk-provider-test"},
provider="openai",
)
model_def = store.create_gateway_model_definition(
name="provider-update-model-def",
secret_id=secret.secret_id,
provider="openai",
model_name="gpt-4",
)
assert model_def.provider == "openai"
assert model_def.model_name == "gpt-4"
updated = store.update_gateway_model_definition(
model_definition_id=model_def.model_definition_id,
provider="anthropic",
model_name="claude-3-5-haiku-latest",
)
assert updated.provider == "anthropic"
assert updated.model_name == "claude-3-5-haiku-latest"
fetched = store.get_gateway_model_definition(model_def.model_definition_id)
assert fetched.provider == "anthropic"
assert fetched.model_name == "claude-3-5-haiku-latest"
store.delete_gateway_model_definition(model_def.model_definition_id)
store.delete_gateway_secret(secret.secret_id)
def test_create_issue_with_all_fields(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
mlflow.set_tracking_uri(mlflow_client.tracking_uri)
experiment_id = mlflow_client.create_experiment("Issue Test")
run = mlflow_client.create_run(experiment_id)
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "High latency issue",
"description": "API calls are taking too long",
"status": IssueStatus.PENDING.value,
"source_run_id": run.info.run_id,
"root_causes": ["Database query inefficiency", "Network latency"],
"severity": IssueSeverity.HIGH.value,
"created_by": "test-user",
},
)
assert response.status_code == 200
data = response.json()
assert "issue" in data
issue = data["issue"]
assert issue["experiment_id"] == experiment_id
assert issue["name"] == "High latency issue"
assert issue["description"] == "API calls are taking too long"
assert issue["status"] == IssueStatus.PENDING.value
assert issue["source_run_id"] == run.info.run_id
assert issue["root_causes"] == ["Database query inefficiency", "Network latency"]
assert issue["severity"] == IssueSeverity.HIGH.value
assert issue["created_by"] == "test-user"
assert "issue_id" in issue
assert "created_timestamp" in issue
assert "last_updated_timestamp" in issue
def test_create_issue_minimal_fields(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Minimal")
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Test issue",
"description": "Test description",
},
)
assert response.status_code == 200
data = response.json()
issue = data["issue"]
assert issue["experiment_id"] == experiment_id
assert issue["name"] == "Test issue"
assert issue["description"] == "Test description"
assert issue["status"] == IssueStatus.PENDING.value
assert "issue_id" in issue
def test_create_issue_with_required_fields(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Required Fields")
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Issue with required fields only",
"description": "Testing issue creation with required fields",
"status": IssueStatus.RESOLVED.value,
},
)
assert response.status_code == 200
data = response.json()
issue = data["issue"]
assert issue["status"] == IssueStatus.RESOLVED.value
assert "issue_id" in issue
assert "created_timestamp" in issue
assert "last_updated_timestamp" in issue
def test_create_issue_invalid_experiment(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": "999999",
"name": "Test issue",
"description": "Test description",
},
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "RESOURCE_DOES_NOT_EXIST"
def test_get_issue(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Get")
create_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Test issue",
"description": "Test description",
"severity": IssueSeverity.MEDIUM.value,
},
)
issue_id = create_response.json()["issue"]["issue_id"]
get_response = requests.get(f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/{issue_id}")
assert get_response.status_code == 200
data = get_response.json()
issue = data["issue"]
assert issue["issue_id"] == issue_id
assert issue["name"] == "Test issue"
assert issue["severity"] == IssueSeverity.MEDIUM.value
def test_get_issue_not_found(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
response = requests.get(f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/nonexistent-issue")
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "RESOURCE_DOES_NOT_EXIST"
def test_update_issue(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Update")
create_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Original name",
"description": "Original description",
"status": IssueStatus.PENDING.value,
},
)
issue_id = create_response.json()["issue"]["issue_id"]
update_response = requests.patch(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/{issue_id}",
json={
"issue_id": issue_id,
"name": "Updated name",
"description": "Updated description",
"status": IssueStatus.RESOLVED.value,
"severity": IssueSeverity.HIGH.value,
},
)
assert update_response.status_code == 200
data = update_response.json()
issue = data["issue"]
assert issue["issue_id"] == issue_id
assert issue["name"] == "Updated name"
assert issue["description"] == "Updated description"
assert issue["status"] == IssueStatus.RESOLVED.value
assert issue["severity"] == IssueSeverity.HIGH.value
def test_search_issues_no_filters(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Search")
for i in range(3):
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": f"Issue {i}",
"description": f"Description {i}",
},
)
search_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search", json={}
)
assert search_response.status_code == 200
data = search_response.json()
assert "issues" in data
assert len(data["issues"]) == 3
assert {issue["name"] for issue in data["issues"]} == {"Issue 0", "Issue 1", "Issue 2"}
assert {issue["status"] for issue in data["issues"]} == {IssueStatus.PENDING.value}
def test_search_issues_by_experiment(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
exp1 = mlflow_client.create_experiment("Issue Test Search Exp1")
exp2 = mlflow_client.create_experiment("Issue Test Search Exp2")
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": exp1,
"name": "Issue in exp1",
"description": "Description",
},
)
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": exp2,
"name": "Issue in exp2",
"description": "Description",
},
)
search_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search",
json={"experiment_id": exp1},
)
assert search_response.status_code == 200
data = search_response.json()
issues = data["issues"]
assert len(issues) == 1
assert issues[0]["experiment_id"] == exp1
assert issues[0]["name"] == "Issue in exp1"
def test_search_issues_by_status(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Search Status")
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Draft issue",
"description": "Description",
"status": IssueStatus.PENDING.value,
},
)
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": "Confirmed issue",
"description": "Description",
"status": IssueStatus.RESOLVED.value,
},
)
search_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search",
json={"experiment_id": experiment_id, "filter_string": "status = 'resolved'"},
)
assert search_response.status_code == 200
data = search_response.json()
issues = data["issues"]
assert all(issue["status"] == IssueStatus.RESOLVED.value for issue in issues)
assert any(issue["name"] == "Confirmed issue" for issue in issues)
def test_search_issues_with_pagination(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Pagination")
for i in range(15):
requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": f"Issue {i}",
"description": f"Description {i}",
},
)
first_page = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search",
json={"experiment_id": experiment_id, "max_results": 10},
)
assert first_page.status_code == 200
first_data = first_page.json()
assert len(first_data["issues"]) == 10
assert "next_page_token" in first_data
assert first_data["next_page_token"] != ""
second_page = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search",
json={
"experiment_id": experiment_id,
"max_results": 10,
"page_token": first_data["next_page_token"],
},
)
assert second_page.status_code == 200
second_data = second_page.json()
assert len(second_data["issues"]) == 5
assert second_data["next_page_token"] == ""
def test_search_issues_sorted_by_timestamp(mlflow_client, store_type):
if store_type == "file":
pytest.skip("Issues are only supported in SqlAlchemyStore")
experiment_id = mlflow_client.create_experiment("Issue Test Sort")
# Create issues with slight delays to ensure different timestamps
issue_ids = []
for i in range(3):
response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues",
json={
"experiment_id": experiment_id,
"name": f"Issue {i}",
"description": f"Description {i}",
},
)
issue_ids.append(response.json()["issue"]["issue_id"])
time.sleep(0.01) # Small delay to ensure different timestamps
search_response = requests.post(
f"{mlflow_client.tracking_uri}/api/3.0/mlflow/issues/search",
json={"experiment_id": experiment_id},
)
assert search_response.status_code == 200
data = search_response.json()
issues = data["issues"]
assert len(issues) == 3
# Issues should be returned (default order is by created_timestamp descending)
assert {issue["issue_id"] for issue in issues} == set(issue_ids)