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)