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

3988 lines
139 KiB
Python

import json
import math
import time
from unittest import mock
import pytest
import mlflow
from mlflow.entities import (
Dataset,
DatasetInput,
EvaluationDataset,
Experiment,
ExperimentTag,
FallbackConfig,
FallbackStrategy,
GatewayEndpointModelConfig,
GatewayModelLinkageType,
GatewayResourceType,
InputTag,
Issue,
IssueSeverity,
IssueStatus,
LifecycleStage,
LoggedModelParameter,
Metric,
Param,
RoutingStrategy,
RunTag,
SourceType,
ViewType,
)
from mlflow.entities.assessment import (
Assessment,
Expectation,
ExpectationValue,
Feedback,
FeedbackValue,
)
from mlflow.entities.assessment_error import AssessmentError
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
from mlflow.entities.model_registry import PromptVersion
from mlflow.entities.span import LiveSpan
from mlflow.entities.trace import Trace
from mlflow.entities.trace_data import TraceData
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_info_v2 import TraceInfoV2
from mlflow.entities.trace_location import TraceLocation
from mlflow.entities.trace_metrics import (
AggregationType,
MetricAggregation,
MetricDataPoint,
MetricViewType,
)
from mlflow.entities.trace_state import TraceState
from mlflow.entities.trace_status import TraceStatus
from mlflow.environment_variables import (
_MLFLOW_CREATE_LOGGED_MODEL_PARAMS_BATCH_SIZE,
_MLFLOW_LOG_LOGGED_MODEL_PARAMS_BATCH_SIZE,
MLFLOW_ASYNC_TRACE_LOGGING_RETRY_TIMEOUT,
)
from mlflow.exceptions import (
MlflowException,
MlflowNotImplementedException,
MlflowTraceDataCorrupted,
)
from mlflow.models import Model
from mlflow.protos.databricks_pb2 import ENDPOINT_NOT_FOUND, RESOURCE_DOES_NOT_EXIST
from mlflow.protos.service_pb2 import (
AddDatasetToExperiments,
AttachModelToGatewayEndpoint,
CalculateTraceFilterCorrelation,
CreateAssessment,
CreateDataset,
CreateGatewayEndpoint,
CreateGatewayEndpointBinding,
CreateGatewayModelDefinition,
CreateGatewaySecret,
CreateLoggedModel,
CreateRun,
DeleteDataset,
DeleteDatasetRecords,
DeleteDatasetTag,
DeleteExperiment,
DeleteGatewayEndpoint,
DeleteGatewayEndpointBinding,
DeleteGatewayModelDefinition,
DeleteGatewaySecret,
DeleteRun,
DeleteScorer,
DeleteTag,
DeleteTraces,
DetachModelFromGatewayEndpoint,
EndTrace,
GetDataset,
GetDatasetExperimentIds,
GetDatasetRecords,
GetExperimentByName,
GetGatewayEndpoint,
GetGatewayModelDefinition,
GetGatewaySecretInfo,
GetLoggedModel,
GetScorer,
GetTrace,
GetTraceInfoV3,
LinkPromptsToTrace,
ListGatewayEndpointBindings,
ListGatewayEndpoints,
ListGatewayModelDefinitions,
ListGatewaySecretInfos,
ListScorers,
ListScorerVersions,
LogBatch,
LogInputs,
LogLoggedModelParamsRequest,
LogMetric,
LogModel,
LogParam,
RegisterScorer,
RemoveDatasetFromExperiments,
RestoreExperiment,
RestoreRun,
SearchEvaluationDatasets,
SearchExperiments,
SearchRuns,
SearchTraces,
SearchTracesV3,
SetDatasetTags,
SetExperimentTag,
SetTag,
SetTraceTag,
StartTrace,
StartTraceV3,
UpdateGatewayEndpoint,
UpdateGatewayModelDefinition,
UpdateGatewaySecret,
UpsertDatasetRecords,
)
from mlflow.protos.service_pb2 import (
FallbackConfig as ProtoFallbackConfig,
)
from mlflow.protos.service_pb2 import (
GatewayEndpointModelConfig as ProtoGatewayEndpointModelConfig,
)
from mlflow.protos.service_pb2 import RunTag as ProtoRunTag
from mlflow.protos.service_pb2 import TraceRequestMetadata as ProtoTraceRequestMetadata
from mlflow.protos.service_pb2 import TraceTag as ProtoTraceTag
from mlflow.store.tracking.rest_store import RestStore
from mlflow.tracing.analysis import TraceFilterCorrelationResult
from mlflow.tracing.constant import TRACE_SCHEMA_VERSION_KEY
from mlflow.tracking.request_header.default_request_header_provider import (
DefaultRequestHeaderProvider,
)
from mlflow.utils.mlflow_tags import MLFLOW_ARTIFACT_LOCATION
from mlflow.utils.proto_json_utils import message_to_json
from mlflow.utils.rest_utils import (
_V3_ISSUES_REST_API_PATH_PREFIX,
_V3_TRACE_REST_API_PATH_PREFIX,
MlflowHostCreds,
get_logged_model_endpoint,
)
from mlflow.utils.workspace_context import WorkspaceContext, get_request_workspace
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
from tests.tracing.helper import create_mock_otel_span
@pytest.fixture(autouse=True, params=[False, True], ids=["workspace-disabled", "workspace-enabled"])
def workspaces_enabled(request):
"""
Run every test in this module with workspaces disabled and enabled to cover both code paths.
"""
# Ensure server version cache doesn't leak between parameter variants
RestStore._get_server_version.cache_clear()
enabled = request.param
if enabled:
with (
WorkspaceContext(DEFAULT_WORKSPACE_NAME),
mock.patch(
"mlflow.store.workspace_rest_store_mixin.WorkspaceRestStoreMixin.supports_workspaces",
return_value=True,
),
):
yield enabled
else:
yield enabled
class MyCoolException(Exception):
pass
class CustomErrorHandlingRestStore(RestStore):
def _call_endpoint(self, api, json_body):
raise MyCoolException("cool")
def mock_http_request():
return mock.patch(
"mlflow.utils.rest_utils.http_request",
return_value=mock.MagicMock(status_code=200, text="{}"),
)
def test_successful_http_request():
def mock_request(*args, **kwargs):
# Filter out None arguments
assert args == ("POST", "https://hello/api/2.0/mlflow/experiments/search")
kwargs = {k: v for k, v in kwargs.items() if v is not None}
headers = DefaultRequestHeaderProvider().request_headers()
if workspace := get_request_workspace():
headers["X-MLFLOW-WORKSPACE"] = workspace
assert kwargs == {
"allow_redirects": True,
"json": {"view_type": "ACTIVE_ONLY"},
"headers": headers,
"verify": True,
"timeout": 120,
}
response = mock.MagicMock()
response.status_code = 200
response.text = '{"experiments": [{"name": "Exp!", "lifecycle_stage": "active"}]}'
return response
with mock.patch("requests.Session.request", side_effect=mock_request):
store = RestStore(lambda: MlflowHostCreds("https://hello"))
experiments = store.search_experiments()
assert experiments[0].name == "Exp!"
def test_failed_http_request():
response = mock.MagicMock()
response.status_code = 404
response.text = '{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": "No experiment"}'
with mock.patch("requests.Session.request", return_value=response):
store = RestStore(lambda: MlflowHostCreds("https://hello"))
with pytest.raises(MlflowException, match="RESOURCE_DOES_NOT_EXIST: No experiment"):
store.search_experiments()
def test_failed_http_request_custom_handler():
response = mock.MagicMock()
response.status_code = 404
response.text = '{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": "No experiment"}'
with mock.patch("requests.Session.request", return_value=response):
store = CustomErrorHandlingRestStore(lambda: MlflowHostCreds("https://hello"))
with pytest.raises(MyCoolException, match="cool"):
store.search_experiments()
def test_response_with_unknown_fields():
experiment_json = {
"experiment_id": "1",
"name": "My experiment",
"artifact_location": "foo",
"lifecycle_stage": "deleted",
"OMG_WHAT_IS_THIS_FIELD": "Hooly cow",
}
response = mock.MagicMock()
response.status_code = 200
experiments = {"experiments": [experiment_json]}
response.text = json.dumps(experiments)
with mock.patch("requests.Session.request", return_value=response):
store = RestStore(lambda: MlflowHostCreds("https://hello"))
experiments = store.search_experiments()
assert len(experiments) == 1
assert experiments[0].name == "My experiment"
def _args(host_creds, endpoint, method, json_body, use_v3=False, retry_timeout_seconds=None):
version = "3.0" if use_v3 else "2.0"
res = {
"host_creds": host_creds,
"endpoint": f"/api/{version}/mlflow/{endpoint}",
"method": method,
}
if retry_timeout_seconds is not None:
res["retry_timeout_seconds"] = retry_timeout_seconds
if method == "GET":
res["params"] = json.loads(json_body)
else:
res["json"] = json.loads(json_body)
return res
def _verify_requests(
http_request, host_creds, endpoint, method, json_body, use_v3=False, retry_timeout_seconds=None
):
"""
Verify HTTP requests in tests.
Args:
http_request: The mocked HTTP request object
host_creds: MlflowHostCreds object
endpoint: The endpoint being called (e.g., "traces/123")
method: The HTTP method (e.g., "GET", "POST")
json_body: The request body as a JSON string
use_v3: If True, verify using /api/3.0/mlflow/ prefix instead of /api/2.0/mlflow/
This is used for trace-related endpoints that use the V3 API.
retry_timeout_seconds: The retry timeout seconds to use for the request
"""
http_request.assert_any_call(
**(_args(host_creds, endpoint, method, json_body, use_v3, retry_timeout_seconds))
)
def test_requestor():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
user_name = "mock user"
source_name = "rest test"
run_name = "my name"
source_name_patch = mock.patch(
"mlflow.tracking.context.default_context._get_source_name", return_value=source_name
)
source_type_patch = mock.patch(
"mlflow.tracking.context.default_context._get_source_type",
return_value=SourceType.LOCAL,
)
with (
mock_http_request() as mock_http,
mock.patch("mlflow.tracking._tracking_service.utils._get_store", return_value=store),
mock.patch("mlflow.tracking.context.default_context._get_user", return_value=user_name),
mock.patch("time.time", return_value=13579),
source_name_patch,
source_type_patch,
):
with mlflow.start_run(experiment_id="43", run_name=run_name):
cr_body = message_to_json(
CreateRun(
experiment_id="43",
user_id=user_name,
run_name=run_name,
start_time=13579000,
tags=[
ProtoRunTag(key="mlflow.source.name", value=source_name),
ProtoRunTag(key="mlflow.source.type", value="LOCAL"),
ProtoRunTag(key="mlflow.user", value=user_name),
ProtoRunTag(key="mlflow.runName", value=run_name),
],
)
)
expected_kwargs = _args(creds, "runs/create", "POST", cr_body)
assert mock_http.call_count == 1
actual_kwargs = mock_http.call_args[1]
# Test the passed tag values separately from the rest of the request
# Tag order is inconsistent on Python 2 and 3, but the order does not matter
expected_tags = expected_kwargs["json"].pop("tags")
actual_tags = actual_kwargs["json"].pop("tags")
assert sorted(expected_tags, key=lambda t: t["key"]) == sorted(
actual_tags, key=lambda t: t["key"]
)
assert expected_kwargs == actual_kwargs
with mock_http_request() as mock_http:
store.log_param("some_uuid", Param("k1", "v1"))
body = message_to_json(
LogParam(run_uuid="some_uuid", run_id="some_uuid", key="k1", value="v1")
)
_verify_requests(mock_http, creds, "runs/log-parameter", "POST", body)
with mock_http_request() as mock_http:
store.set_experiment_tag("some_id", ExperimentTag("t1", "abcd" * 1000))
body = message_to_json(
SetExperimentTag(experiment_id="some_id", key="t1", value="abcd" * 1000)
)
_verify_requests(mock_http, creds, "experiments/set-experiment-tag", "POST", body)
with mock_http_request() as mock_http:
store.set_tag("some_uuid", RunTag("t1", "abcd" * 1000))
body = message_to_json(
SetTag(run_uuid="some_uuid", run_id="some_uuid", key="t1", value="abcd" * 1000)
)
_verify_requests(mock_http, creds, "runs/set-tag", "POST", body)
with mock_http_request() as mock_http:
store.delete_tag("some_uuid", "t1")
body = message_to_json(DeleteTag(run_id="some_uuid", key="t1"))
_verify_requests(mock_http, creds, "runs/delete-tag", "POST", body)
with mock_http_request() as mock_http:
store.log_metric("u2", Metric("m1", 0.87, 12345, 3))
body = message_to_json(
LogMetric(run_uuid="u2", run_id="u2", key="m1", value=0.87, timestamp=12345, step=3)
)
_verify_requests(mock_http, creds, "runs/log-metric", "POST", body)
with mock_http_request() as mock_http:
metrics = [
Metric("m1", 0.87, 12345, 0),
Metric("m2", 0.49, 12345, -1),
Metric("m3", 0.58, 12345, 2),
]
params = [Param("p1", "p1val"), Param("p2", "p2val")]
tags = [RunTag("t1", "t1val"), RunTag("t2", "t2val")]
store.log_batch(run_id="u2", metrics=metrics, params=params, tags=tags)
metric_protos = [metric.to_proto() for metric in metrics]
param_protos = [param.to_proto() for param in params]
tag_protos = [tag.to_proto() for tag in tags]
body = message_to_json(
LogBatch(run_id="u2", metrics=metric_protos, params=param_protos, tags=tag_protos)
)
_verify_requests(mock_http, creds, "runs/log-batch", "POST", body)
with mock_http_request() as mock_http:
dataset = Dataset(name="name", digest="digest", source_type="st", source="source")
tag = InputTag(key="k1", value="v1")
dataset_input = DatasetInput(dataset=dataset, tags=[tag])
store.log_inputs("some_uuid", [dataset_input])
body = message_to_json(LogInputs(run_id="some_uuid", datasets=[dataset_input.to_proto()]))
_verify_requests(mock_http, creds, "runs/log-inputs", "POST", body)
with mock_http_request() as mock_http:
store.delete_run("u25")
_verify_requests(
mock_http, creds, "runs/delete", "POST", message_to_json(DeleteRun(run_id="u25"))
)
with mock_http_request() as mock_http:
store.restore_run("u76")
_verify_requests(
mock_http, creds, "runs/restore", "POST", message_to_json(RestoreRun(run_id="u76"))
)
with mock_http_request() as mock_http:
store.delete_experiment("0")
_verify_requests(
mock_http,
creds,
"experiments/delete",
"POST",
message_to_json(DeleteExperiment(experiment_id="0")),
)
with mock_http_request() as mock_http:
store.restore_experiment("0")
_verify_requests(
mock_http,
creds,
"experiments/restore",
"POST",
message_to_json(RestoreExperiment(experiment_id="0")),
)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
response = mock.MagicMock()
response.status_code = 200
response.text = '{"runs": ["1a", "2b", "3c"], "next_page_token": "67890fghij"}'
mock_http.return_value = response
result = store.search_runs(
["0", "1"],
"params.p1 = 'a'",
ViewType.ACTIVE_ONLY,
max_results=10,
order_by=["a"],
page_token="12345abcde",
)
expected_message = SearchRuns(
experiment_ids=["0", "1"],
filter="params.p1 = 'a'",
run_view_type=ViewType.to_proto(ViewType.ACTIVE_ONLY),
max_results=10,
order_by=["a"],
page_token="12345abcde",
)
_verify_requests(mock_http, creds, "runs/search", "POST", message_to_json(expected_message))
assert result.token == "67890fghij"
with mock_http_request() as mock_http:
run_id = "run_id"
m = Model(artifact_path="model/path", run_id="run_id", flavors={"tf": "flavor body"})
store.record_logged_model("run_id", m)
expected_message = LogModel(run_id=run_id, model_json=json.dumps(m.get_tags_dict()))
_verify_requests(
mock_http, creds, "runs/log-model", "POST", message_to_json(expected_message)
)
# if model has config, it should be removed from the model_json before sending to the server
with mock_http_request() as mock_http:
run_id = "run_id"
flavors_with_config = {
"tf": "flavor body",
"python_function": {"config": {"a": 1}, "code": "code"},
}
m_with_config = Model(
artifact_path="model/path", run_id="run_id", flavors=flavors_with_config
)
store.record_logged_model("run_id", m_with_config)
flavors = m_with_config.get_tags_dict().get("flavors", {})
assert all("config" not in v for v in flavors.values())
expected_message = LogModel(
run_id=run_id, model_json=json.dumps(m_with_config.get_tags_dict())
)
_verify_requests(
mock_http,
creds,
"runs/log-model",
"POST",
message_to_json(expected_message),
)
with mock_http_request() as mock_http:
trace_id = "tr-123"
store.get_trace_info(trace_id)
# Verify the V3 API was called
v3_expected_message = GetTraceInfoV3(trace_id=trace_id)
_verify_requests(
mock_http,
creds,
"traces/tr-123",
"GET",
message_to_json(v3_expected_message),
use_v3=True,
)
def test_get_experiment_by_name():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
response = mock.MagicMock()
response.status_code = 200
experiment = Experiment(
experiment_id="123",
name="abc",
artifact_location="/abc",
lifecycle_stage=LifecycleStage.ACTIVE,
effective_trace_archival_retention="30d",
)
response.text = json.dumps({
"experiment": json.loads(message_to_json(experiment.to_proto()))
})
mock_http.return_value = response
result = store.get_experiment_by_name("abc")
expected_message0 = GetExperimentByName(experiment_name="abc")
_verify_requests(
mock_http,
creds,
"experiments/get-by-name",
"GET",
message_to_json(expected_message0),
)
assert result.experiment_id == experiment.experiment_id
assert result.name == experiment.name
assert result.artifact_location == experiment.artifact_location
assert result.lifecycle_stage == experiment.lifecycle_stage
assert result.effective_trace_archival_retention == "30d"
# Test GetExperimentByName against nonexistent experiment
mock_http.reset_mock()
nonexistent_exp_response = mock.MagicMock()
nonexistent_exp_response.status_code = 404
nonexistent_exp_response.text = MlflowException(
"Exp doesn't exist!", RESOURCE_DOES_NOT_EXIST
).serialize_as_json()
mock_http.return_value = nonexistent_exp_response
assert store.get_experiment_by_name("nonexistent-experiment") is None
expected_message1 = GetExperimentByName(experiment_name="nonexistent-experiment")
_verify_requests(
mock_http,
creds,
"experiments/get-by-name",
"GET",
message_to_json(expected_message1),
)
assert mock_http.call_count == 1
def test_search_experiments():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.search_experiments(
view_type=ViewType.DELETED_ONLY,
max_results=5,
filter_string="name",
order_by=["name"],
page_token="abc",
)
_verify_requests(
mock_http,
creds,
"experiments/search",
"POST",
message_to_json(
SearchExperiments(
view_type=ViewType.DELETED_ONLY,
max_results=5,
filter="name",
order_by=["name"],
page_token="abc",
)
),
)
def _mock_response_with_200_status_code():
mock_response = mock.MagicMock()
mock_response.status_code = 200
return mock_response
def test_get_metric_history_paginated():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response_1 = _mock_response_with_200_status_code()
response_2 = _mock_response_with_200_status_code()
response_payload_1 = {
"metrics": [
{"key": "a_metric", "value": 42, "timestamp": 123456777, "step": 0},
{"key": "a_metric", "value": 46, "timestamp": 123456797, "step": 1},
],
"next_page_token": "AcursorForTheRestofTheData",
}
response_1.text = json.dumps(response_payload_1)
response_payload_2 = {
"metrics": [
{"key": "a_metric", "value": 40, "timestamp": 123456877, "step": 2},
{"key": "a_metric", "value": 56, "timestamp": 123456897, "step": 3},
],
"next_page_token": "",
}
response_2.text = json.dumps(response_payload_2)
with mock.patch(
"requests.Session.request", side_effect=[response_1, response_2]
) as mock_request:
# Fetch the first page
metrics = store.get_metric_history(
run_id="2", metric_key="a_metric", max_results=2, page_token=None
)
mock_request.assert_called_once()
assert mock_request.call_args.kwargs["params"] == {
"max_results": 2,
"metric_key": "a_metric",
"run_id": "2",
"run_uuid": "2",
}
assert len(metrics) == 2
assert metrics[0] == Metric(key="a_metric", value=42, timestamp=123456777, step=0)
assert metrics[1] == Metric(key="a_metric", value=46, timestamp=123456797, step=1)
assert metrics.token == "AcursorForTheRestofTheData"
# Fetch the second page
mock_request.reset_mock()
metrics = store.get_metric_history(
run_id="2", metric_key="a_metric", max_results=2, page_token=metrics.token
)
mock_request.assert_called_once()
assert mock_request.call_args.kwargs["params"] == {
"max_results": 2,
"page_token": "AcursorForTheRestofTheData",
"metric_key": "a_metric",
"run_id": "2",
"run_uuid": "2",
}
assert len(metrics) == 2
assert metrics[0] == Metric(key="a_metric", value=40, timestamp=123456877, step=2)
assert metrics[1] == Metric(key="a_metric", value=56, timestamp=123456897, step=3)
assert metrics.token is None
def test_get_metric_history_on_non_existent_metric_key():
creds = MlflowHostCreds("https://hello")
rest_store = RestStore(lambda: creds)
empty_metric_response = _mock_response_with_200_status_code()
empty_metric_response.text = json.dumps({})
with mock.patch(
"requests.Session.request", side_effect=[empty_metric_response]
) as mock_request:
metrics = rest_store.get_metric_history(run_id="1", metric_key="test_metric")
mock_request.assert_called_once()
assert metrics == []
def test_deprecated_start_trace_v2():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
request_id = "tr-123"
experiment_id = "447585625682310"
timestamp_ms = 123
# Metadata/tags values should be string, but should not break for other types too
metadata = {"key1": "val1", "key2": "val2", "key3": 123, TRACE_SCHEMA_VERSION_KEY: "2"}
tags = {"tag1": "tv1", "tag2": "tv2", "tag3": None}
expected_request = StartTrace(
experiment_id=experiment_id,
timestamp_ms=123,
request_metadata=[
ProtoTraceRequestMetadata(key=k, value=str(v)) for k, v in metadata.items()
],
tags=[ProtoTraceTag(key=k, value=str(v)) for k, v in tags.items()],
)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"trace_info": {
"request_id": request_id,
"experiment_id": experiment_id,
"timestamp_ms": timestamp_ms,
"execution_time_ms": None,
"status": 0, # Running
"request_metadata": [{"key": k, "value": str(v)} for k, v in metadata.items()],
"tags": [{"key": k, "value": str(v)} for k, v in tags.items()],
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.deprecated_start_trace_v2(
experiment_id=experiment_id,
timestamp_ms=timestamp_ms,
request_metadata=metadata,
tags=tags,
)
_verify_requests(mock_http, creds, "traces", "POST", message_to_json(expected_request))
assert isinstance(res, TraceInfoV2)
assert res.request_id == request_id
assert res.experiment_id == experiment_id
assert res.timestamp_ms == timestamp_ms
assert res.execution_time_ms == 0
assert res.status == TraceStatus.UNSPECIFIED
assert res.request_metadata == {k: str(v) for k, v in metadata.items()}
assert res.tags == {k: str(v) for k, v in tags.items()}
def test_start_trace(monkeypatch):
monkeypatch.setenv(MLFLOW_ASYNC_TRACE_LOGGING_RETRY_TIMEOUT.name, "1")
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
trace = Trace(
info=TraceInfo(
trace_id="tr-123",
trace_location=TraceLocation.from_experiment_id("123"),
request_time=123,
execution_duration=10,
state=TraceState.OK,
request_preview="",
response_preview="",
trace_metadata={},
),
data=TraceData(),
)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
expected_request = StartTraceV3(trace=trace.to_proto())
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.start_trace(trace.info)
_verify_requests(
mock_http,
creds,
"traces",
"POST",
message_to_json(expected_request),
use_v3=True,
retry_timeout_seconds=1,
)
def test_deprecated_end_trace_v2():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
experiment_id = "447585625682310"
request_id = "tr-123"
timestamp_ms = 123
status = TraceStatus.OK
metadata = {"key1": "val1", "key2": "val2", TRACE_SCHEMA_VERSION_KEY: "2"}
tags = {"tag1": "tv1", "tag2": "tv2"}
expected_request = EndTrace(
request_id=request_id,
timestamp_ms=123,
status=status,
request_metadata=[ProtoTraceRequestMetadata(key=k, value=v) for k, v in metadata.items()],
tags=[ProtoTraceTag(key=k, value=v) for k, v in tags.items()],
)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"trace_info": {
"request_id": request_id,
"experiment_id": experiment_id,
"timestamp_ms": timestamp_ms,
"execution_time_ms": 12345,
"status": 1, # OK
"request_metadata": [{"key": k, "value": v} for k, v in metadata.items()],
"tags": [{"key": k, "value": v} for k, v in tags.items()],
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.deprecated_end_trace_v2(
request_id=request_id,
timestamp_ms=timestamp_ms,
status=status,
request_metadata=metadata,
tags=tags,
)
_verify_requests(
mock_http,
creds,
f"traces/{request_id}",
"PATCH",
message_to_json(expected_request),
use_v3=False,
)
assert isinstance(res, TraceInfoV2)
assert res.request_id == request_id
assert res.experiment_id == experiment_id
assert res.timestamp_ms == timestamp_ms
assert res.execution_time_ms == 12345
assert res.status == TraceStatus.OK
assert res.request_metadata == metadata
assert res.tags == tags
def test_search_traces():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
# Format the response
response.text = json.dumps({
"traces": [
{
"trace_id": "tr-1234",
"trace_location": {
"type": "MLFLOW_EXPERIMENT",
"mlflow_experiment": {"experiment_id": "1234"},
},
"request_time": "1970-01-01T00:00:00.123Z",
"execution_duration_ms": 456,
"state": "OK",
"trace_metadata": {"key": "value"},
"tags": {"k": "v"},
}
],
"next_page_token": "token",
})
# Parameters for search_traces
experiment_ids = ["1234"]
filter_string = "state = 'OK'"
max_results = 10
order_by = ["request_time DESC"]
page_token = "12345abcde"
# Test with databricks tracking URI (using v3 endpoint)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
trace_infos, token = store.search_traces(
locations=experiment_ids,
filter_string=filter_string,
max_results=max_results,
order_by=order_by,
page_token=page_token,
)
# Verify the correct endpoint was called
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V3_TRACE_REST_API_PATH_PREFIX}/search"
# Verify the correct parameters were passed
json_body = call_args["json"]
# The field name should now be 'locations' instead of 'trace_locations'
assert "locations" in json_body
# The experiment_ids are converted to trace_locations
assert len(json_body["locations"]) == 1
assert json_body["locations"][0]["mlflow_experiment"]["experiment_id"] == experiment_ids[0]
assert json_body["filter"] == filter_string
assert json_body["max_results"] == max_results
assert json_body["order_by"] == order_by
assert json_body["page_token"] == page_token
# Verify the correct parameters were passed and the correct trace info objects were returned
# for either endpoint
assert len(trace_infos) == 1
assert isinstance(trace_infos[0], TraceInfo)
assert trace_infos[0].trace_id == "tr-1234"
assert trace_infos[0].experiment_id == "1234"
assert trace_infos[0].request_time == 123
# V3's state maps to V2's status
assert trace_infos[0].state == TraceStatus.OK.to_state()
# This is correct because TraceInfoV3.from_proto converts the repeated field tags to a dict
assert trace_infos[0].tags == {"k": "v"}
assert trace_infos[0].trace_metadata == {"key": "value"}
assert token == "token"
def test_search_traces_errors():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with pytest.raises(
MlflowException,
match="Locations must be a list of experiment IDs",
):
store.search_traces(locations=["catalog.schema"])
with pytest.raises(
MlflowException,
match="Searching traces by model_id is not supported on the current tracking server.",
):
store.search_traces(model_id="model_id")
def test_search_traces_v3_endpoint_not_found_falls_back_to_v2():
store = RestStore(lambda: MlflowHostCreds("https://hello"))
v2_response = mock.MagicMock()
v2_response.traces = []
v2_response.next_page_token = ""
endpoint_not_found = MlflowException("Not found", error_code=ENDPOINT_NOT_FOUND)
with mock.patch.object(
store, "_call_endpoint", side_effect=[endpoint_not_found, v2_response]
) as mock_endpoint:
trace_infos, token = store._search_traces(
locations=["123", "456"],
filter_string="state = 'OK'",
max_results=10,
order_by=["request_time DESC"],
page_token="abc",
)
assert mock_endpoint.call_count == 2
# First call must target the V3 endpoint
v3_call = mock_endpoint.call_args_list[0]
assert v3_call.args[0] is SearchTracesV3
# Second call must use the V2 body with experiment_ids, not locations
v2_call = mock_endpoint.call_args_list[1]
assert v2_call.args[0] is SearchTraces
v2_body = json.loads(v2_call.args[1])
assert v2_body == {
"experiment_ids": ["123", "456"],
"filter": "state = 'OK'",
"max_results": 10,
"order_by": ["request_time DESC"],
"page_token": "abc",
}
assert trace_infos == []
assert token is None
def test_get_artifact_uri_for_trace_compatibility():
from mlflow.tracing.constant import TraceTagKey
from mlflow.tracing.utils.artifact_utils import (
get_archive_uri_for_trace,
get_artifact_uri_for_trace,
)
# Create a TraceInfo (v2) object
trace_info_v2 = TraceInfoV2(
request_id="tr-1234",
experiment_id="1234",
timestamp_ms=123,
execution_time_ms=456,
status=TraceStatus.OK,
request_metadata={"key": "value"},
tags={MLFLOW_ARTIFACT_LOCATION: "s3://bucket/trace-v2-path"},
)
# Create a TraceInfoV3 object
trace_location = TraceLocation.from_experiment_id("5678")
trace_info_v3 = TraceInfo(
trace_id="tr-5678",
trace_location=trace_location,
request_time=789,
state=TraceState.OK,
trace_metadata={"key3": "value3"},
tags={MLFLOW_ARTIFACT_LOCATION: "s3://bucket/trace-v3-path"},
)
# Test that get_artifact_uri_for_trace works with TraceInfo (v2)
v2_uri = get_artifact_uri_for_trace(trace_info_v2)
assert v2_uri == "s3://bucket/trace-v2-path"
# Test that get_artifact_uri_for_trace works with TraceInfoV3
v3_uri = get_artifact_uri_for_trace(trace_info_v3)
assert v3_uri == "s3://bucket/trace-v3-path"
archive_trace_info_v3 = TraceInfo(
trace_id="tr-9012",
trace_location=trace_location,
request_time=789,
state=TraceState.OK,
trace_metadata={"key3": "value3"},
tags={TraceTagKey.ARCHIVE_LOCATION: "s3://bucket/trace-v3-archive"},
)
archive_uri = get_archive_uri_for_trace(archive_trace_info_v3)
assert archive_uri == "s3://bucket/trace-v3-archive"
# Test that get_artifact_uri_for_trace raises the expected exception when tag is missing
trace_info_no_tag = TraceInfoV2(
request_id="tr-1234",
experiment_id="1234",
timestamp_ms=123,
execution_time_ms=456,
status=TraceStatus.OK,
tags={},
)
with pytest.raises(
MlflowTraceDataCorrupted,
match="Trace data is corrupted for request_id=tr-1234",
):
get_artifact_uri_for_trace(trace_info_no_tag)
with pytest.raises(
MlflowTraceDataCorrupted,
match="Trace data is corrupted for request_id=tr-1234",
):
get_archive_uri_for_trace(trace_info_no_tag)
@pytest.mark.parametrize(
"delete_traces_kwargs",
[
{"experiment_id": "0", "request_ids": ["tr-1234"]},
{"experiment_id": "0", "max_timestamp_millis": 1, "max_traces": 2},
],
)
def test_delete_traces(delete_traces_kwargs):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
request = DeleteTraces(**delete_traces_kwargs)
response.text = json.dumps({"traces_deleted": 1})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
call_kwargs = delete_traces_kwargs.copy()
if "request_ids" in call_kwargs:
call_kwargs["trace_ids"] = call_kwargs.pop("request_ids")
res = store.delete_traces(**call_kwargs)
_verify_requests(mock_http, creds, "traces/delete-traces", "POST", message_to_json(request))
assert res == 1
def test_delete_traces_with_batching():
from mlflow.environment_variables import _MLFLOW_DELETE_TRACES_MAX_BATCH_SIZE
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
# Create 250 trace IDs to test batching (should create 3 batches: 100, 100, 50)
num_traces = 250
trace_ids = [f"tr-{i}" for i in range(num_traces)]
# Each batch returns some number of deleted traces
response.text = json.dumps({"traces_deleted": 100})
batch_size = _MLFLOW_DELETE_TRACES_MAX_BATCH_SIZE.get()
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.delete_traces(experiment_id="0", trace_ids=trace_ids)
# Verify that we made 3 API calls (250 / 100 = 3 batches)
expected_num_calls = math.ceil(num_traces / batch_size)
assert mock_http.call_count == expected_num_calls
# Verify that batch sizes are [100, 100, 50]
batch_sizes = [len(call[1]["json"]["request_ids"]) for call in mock_http.call_args_list]
assert batch_sizes == [100, 100, 50]
def test_set_trace_tag():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "tr-1234"
request = SetTraceTag(
key="k",
value="v",
)
response.text = "{}"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.set_trace_tag(
trace_id=trace_id,
key=request.key,
value=request.value,
)
_verify_requests(
mock_http,
creds,
f"traces/{trace_id}/tags",
"PATCH",
message_to_json(request),
use_v3=False,
)
assert res is None
@pytest.mark.parametrize("is_databricks", [True, False])
def test_log_assessment_feedback(is_databricks):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"assessment": {
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_id": "tr-1234",
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"feedback": {"value": True},
"rationale": "rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
}
})
feedback = Feedback(
trace_id="tr-1234",
name="assessment_name",
value=True,
source=AssessmentSource(
source_type=AssessmentSourceType.LLM_JUDGE, source_id="gpt-4o-mini"
),
create_time_ms=int(time.time() * 1000),
last_update_time_ms=int(time.time() * 1000),
rationale="rationale",
metadata={"model": "gpt-4o-mini"},
span_id=None,
)
request = CreateAssessment(assessment=feedback.to_proto())
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.create_assessment(feedback)
_verify_requests(
mock_http,
creds,
"traces/tr-1234/assessments",
"POST",
message_to_json(request),
use_v3=True,
)
assert isinstance(res, Feedback)
assert res.assessment_id is not None
assert res.value == feedback.value
@pytest.mark.parametrize("is_databricks", [True, False])
def test_log_assessment_expectation(is_databricks):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"assessment": {
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_id": "tr-1234",
"source": {
"source_type": "HUMAN",
"source_id": "me",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"expectation": {
"serialized_value": {
"value": '{"key1": "value1", "key2": "value2"}',
"serialization_format": "JSON_FORMAT",
}
},
"error": None,
"span_id": None,
}
})
expectation = Expectation(
trace_id="tr-1234",
name="assessment_name",
value={"key1": "value1", "key2": "value2"},
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN, source_id="me"),
create_time_ms=int(time.time() * 1000),
last_update_time_ms=int(time.time() * 1000),
span_id=None,
)
request = CreateAssessment(assessment=expectation.to_proto())
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.create_assessment(expectation)
_verify_requests(
mock_http,
creds,
"traces/tr-1234/assessments",
"POST",
message_to_json(request),
use_v3=True,
)
assert isinstance(res, Expectation)
assert res.assessment_id is not None
assert res.value == expectation.value
@pytest.mark.parametrize("is_databricks", [True, False])
@pytest.mark.parametrize(
("updates", "expected_request_json"),
[
(
{"name": "updated_name"},
{
"assessment": {
"assessment_id": "1234",
"trace_id": "tr-1234",
"assessment_name": "updated_name",
},
"update_mask": "assessmentName",
},
),
(
{"expectation": ExpectationValue(value="updated_value")},
{
"assessment": {
"assessment_id": "1234",
"trace_id": "tr-1234",
"expectation": {"value": "updated_value"},
},
"update_mask": "expectation",
},
),
(
{
"feedback": FeedbackValue(value=0.5),
"rationale": "update",
"metadata": {"model": "gpt-4o-mini"},
},
{
"assessment": {
"assessment_id": "1234",
"trace_id": "tr-1234",
"feedback": {"value": 0.5},
"rationale": "update",
"metadata": {"model": "gpt-4o-mini"},
},
"update_mask": "feedback,rationale,metadata",
},
),
],
)
def test_update_assessment(updates, expected_request_json, is_databricks):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"assessment": {
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_id": "tr-1234",
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-25T01:23:45Z",
"feedback": {"value": True},
"rationale": "rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.update_assessment(
trace_id="tr-1234",
assessment_id="1234",
**updates,
)
_verify_requests(
mock_http,
creds,
"traces/tr-1234/assessments/1234",
"PATCH",
json.dumps(expected_request_json),
use_v3=True,
)
assert isinstance(res, Assessment)
@pytest.mark.parametrize("is_databricks", [True, False])
def test_get_assessment(is_databricks):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"assessment": {
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_id": "tr-1234",
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-25T01:23:45Z",
"feedback": {"value": "test value"},
"rationale": "rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.get_assessment(trace_id="tr-1234", assessment_id="1234")
expected_request_json = {"assessment_id": "1234", "trace_id": "tr-1234"}
_verify_requests(
mock_http,
creds,
"traces/tr-1234/assessments/1234",
"GET",
json.dumps(expected_request_json),
use_v3=True,
)
assert isinstance(res, Feedback)
assert res.assessment_id == "1234"
assert res.name == "assessment_name"
assert res.trace_id == "tr-1234"
assert res.source.source_type == AssessmentSourceType.LLM_JUDGE
assert res.source.source_id == "gpt-4o-mini"
assert res.value == "test value"
@pytest.mark.parametrize("is_databricks", [True, False])
def test_delete_assessment(is_databricks):
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = "{}"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.delete_assessment(trace_id="tr-1234", assessment_id="1234")
expected_request_json = {"assessment_id": "1234", "trace_id": "tr-1234"}
_verify_requests(
mock_http,
creds,
"traces/tr-1234/assessments/1234",
"DELETE",
json.dumps(expected_request_json),
use_v3=True,
)
def test_update_assessment_invalid_update():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with pytest.raises(MlflowException, match="Exactly one of `expectation` or `feedback`"):
store.update_assessment(
trace_id="tr-1234",
assessment_id="1234",
expectation=ExpectationValue(value="updated_value"),
feedback=FeedbackValue(value=0.5),
)
def test_get_trace_info():
# Generate a sample trace in v3 format
with mlflow.start_span(name="test_span") as span:
span.set_inputs({"input": "value"})
span.set_outputs({"output": "value"})
trace = mlflow.get_trace(span.trace_id)
trace.info.trace_metadata = {"key1": "value1"}
trace.info.tags = {"tag1": "value1"}
trace.info.assessments = [
Feedback(name="feedback", value=0.9, trace_id=span.trace_id),
Feedback(
name="feedback_error",
value=None,
error=AssessmentError(error_code="500", error_message="error message"),
trace_id=span.trace_id,
),
Expectation(name="expectation", value=True, trace_id=span.trace_id, span_id=span.span_id),
Expectation(
name="complex_expectation",
value={"complex": [{"key": "value"}]},
source=AssessmentSource(
source_type=AssessmentSourceType.LLM_JUDGE, source_id="gpt-4o-mini"
),
trace_id=span.trace_id,
),
]
trace_proto = trace.to_proto()
mock_response = GetTraceInfoV3.Response(trace=trace_proto)
store = RestStore(lambda: MlflowHostCreds("https://hello"))
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
result = store.get_trace_info(span.trace_id)
# Verify we get the expected object back
assert isinstance(result, TraceInfo)
assert result.trace_id == span.trace_id
assert result.experiment_id == "0"
assert result.trace_metadata == {"key1": "value1"}
assert result.tags == {"tag1": "value1"}
assert result.state == TraceState.OK
assert len(result.assessments) == 4
assert result.assessments[0].name == "feedback"
assert result.assessments[1].name == "feedback_error"
assert result.assessments[2].name == "expectation"
assert result.assessments[3].name == "complex_expectation"
def test_get_trace():
# Generate a sample trace with spans
with mlflow.start_span(name="root_span") as span:
span.set_inputs({"input": "value"})
span.set_outputs({"output": "value"})
with mlflow.start_span(name="child_span") as child:
child.set_inputs({"child_input": "child_value"})
trace = mlflow.get_trace(span.trace_id)
trace_proto = trace.to_proto()
mock_response = GetTrace.Response(trace=trace_proto)
store = RestStore(lambda: MlflowHostCreds("https://hello"))
with mock.patch.object(store, "_call_endpoint", return_value=mock_response) as mock_call:
result = store.get_trace(span.trace_id, allow_partial=True)
# Verify we get the expected object back
assert isinstance(result, Trace)
assert result.info.trace_id == span.trace_id
assert len(result.data.spans) == 2
# Verify the endpoint was called with correct parameters
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == GetTrace
# Check the request body contains the trace_id and allow_partial
request_body_json = json.loads(call_args[0][1])
assert request_body_json["trace_id"] == span.trace_id
assert request_body_json["allow_partial"] is True
def test_get_trace_with_allow_partial_false():
# Generate a sample trace
with mlflow.start_span(name="test_span") as span:
span.set_inputs({"input": "value"})
trace = mlflow.get_trace(span.trace_id)
trace_proto = trace.to_proto()
mock_response = GetTrace.Response(trace=trace_proto)
store = RestStore(lambda: MlflowHostCreds("https://hello"))
with mock.patch.object(store, "_call_endpoint", return_value=mock_response) as mock_call:
result = store.get_trace(span.trace_id, allow_partial=False)
# Verify we get the expected object back
assert isinstance(result, Trace)
assert result.info.trace_id == span.trace_id
# Verify the endpoint was called with allow_partial=False
call_args = mock_call.call_args
request_body_json = json.loads(call_args[0][1])
assert request_body_json["allow_partial"] is False
def test_get_trace_handles_old_server_routing_conflict():
store = RestStore(lambda: MlflowHostCreds("https://hello"))
# Simulate old server returning "Trace with ID 'get' not found"
error_response = MlflowException(
"Trace with ID 'get' not found",
error_code=RESOURCE_DOES_NOT_EXIST,
)
with mock.patch.object(store, "_call_endpoint", side_effect=error_response):
with pytest.raises(MlflowNotImplementedException): # noqa: PT011
store.get_trace("some-trace-id")
def test_get_trace_raises_other_errors():
store = RestStore(lambda: MlflowHostCreds("https://hello"))
error_message = "Trace with ID 'abc123' not found"
genuine_error = MlflowException(
error_message,
error_code=RESOURCE_DOES_NOT_EXIST,
)
with mock.patch.object(store, "_call_endpoint", side_effect=genuine_error):
with pytest.raises(MlflowException, match=error_message):
store.get_trace("abc123")
def test_log_logged_model_params():
with mock.patch("mlflow.store.tracking.rest_store.call_endpoint") as mock_call_endpoint:
# Create test data
model_id = "model_123"
params = [
LoggedModelParameter(key=f"param_{i}", value=f"value_{i}")
for i in range(250) # Create enough params to test batching
]
batches = [
message_to_json(
LogLoggedModelParamsRequest(
model_id=model_id,
params=[p.to_proto() for p in params[:100]],
)
),
message_to_json(
LogLoggedModelParamsRequest(
model_id=model_id,
params=[p.to_proto() for p in params[100:200]],
)
),
message_to_json(
LogLoggedModelParamsRequest(
model_id=model_id,
params=[p.to_proto() for p in params[200:]],
)
),
]
store = RestStore(lambda: None)
store.log_logged_model_params(model_id=model_id, params=params)
# Verify call_endpoint was called with the correct arguments
assert mock_call_endpoint.call_count == 3
for i, call in enumerate(mock_call_endpoint.call_args_list):
_, endpoint, method, json_body, response_proto = call.args
# Verify endpoint and method are correct
assert endpoint, method == RestStore._METHOD_TO_INFO[LogLoggedModelParamsRequest]
assert json_body == batches[i]
@pytest.mark.parametrize(
("params_count", "expected_call_count", "create_batch_size", "log_batch_size"),
[
(None, 1, 100, 100), # None params - only CreateLoggedModel
(0, 1, 100, 100), # No params - only CreateLoggedModel
(5, 1, 100, 100), # Few params - only CreateLoggedModel
(100, 1, 100, 100), # Exactly 100 params - only CreateLoggedModel
(
150,
3,
100,
100,
), # 150 params - CreateLoggedModel + LogLoggedModelParamsRequest + GetLoggedModel
(
250,
4,
100,
100,
), # 250 params - CreateLoggedModel + 2 LogLoggedModelParamsRequest calls + GetLoggedModel
(
250,
3,
200,
100,
), # 250 params with larger create batch - CreateLoggedModel
# + 1 LogLoggedModelParamsRequest + GetLoggedModel
(
250,
5,
100,
50,
), # 250 params with smaller log batch - CreateLoggedModel
# + 4 LogLoggedModelParamsRequest calls + GetLoggedModel
],
)
def test_create_logged_models_with_params(
monkeypatch, params_count, expected_call_count, create_batch_size, log_batch_size
):
# Set environment variables using monkeypatch
monkeypatch.setenv(_MLFLOW_CREATE_LOGGED_MODEL_PARAMS_BATCH_SIZE.name, str(create_batch_size))
monkeypatch.setenv(_MLFLOW_LOG_LOGGED_MODEL_PARAMS_BATCH_SIZE.name, str(log_batch_size))
store = RestStore(lambda: None)
with (
mock.patch("mlflow.entities.logged_model.LoggedModel.from_proto") as mock_from_proto,
mock.patch.object(store, "_call_endpoint") as mock_call_endpoint,
):
# Setup mocks
mock_model = mock.MagicMock()
model_id = "model_123"
mock_model.model_id = model_id
mock_from_proto.return_value = mock_model
mock_response = mock.MagicMock()
mock_response.model = mock.MagicMock()
mock_call_endpoint.return_value = mock_response
# Create params
params = (
[LoggedModelParameter(key=f"key_{i}", value=f"value_{i}") for i in range(params_count)]
if params_count
else None
)
# Call the method
store.create_logged_model("experiment_id", params=params)
# Verify calls
endpoint = get_logged_model_endpoint(model_id)
# CreateLoggedModel should always be called
initial_params = [p.to_proto() for p in params[:create_batch_size]] if params else None
mock_call_endpoint.assert_any_call(
CreateLoggedModel,
message_to_json(
CreateLoggedModel(
experiment_id="experiment_id",
params=initial_params,
)
),
)
# If params > create_batch_size, additional calls should be made
if params_count and params_count > create_batch_size:
# LogLoggedModelParamsRequest should be called for remaining params
remaining_params = params[create_batch_size:]
for i in range(0, len(remaining_params), log_batch_size):
batch = remaining_params[i : i + log_batch_size]
mock_call_endpoint.assert_any_call(
LogLoggedModelParamsRequest,
json_body=message_to_json(
LogLoggedModelParamsRequest(
model_id=model_id,
params=[p.to_proto() for p in batch],
)
),
endpoint=f"{endpoint}/params",
)
# GetLoggedModel should be called to get the updated model
mock_call_endpoint.assert_any_call(GetLoggedModel, endpoint=endpoint)
# Verify total number of calls
assert mock_call_endpoint.call_count == expected_call_count
def test_create_evaluation_dataset():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
create_response = CreateDataset.Response()
create_response.dataset.dataset_id = "d-1234567890abcdef1234567890abcdef"
create_response.dataset.name = "test_dataset"
create_response.dataset.created_time = 1234567890
create_response.dataset.last_update_time = 1234567890
create_response.dataset.digest = "abc123"
create_response.dataset.tags = json.dumps({"env": "test"})
mock_call.side_effect = [create_response]
store.create_dataset(
name="test_dataset",
tags={"env": "test"},
experiment_ids=["0", "1"],
)
assert mock_call.call_count == 1
create_req = CreateDataset(
name="test_dataset",
experiment_ids=["0", "1"],
tags=json.dumps({"env": "test"}),
)
mock_call.assert_called_once_with(
CreateDataset,
message_to_json(create_req),
endpoint="/api/3.0/mlflow/datasets/create",
)
def test_create_dataset_without_experiment_ids():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
create_response = CreateDataset.Response()
create_response.dataset.dataset_id = "d-abcdef1234567890abcdef1234567890"
create_response.dataset.name = "test_dataset_no_exp"
create_response.dataset.created_time = 1234567890
create_response.dataset.last_update_time = 1234567890
create_response.dataset.digest = "xyz789"
mock_call.side_effect = [create_response]
store.create_dataset(
name="test_dataset_no_exp",
tags={"env": "prod"},
)
assert mock_call.call_count == 1
create_req = CreateDataset(
name="test_dataset_no_exp",
tags=json.dumps({"env": "prod"}),
)
mock_call.assert_called_once_with(
CreateDataset,
message_to_json(create_req),
endpoint="/api/3.0/mlflow/datasets/create",
)
def test_create_dataset_with_empty_experiment_ids():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
create_response = CreateDataset.Response()
create_response.dataset.dataset_id = "d-fedcba0987654321fedcba0987654321"
create_response.dataset.name = "test_dataset_empty"
create_response.dataset.created_time = 1234567890
create_response.dataset.last_update_time = 1234567890
create_response.dataset.digest = "empty123"
mock_call.side_effect = [create_response]
store.create_dataset(
name="test_dataset_empty",
experiment_ids=[],
tags={"env": "staging"},
)
assert mock_call.call_count == 1
create_req = CreateDataset(
name="test_dataset_empty",
experiment_ids=[],
tags=json.dumps({"env": "staging"}),
)
mock_call.assert_called_once_with(
CreateDataset,
message_to_json(create_req),
endpoint="/api/3.0/mlflow/datasets/create",
)
def test_get_evaluation_dataset():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = GetDataset.Response()
response.dataset.dataset_id = dataset_id
response.dataset.name = "test_dataset"
response.dataset.digest = "abc123"
response.dataset.created_time = 1234567890
response.dataset.last_update_time = 1234567890
mock_call.return_value = response
result = store.get_dataset(dataset_id)
assert result.dataset_id == dataset_id
assert result.name == "test_dataset"
mock_call.assert_called_once_with(
GetDataset,
None,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}",
)
def test_delete_evaluation_dataset():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.return_value = DeleteDataset.Response()
store.delete_dataset(dataset_id)
mock_call.assert_called_once_with(
DeleteDataset,
None,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}",
)
def test_search_evaluation_datasets():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.search_datasets(
experiment_ids=["0", "1"],
filter_string='name = "dataset1"',
max_results=10,
order_by=["name DESC"],
page_token="token123",
)
_verify_requests(
mock_http,
creds,
"datasets/search",
"POST",
message_to_json(
SearchEvaluationDatasets(
experiment_ids=["0", "1"],
filter_string='name = "dataset1"',
max_results=10,
order_by=["name DESC"],
page_token="token123",
)
),
use_v3=True,
)
def test_set_evaluation_dataset_tags():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
tags = {"env": "production", "version": "2.0", "deprecated": None}
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.return_value = mock.Mock()
store.set_dataset_tags(
dataset_id=dataset_id,
tags=tags,
)
req = SetDatasetTags(
tags=json.dumps(tags),
)
expected_json = message_to_json(req)
mock_call.assert_called_once_with(
SetDatasetTags,
expected_json,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/tags",
)
def test_delete_dataset_tag():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
key = "deprecated_tag"
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.return_value = mock.Mock()
store.delete_dataset_tag(
dataset_id=dataset_id,
key=key,
)
mock_call.assert_called_once_with(
DeleteDatasetTag,
None,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/tags/{key}",
)
def test_dataset_apis_blocked_in_databricks():
# Test that the decorator blocks dataset APIs when using a Databricks tracking URI
# Mock the tracking URI to return a Databricks URI
with mock.patch("mlflow.tracking.get_tracking_uri", return_value="databricks://profile"):
creds = MlflowHostCreds("https://workspace.cloud.databricks.com")
store = RestStore(lambda: creds)
with pytest.raises(
MlflowException,
match="Evaluation dataset APIs is not supported in Databricks environments",
):
store.create_dataset(name="test", experiment_id=["0"])
# Test that APIs work when not using Databricks tracking URI
with mock.patch("mlflow.tracking.get_tracking_uri", return_value="http://localhost:5000"):
non_databricks_creds = MlflowHostCreds("http://localhost:5000")
non_databricks_store = RestStore(lambda: non_databricks_creds)
mock_response = mock.MagicMock()
mock_response.dataset.tags = "{}"
non_databricks_store._call_endpoint = mock.MagicMock(return_value=mock_response)
# This should not raise an error
result = non_databricks_store.get_dataset("d-123")
assert result is not None
def test_upsert_evaluation_dataset_records():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
records = [
{
"inputs": {"question": "What is MLflow?"},
"expectations": {"accuracy": 0.95},
"source": {
"source_type": "HUMAN",
"source_data": {"user": "user123"},
},
},
{
"inputs": {"question": "How to use MLflow?"},
"expectations": {"accuracy": 0.9},
"source": {
"source_type": "TRACE",
"source_data": {"trace_id": "trace123"},
},
},
]
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = UpsertDatasetRecords.Response()
response.inserted_count = 2
response.updated_count = 0
mock_call.return_value = response
result = store.upsert_dataset_records(
dataset_id=dataset_id,
records=records,
)
assert result == {"inserted": 2, "updated": 0}
req = UpsertDatasetRecords(
records=json.dumps(records),
)
expected_json = message_to_json(req)
mock_call.assert_called_once_with(
UpsertDatasetRecords,
expected_json,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/records",
)
def test_delete_evaluation_dataset_records():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
record_ids = ["dr-record1", "dr-record2"]
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = DeleteDatasetRecords.Response()
response.deleted_count = 2
mock_call.return_value = response
result = store.delete_dataset_records(
dataset_id=dataset_id,
dataset_record_ids=record_ids,
)
assert result == 2
req = DeleteDatasetRecords(
dataset_record_ids=record_ids,
)
expected_json = message_to_json(req)
mock_call.assert_called_once_with(
DeleteDatasetRecords,
expected_json,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/records",
)
def test_delete_evaluation_dataset_records_empty():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
record_ids = ["dr-nonexistent"]
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = DeleteDatasetRecords.Response()
response.deleted_count = 0
mock_call.return_value = response
result = store.delete_dataset_records(
dataset_id=dataset_id,
dataset_record_ids=record_ids,
)
assert result == 0
def test_get_evaluation_dataset_experiment_ids():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = GetDatasetExperimentIds.Response()
response.experiment_ids.extend(["exp1", "exp2", "exp3"])
mock_call.return_value = response
result = store.get_dataset_experiment_ids(dataset_id)
assert result == ["exp1", "exp2", "exp3"]
mock_call.assert_called_once_with(
GetDatasetExperimentIds,
None,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/experiment-ids",
)
def test_evaluation_dataset_error_handling():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
error_response = {
"error_code": "RESOURCE_DOES_NOT_EXIST",
"message": "Evaluation dataset not found",
}
response = mock.MagicMock()
response.status_code = 404
response.text = json.dumps(error_response)
mock_http.return_value = response
with pytest.raises(MlflowException, match="Evaluation dataset not found"):
store.get_dataset("d-nonexistent")
def test_evaluation_dataset_comprehensive_workflow():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call:
create_response = CreateDataset.Response()
create_response.dataset.dataset_id = dataset_id
create_response.dataset.name = "test_dataset"
create_response.dataset.created_time = 1234567890
create_response.dataset.last_update_time = 1234567890
create_response.dataset.digest = "abc123"
create_response.dataset.tags = json.dumps({"env": "test", "version": "1.0"})
get_response1 = GetDataset.Response()
get_response1.dataset.CopyFrom(create_response.dataset)
get_response1.dataset.tags = json.dumps({"env": "staging", "version": "1.1", "team": "ml"})
upsert_response1 = UpsertDatasetRecords.Response()
upsert_response1.inserted_count = 2
upsert_response1.updated_count = 0
get_response2 = GetDataset.Response()
get_response2.dataset.CopyFrom(get_response1.dataset)
get_response2.dataset.tags = json.dumps({"env": "production", "version": "2.0"})
upsert_response2 = UpsertDatasetRecords.Response()
upsert_response2.inserted_count = 1
upsert_response2.updated_count = 2
mock_call.side_effect = [
create_response, # Create with tags
None, # First tag update
get_response1, # Get after first tag update
upsert_response1, # First record upsert
None, # Second tag update (remove team tag)
get_response2, # Get after second tag update
upsert_response2, # Second record upsert
]
dataset = store.create_dataset(
name="test_dataset",
tags={"env": "test", "version": "1.0"},
experiment_ids=["exp1"],
)
assert dataset.tags == {"env": "test", "version": "1.0"}
store.set_dataset_tags(
dataset_id=dataset_id,
tags={"env": "staging", "version": "1.1", "team": "ml"},
)
updated_dataset = store.get_dataset(dataset_id)
assert updated_dataset.tags == {"env": "staging", "version": "1.1", "team": "ml"}
records1 = [
{"inputs": {"q": "What is MLflow?"}, "expectations": {"score": 0.9}},
{"inputs": {"q": "How to track?"}, "expectations": {"score": 0.8}},
]
result1 = store.upsert_dataset_records(dataset_id, records1)
assert result1 == {"inserted": 2, "updated": 0}
store.set_dataset_tags(
dataset_id=dataset_id,
tags={"env": "production", "version": "2.0", "team": None},
)
final_dataset = store.get_dataset(dataset_id)
assert final_dataset.tags == {"env": "production", "version": "2.0"}
records2 = [
{"inputs": {"q": "What is tracking?"}, "expectations": {"score": 0.95}}, # New
{"inputs": {"q": "What is MLflow?"}, "expectations": {"score": 0.95}}, # Update
{"inputs": {"q": "How to track?"}, "expectations": {"score": 0.85}}, # Update
]
result2 = store.upsert_dataset_records(dataset_id, records2)
assert result2 == {"inserted": 1, "updated": 2}
assert mock_call.call_count == 7
def test_evaluation_dataset_merge_records():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
eval_dataset = EvaluationDataset(
dataset_id=dataset_id,
name="test_dataset",
digest="abc123",
created_time=1234567890,
last_update_time=1234567890,
)
with mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_get_store:
mock_get_store.return_value = store
with mock.patch.object(store, "get_dataset") as mock_get:
mock_get.return_value = eval_dataset
with mock.patch.object(store, "_call_endpoint") as mock_call:
upsert_response = UpsertDatasetRecords.Response()
upsert_response.inserted_count = 2
upsert_response.updated_count = 0
mock_call.return_value = upsert_response
records = [
{
"inputs": {"question": "What is MLflow?", "temperature": 0.7},
"expectations": {"accuracy": 0.95},
},
{
"inputs": {"question": "How to track?", "model": "gpt-4"},
"expectations": {"clarity": 0.85},
},
]
result = eval_dataset.merge_records(records)
assert result is eval_dataset
assert mock_get.call_count == 1
assert mock_call.call_count == 1
call_args = mock_call.call_args_list[0]
assert call_args[0][0] == UpsertDatasetRecords
# Check the endpoint path contains the dataset_id
endpoint = call_args[1].get("endpoint")
assert endpoint == f"/api/3.0/mlflow/datasets/{dataset_id}/records"
# Check the request body contains the records
upsert_req_json = call_args[0][1]
upsert_req_dict = json.loads(upsert_req_json)
sent_records = json.loads(upsert_req_dict["records"])
assert len(sent_records) == 2
def test_evaluation_dataset_get_records():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = GetDatasetRecords.Response()
records = [
{
"dataset_id": dataset_id,
"dataset_record_id": "r-001",
"inputs": {"question": "What is MLflow?"},
"expectations": {"accuracy": 0.95},
"tags": {"source": "test"},
"source_type": "HUMAN",
"source_id": "user123",
"created_time": 1234567890,
"last_update_time": 1234567890,
},
{
"dataset_id": dataset_id,
"dataset_record_id": "r-002",
"inputs": {"question": "How to track?"},
"expectations": {"clarity": 0.85},
"tags": {},
"source_type": "TRACE",
"source_id": "trace456",
"created_time": 1234567891,
"last_update_time": 1234567891,
},
]
response.records = json.dumps(records)
response.next_page_token = ""
mock_call.return_value = response
records, next_page_token = store._load_dataset_records(dataset_id)
assert len(records) == 2
assert records[0].dataset_record_id == "r-001"
assert records[0].inputs == {"question": "What is MLflow?"}
assert records[1].dataset_record_id == "r-002"
assert next_page_token is None
req = GetDatasetRecords(
max_results=1000,
)
expected_json = message_to_json(req)
mock_call.assert_called_once_with(
GetDatasetRecords,
expected_json,
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/records",
)
def test_evaluation_dataset_lazy_loading_records():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
dataset_id = "d-1234567890abcdef1234567890abcdef"
eval_dataset = EvaluationDataset(
dataset_id=dataset_id,
name="test_dataset",
digest="abc123",
created_time=1234567890,
last_update_time=1234567890,
)
with mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_get_store:
mock_get_store.return_value = store
with mock.patch.object(store, "_load_dataset_records") as mock_load:
from mlflow.entities.dataset_record import DatasetRecord
mock_records = [
DatasetRecord(
dataset_id=dataset_id,
dataset_record_id="r-001",
inputs={"q": "test"},
expectations={"score": 0.9},
tags={},
created_time=1234567890,
last_update_time=1234567890,
)
]
mock_load.return_value = (mock_records, None)
records = eval_dataset.records
assert len(records) == 1
assert records[0].dataset_record_id == "r-001"
mock_load.assert_called_once_with(dataset_id, max_results=None)
def test_evaluation_dataset_pagination():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.search_datasets(max_results=10)
_verify_requests(
mock_http,
creds,
"datasets/search",
"POST",
message_to_json(
SearchEvaluationDatasets(
experiment_ids=[],
filter_string=None,
max_results=10,
order_by=[],
page_token=None,
)
),
use_v3=True,
)
with mock_http_request() as mock_http:
store.search_datasets(max_results=10, page_token="page2")
_verify_requests(
mock_http,
creds,
"datasets/search",
"POST",
message_to_json(
SearchEvaluationDatasets(
experiment_ids=[],
filter_string=None,
max_results=10,
order_by=[],
page_token="page2",
)
),
use_v3=True,
)
def test_load_dataset_records_pagination():
store = RestStore(lambda: None)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
mock_response = mock.MagicMock()
mock_record1 = mock.MagicMock()
mock_record1.dataset_record_id = "r-001"
mock_record1.inputs = json.dumps({"q": "Question 1"})
mock_record1.expectations = json.dumps({"a": "Answer 1"})
mock_record1.tags = "{}"
mock_record1.source_type = "TRACE"
mock_record1.source_id = "trace-1"
mock_record1.created_time = 1609459200
mock_record2 = mock.MagicMock()
mock_record2.dataset_record_id = "r-002"
mock_record2.inputs = json.dumps({"q": "Question 2"})
mock_record2.expectations = json.dumps({"a": "Answer 2"})
mock_record2.tags = "{}"
mock_record2.source_type = "TRACE"
mock_record2.source_id = "trace-2"
mock_record2.created_time = 1609459201
mock_response.records = json.dumps([
{
"dataset_id": dataset_id,
"dataset_record_id": "r-001",
"inputs": {"q": "Question 1"},
"expectations": {"a": "Answer 1"},
"tags": {},
"source_type": "TRACE",
"source_id": "trace-1",
"created_time": 1609459200,
"last_update_time": 1609459200,
},
{
"dataset_id": dataset_id,
"dataset_record_id": "r-002",
"inputs": {"q": "Question 2"},
"expectations": {"a": "Answer 2"},
"tags": {},
"source_type": "TRACE",
"source_id": "trace-2",
"created_time": 1609459201,
"last_update_time": 1609459201,
},
])
mock_response.next_page_token = "token_page2"
mock_call_endpoint.return_value = mock_response
records, next_token = store._load_dataset_records(
dataset_id, max_results=2, page_token=None
)
assert len(records) == 2
assert records[0].dataset_record_id == "r-001"
assert records[1].dataset_record_id == "r-002"
assert next_token == "token_page2"
mock_call_endpoint.assert_called_once_with(
GetDatasetRecords,
message_to_json(GetDatasetRecords(max_results=2)),
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/records",
)
mock_call_endpoint.reset_mock()
mock_response.records = json.dumps([
{
"dataset_id": dataset_id,
"dataset_record_id": "r-003",
"inputs": {"q": "Question 3"},
"expectations": {"a": "Answer 3"},
"tags": {},
"source_type": "TRACE",
"source_id": "trace-3",
"created_time": 1609459202,
"last_update_time": 1609459202,
}
])
mock_response.next_page_token = ""
records, next_token = store._load_dataset_records(
dataset_id, max_results=2, page_token="token_page2"
)
assert len(records) == 1
assert records[0].dataset_record_id == "r-003"
assert next_token is None
req_with_token = GetDatasetRecords(max_results=2)
req_with_token.page_token = "token_page2"
mock_call_endpoint.assert_called_once_with(
GetDatasetRecords,
message_to_json(req_with_token),
endpoint=f"/api/3.0/mlflow/datasets/{dataset_id}/records",
)
def test_evaluation_dataset_created_by_and_updated_by():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
created_response = CreateDataset.Response()
created_response.dataset.dataset_id = "d-test123"
created_response.dataset.name = "test_dataset"
created_response.dataset.created_time = 1234567890000
created_response.dataset.last_update_time = 1234567890000
created_response.dataset.created_by = "user1"
created_response.dataset.last_updated_by = "user1"
created_response.dataset.digest = "abc123"
created_response.dataset.tags = json.dumps({"mlflow.user": "user1", "environment": "test"})
mock_call.return_value = created_response
dataset = store.create_dataset(
name="test_dataset",
experiment_ids=["exp1"],
tags={"mlflow.user": "user1", "environment": "test"},
)
assert dataset.created_by == "user1"
assert dataset.last_updated_by == "user1"
assert dataset.tags["mlflow.user"] == "user1"
upsert_response = UpsertDatasetRecords.Response()
upsert_response.inserted_count = 1
upsert_response.updated_count = 0
get_response = GetDataset.Response()
get_response.dataset.dataset_id = "d-test123"
get_response.dataset.name = "test_dataset"
get_response.dataset.created_time = 1234567890000
get_response.dataset.last_update_time = 1234567900000
get_response.dataset.created_by = "user1"
get_response.dataset.last_updated_by = "user2"
get_response.dataset.digest = "def456"
get_response.dataset.tags = json.dumps({"mlflow.user": "user1", "environment": "test"})
mock_call.side_effect = [upsert_response, get_response]
records = [
{
"inputs": {"question": "Test?"},
"expectations": {"score": 0.9},
"tags": {"mlflow.user": "user2"},
}
]
result = store.upsert_dataset_records("d-test123", records)
assert result["inserted"] == 1
assert result["updated"] == 0
updated_dataset = store.get_dataset("d-test123")
assert updated_dataset.created_by == "user1"
assert updated_dataset.last_updated_by == "user2"
created_response_no_user = CreateDataset.Response()
created_response_no_user.dataset.dataset_id = "d-test456"
created_response_no_user.dataset.name = "test_dataset_no_user"
created_response_no_user.dataset.created_time = 1234567890000
created_response_no_user.dataset.last_update_time = 1234567890000
created_response_no_user.dataset.digest = "ghi789"
created_response_no_user.dataset.tags = json.dumps({"environment": "production"})
mock_call.side_effect = None
mock_call.return_value = created_response_no_user
dataset_no_user = store.create_dataset(
name="test_dataset_no_user",
experiment_ids=["exp2"],
tags={"environment": "production"},
)
assert dataset_no_user.created_by is None
assert dataset_no_user.last_updated_by is None
set_tags_response = SetDatasetTags.Response()
set_tags_response.dataset.dataset_id = "d-test123"
set_tags_response.dataset.name = "test_dataset"
set_tags_response.dataset.created_time = 1234567890000
set_tags_response.dataset.last_update_time = 1234567890000
set_tags_response.dataset.created_by = "user1"
set_tags_response.dataset.last_updated_by = "user1"
set_tags_response.dataset.digest = "abc123"
set_tags_response.dataset.tags = json.dumps({
"mlflow.user": "user3",
"environment": "staging",
"version": "2.0",
})
mock_call.return_value = set_tags_response
store.set_dataset_tags(
"d-test123",
{"mlflow.user": "user3", "version": "2.0", "environment": "staging"},
)
call_args = mock_call.call_args_list[-1]
api, json_body = call_args[0]
assert api == SetDatasetTags
request_dict = json.loads(json_body)
tags_dict = json.loads(request_dict["tags"])
assert "mlflow.user" in tags_dict
assert tags_dict["mlflow.user"] == "user3"
def test_evaluation_dataset_user_tracking_search():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
search_response = SearchEvaluationDatasets.Response()
dataset1 = search_response.datasets.add()
dataset1.dataset_id = "d-dataset1"
dataset1.name = "dataset1"
dataset1.created_time = 1234567890000
dataset1.last_update_time = 1234567900000
dataset1.created_by = "user1"
dataset1.last_updated_by = "user2"
dataset1.digest = "search1"
dataset2 = search_response.datasets.add()
dataset2.dataset_id = "d-dataset2"
dataset2.name = "dataset2"
dataset2.created_time = 1234567891000
dataset2.last_update_time = 1234567891000
dataset2.created_by = "user2"
dataset2.last_updated_by = "user2"
dataset2.digest = "search2"
mock_call.return_value = search_response
results = store.search_datasets(filter_string="created_by = 'user1'")
assert len(results) == 2
assert results[0].created_by == "user1"
assert results[0].last_updated_by == "user2"
call_args = mock_call.call_args_list[-1]
api, json_body = call_args[0]
assert api == SearchEvaluationDatasets
request_json = json.loads(json_body)
assert request_json["filter_string"] == "created_by = 'user1'"
results = store.search_datasets(filter_string="last_updated_by = 'user2'")
call_args = mock_call.call_args_list[-1]
api, json_body = call_args[0]
request_json = json.loads(json_body)
assert request_json["filter_string"] == "last_updated_by = 'user2'"
def test_add_dataset_to_experiments():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = AddDatasetToExperiments.Response()
response.dataset.dataset_id = "d-1234567890abcdef1234567890abcdef"
response.dataset.name = "test_dataset"
response.dataset.experiment_ids.extend(["0", "1", "3", "4"])
response.dataset.created_time = 1234567890
response.dataset.last_update_time = 1234567890
response.dataset.digest = "abc123"
mock_call.side_effect = [response]
result = store.add_dataset_to_experiments(
dataset_id="d-1234567890abcdef1234567890abcdef",
experiment_ids=["3", "4"],
)
assert mock_call.call_count == 1
assert result.dataset_id == "d-1234567890abcdef1234567890abcdef"
assert "3" in result.experiment_ids
assert "4" in result.experiment_ids
assert "0" in result.experiment_ids
assert "1" in result.experiment_ids
req = AddDatasetToExperiments(
dataset_id="d-1234567890abcdef1234567890abcdef",
experiment_ids=["3", "4"],
)
mock_call.assert_called_once_with(
AddDatasetToExperiments,
message_to_json(req),
endpoint="/api/3.0/mlflow/datasets/d-1234567890abcdef1234567890abcdef/add-experiments",
)
def test_remove_dataset_from_experiments():
creds = MlflowHostCreds("https://test-server")
store = RestStore(lambda: creds)
with mock.patch.object(store, "_call_endpoint") as mock_call:
response = RemoveDatasetFromExperiments.Response()
response.dataset.dataset_id = "d-1234567890abcdef1234567890abcdef"
response.dataset.name = "test_dataset"
response.dataset.experiment_ids.extend(["0", "1"])
response.dataset.created_time = 1234567890
response.dataset.last_update_time = 1234567890
response.dataset.digest = "abc123"
mock_call.side_effect = [response]
result = store.remove_dataset_from_experiments(
dataset_id="d-1234567890abcdef1234567890abcdef",
experiment_ids=["3"],
)
assert mock_call.call_count == 1
assert result.dataset_id == "d-1234567890abcdef1234567890abcdef"
assert "3" not in result.experiment_ids
assert "0" in result.experiment_ids
assert "1" in result.experiment_ids
req = RemoveDatasetFromExperiments(
dataset_id="d-1234567890abcdef1234567890abcdef",
experiment_ids=["3"],
)
mock_call.assert_called_once_with(
RemoveDatasetFromExperiments,
message_to_json(req),
endpoint="/api/3.0/mlflow/datasets/d-1234567890abcdef1234567890abcdef/remove-experiments",
)
def test_register_scorer():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
serialized_scorer = "serialized_scorer_data"
mock_response = mock.MagicMock()
mock_response.version = 1
mock_response.scorer_id = "test-scorer-id"
mock_response.experiment_id = experiment_id
mock_response.name = name
mock_response.serialized_scorer = serialized_scorer
mock_response.creation_time = 1234567890
mock_call_endpoint.return_value = mock_response
scorer_version = store.register_scorer(experiment_id, name, serialized_scorer)
assert scorer_version.scorer_version == 1
assert scorer_version.scorer_id == "test-scorer-id"
assert scorer_version.experiment_id == experiment_id
assert scorer_version.scorer_name == name
assert scorer_version._serialized_scorer == serialized_scorer
assert scorer_version.creation_time == 1234567890
mock_call_endpoint.assert_called_once_with(
RegisterScorer,
message_to_json(
RegisterScorer(
experiment_id=experiment_id, name=name, serialized_scorer=serialized_scorer
)
),
endpoint="/api/3.0/mlflow/scorers/register",
)
def test_list_scorers():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
# Mock response
mock_scorer1 = mock.MagicMock()
mock_scorer1.experiment_id = 123
mock_scorer1.scorer_name = "accuracy_scorer"
mock_scorer1.scorer_version = 1
mock_scorer1.serialized_scorer = "serialized_accuracy_scorer"
mock_scorer2 = mock.MagicMock()
mock_scorer2.experiment_id = 123
mock_scorer2.scorer_name = "safety_scorer"
mock_scorer2.scorer_version = 2
mock_scorer2.serialized_scorer = "serialized_safety_scorer"
mock_response = mock.MagicMock()
mock_response.scorers = [mock_scorer1, mock_scorer2]
mock_call_endpoint.return_value = mock_response
# Call the method
scorers = store.list_scorers(experiment_id)
# Verify result
assert len(scorers) == 2
assert scorers[0].scorer_name == "accuracy_scorer"
assert scorers[0].scorer_version == 1
assert scorers[0]._serialized_scorer == "serialized_accuracy_scorer"
assert scorers[1].scorer_name == "safety_scorer"
assert scorers[1].scorer_version == 2
assert scorers[1]._serialized_scorer == "serialized_safety_scorer"
# Verify API call
mock_call_endpoint.assert_called_once_with(
ListScorers,
message_to_json(ListScorers(experiment_id=experiment_id)),
endpoint="/api/3.0/mlflow/scorers/list",
)
def test_list_scorer_versions():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
# Mock response
mock_scorer1 = mock.MagicMock()
mock_scorer1.experiment_id = 123
mock_scorer1.scorer_name = "accuracy_scorer"
mock_scorer1.scorer_version = 1
mock_scorer1.serialized_scorer = "serialized_accuracy_scorer_v1"
mock_scorer2 = mock.MagicMock()
mock_scorer2.experiment_id = 123
mock_scorer2.scorer_name = "accuracy_scorer"
mock_scorer2.scorer_version = 2
mock_scorer2.serialized_scorer = "serialized_accuracy_scorer_v2"
mock_response = mock.MagicMock()
mock_response.scorers = [mock_scorer1, mock_scorer2]
mock_call_endpoint.return_value = mock_response
# Call the method
scorers = store.list_scorer_versions(experiment_id, name)
# Verify result
assert len(scorers) == 2
assert scorers[0].scorer_version == 1
assert scorers[0]._serialized_scorer == "serialized_accuracy_scorer_v1"
assert scorers[1].scorer_version == 2
assert scorers[1]._serialized_scorer == "serialized_accuracy_scorer_v2"
# Verify API call
mock_call_endpoint.assert_called_once_with(
ListScorerVersions,
message_to_json(ListScorerVersions(experiment_id=experiment_id, name=name)),
endpoint="/api/3.0/mlflow/scorers/versions",
)
def test_get_scorer_with_version():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
version = 2
# Mock response
mock_response = mock.MagicMock()
mock_scorer = mock.MagicMock()
mock_scorer.experiment_id = 123
mock_scorer.scorer_name = "accuracy_scorer"
mock_scorer.scorer_version = 2
mock_scorer.serialized_scorer = "serialized_accuracy_scorer_v2"
mock_scorer.creation_time = 1640995200000
mock_response.scorer = mock_scorer
mock_call_endpoint.return_value = mock_response
# Call the method
result = store.get_scorer(experiment_id, name, version=version)
# Verify result
assert result._serialized_scorer == "serialized_accuracy_scorer_v2"
assert result.scorer_version == 2
assert result.scorer_name == "accuracy_scorer"
# Verify API call
mock_call_endpoint.assert_called_once_with(
GetScorer,
message_to_json(GetScorer(experiment_id=experiment_id, name=name, version=version)),
endpoint="/api/3.0/mlflow/scorers/get",
)
def test_get_scorer_without_version():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
# Mock response
mock_response = mock.MagicMock()
mock_scorer = mock.MagicMock()
mock_scorer.experiment_id = 123
mock_scorer.scorer_name = "accuracy_scorer"
mock_scorer.scorer_version = 3
mock_scorer.serialized_scorer = "serialized_accuracy_scorer_latest"
mock_scorer.creation_time = 1640995200000
mock_response.scorer = mock_scorer
mock_call_endpoint.return_value = mock_response
# Call the method
result = store.get_scorer(experiment_id, name)
# Verify result
assert result._serialized_scorer == "serialized_accuracy_scorer_latest"
assert result.scorer_version == 3
assert result.scorer_name == "accuracy_scorer"
# Verify API call
mock_call_endpoint.assert_called_once_with(
GetScorer,
message_to_json(GetScorer(experiment_id=experiment_id, name=name)),
endpoint="/api/3.0/mlflow/scorers/get",
)
def test_delete_scorer_with_version():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
version = 2
# Mock response (empty response for delete operations)
mock_response = mock.MagicMock()
mock_call_endpoint.return_value = mock_response
# Call the method
store.delete_scorer(experiment_id, name, version=version)
# Verify API call
mock_call_endpoint.assert_called_once_with(
DeleteScorer,
message_to_json(DeleteScorer(experiment_id=experiment_id, name=name, version=version)),
endpoint="/api/3.0/mlflow/scorers/delete",
)
def test_delete_scorer_without_version():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_id = "123"
name = "accuracy_scorer"
# Mock response (empty response for delete operations)
mock_response = mock.MagicMock()
mock_call_endpoint.return_value = mock_response
# Call the method
store.delete_scorer(experiment_id, name)
# Verify API call
mock_call_endpoint.assert_called_once_with(
DeleteScorer,
message_to_json(DeleteScorer(experiment_id=experiment_id, name=name)),
endpoint="/api/3.0/mlflow/scorers/delete",
)
def test_calculate_trace_filter_correlation():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_ids = ["123", "456"]
filter_string1 = "span.type = 'LLM'"
filter_string2 = "feedback.quality > 0.8"
base_filter = "request_time > 1000"
mock_response = mock.MagicMock()
mock_response.npmi = 0.456
mock_response.npmi_smoothed = 0.445
mock_response.filter1_count = 100
mock_response.filter2_count = 80
mock_response.joint_count = 50
mock_response.total_count = 200
mock_response.HasField = lambda field: field in ["npmi", "npmi_smoothed"]
mock_call_endpoint.return_value = mock_response
result = store.calculate_trace_filter_correlation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=base_filter,
)
assert isinstance(result, TraceFilterCorrelationResult)
assert result.npmi == 0.456
assert result.npmi_smoothed == 0.445
assert result.filter1_count == 100
assert result.filter2_count == 80
assert result.joint_count == 50
assert result.total_count == 200
expected_request = CalculateTraceFilterCorrelation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=base_filter,
)
mock_call_endpoint.assert_called_once_with(
CalculateTraceFilterCorrelation,
message_to_json(expected_request),
"/api/3.0/mlflow/traces/calculate-filter-correlation",
)
def test_calculate_trace_filter_correlation_without_base_filter():
store = RestStore(lambda: None)
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
experiment_ids = ["123"]
filter_string1 = "span.type = 'LLM'"
filter_string2 = "feedback.quality > 0.8"
mock_response = mock.MagicMock()
mock_response.filter1_count = 0
mock_response.filter2_count = 0
mock_response.joint_count = 0
mock_response.total_count = 100
mock_response.HasField = lambda field: False
mock_call_endpoint.return_value = mock_response
result = store.calculate_trace_filter_correlation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
)
assert isinstance(result, TraceFilterCorrelationResult)
assert math.isnan(result.npmi)
assert result.npmi_smoothed is None
assert result.filter1_count == 0
assert result.filter2_count == 0
assert result.joint_count == 0
assert result.total_count == 100
expected_request = CalculateTraceFilterCorrelation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
)
mock_call_endpoint.assert_called_once_with(
CalculateTraceFilterCorrelation,
message_to_json(expected_request),
"/api/3.0/mlflow/traces/calculate-filter-correlation",
)
def _create_mock_response(status_code: int = 200, text: str = "{}") -> mock.MagicMock:
"""Helper to create a mock HTTP response."""
response = mock.MagicMock()
response.status_code = status_code
response.text = text
return response
def _create_test_spans() -> list[LiveSpan]:
"""Helper to create test spans for log_spans tests."""
otel_span = create_mock_otel_span(
trace_id=123,
span_id=1,
name="test_span",
start_time=1000000,
end_time=2000000,
)
return [LiveSpan(otel_span, trace_id="tr-123")]
def test_log_spans_with_version_check():
spans = _create_test_spans()
experiment_id = "exp-123"
# Test 1: Server version is None (failed to retrieve)
# Use unique host to avoid cache conflicts
creds1 = MlflowHostCreds("https://host1")
store1 = RestStore(lambda: creds1)
with mock.patch(
"mlflow.store.tracking.rest_store.http_request", side_effect=Exception("Connection error")
):
with pytest.raises(NotImplementedError, match="could not identify MLflow server version"):
store1.log_spans(experiment_id, spans)
# Test 2: Server version is less than 3.4
creds2 = MlflowHostCreds("https://host2")
store2 = RestStore(lambda: creds2)
with mock.patch(
"mlflow.store.tracking.rest_store.http_request",
return_value=_create_mock_response(text="3.3.0"),
):
with pytest.raises(
NotImplementedError, match="MLflow server version 3.3.0 is less than 3.4"
):
store2.log_spans(experiment_id, spans)
# Test 3: Server version is exactly 3.4.0 - should succeed
creds3 = MlflowHostCreds("https://host3")
store3 = RestStore(lambda: creds3)
with mock.patch(
"mlflow.store.tracking.rest_store.http_request",
side_effect=[
# First call is to /version, second is to OTLP endpoint
_create_mock_response(text="3.4.0"), # version response
_create_mock_response(), # OTLP response
],
):
result = store3.log_spans(experiment_id, spans)
assert result == spans
# Test 4: Server version is greater than 3.4 - should succeed
creds4 = MlflowHostCreds("https://host4")
store4 = RestStore(lambda: creds4)
with mock.patch(
"mlflow.store.tracking.rest_store.http_request",
side_effect=[
# First call is to /version, second is to OTLP endpoint
_create_mock_response(text="3.5.0"), # version response
_create_mock_response(), # OTLP response
],
):
result = store4.log_spans(experiment_id, spans)
assert result == spans
# Test 5: Real timeout test - verify that timeout works properly without mocking
# Using a non-existent host that will trigger timeout
creds5 = MlflowHostCreds("https://host5")
store5 = RestStore(lambda: creds5)
start_time = time.time()
with pytest.raises(NotImplementedError, match="could not identify MLflow server version"):
store5.log_spans(experiment_id, spans)
elapsed_time = time.time() - start_time
# Should timeout within 3 seconds (plus some buffer for processing)
assert elapsed_time < 5, f"Version check took {elapsed_time}s, should timeout within 3s"
def test_server_version_check_caching():
spans = _create_test_spans()
experiment_id = "exp-123"
# Use the same host credentials for all stores to test caching
creds = MlflowHostCreds("https://cached-host")
store1 = RestStore(lambda: creds)
store2 = RestStore(lambda: creds) # Different store instance, same creds
# First call - should fetch version and then call OTLP
with mock.patch(
"mlflow.store.tracking.rest_store.http_request",
side_effect=[
_create_mock_response(text="3.5.0"), # version response
_create_mock_response(), # OTLP response
],
) as mock_http:
# We call log_spans because it performs a server version check via _get_server_version
result1 = store1.log_spans(experiment_id, spans)
assert result1 == spans
# Should have called /version first, then /v1/traces
mock_http.assert_any_call(
host_creds=creds,
endpoint="/version",
method="GET",
timeout=3,
max_retries=0,
retry_timeout_seconds=1,
raise_on_status=True,
)
mock_http.assert_any_call(
host_creds=creds,
endpoint="/v1/traces",
method="POST",
data=mock.ANY,
extra_headers=mock.ANY,
)
assert mock_http.call_count == 2
# Second call with same store - should use cached version, only call OTLP
with mock.patch(
"mlflow.store.tracking.rest_store.http_request", return_value=_create_mock_response()
) as mock_http:
result2 = store1.log_spans(experiment_id, spans)
assert result2 == spans
# Should only call OTLP, not version (cached)
mock_http.assert_called_once_with(
host_creds=creds,
endpoint="/v1/traces",
method="POST",
data=mock.ANY,
extra_headers=mock.ANY,
)
# Third call with different store but same creds - should still use cached version
with mock.patch(
"mlflow.store.tracking.rest_store.http_request", return_value=_create_mock_response()
) as mock_http:
result3 = store2.log_spans(experiment_id, spans)
assert result3 == spans
# Should only call OTLP, not version (cached across instances)
mock_http.assert_called_once_with(
host_creds=creds,
endpoint="/v1/traces",
method="POST",
data=mock.ANY,
extra_headers=mock.ANY,
)
def test_link_prompts_to_trace():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = "{}"
trace_id = "tr-1234"
prompt_versions = [
PromptVersion(name="prompt1", version=1, template="template1"),
PromptVersion(name="prompt2", version=2, template="template2"),
]
request = LinkPromptsToTrace(
trace_id=trace_id,
prompt_versions=[
LinkPromptsToTrace.PromptVersionRef(name=pv.name, version=str(pv.version))
for pv in prompt_versions
],
)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.link_prompts_to_trace(trace_id=trace_id, prompt_versions=prompt_versions)
_verify_requests(
mock_http,
creds,
"traces/link-prompts",
"POST",
message_to_json(request),
)
def test_create_gateway_secret():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.create_gateway_secret(
secret_name="test-key",
secret_value={"api_key": "sk-test-12345"},
provider="openai",
)
body = message_to_json(
CreateGatewaySecret(
secret_name="test-key",
secret_value={"api_key": "sk-test-12345"},
provider="openai",
)
)
_verify_requests(mock_http, creds, "gateway/secrets/create", "POST", body, use_v3=True)
def test_get_gateway_secret_info():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.get_secret_info(secret_id="secret-123")
body = message_to_json(GetGatewaySecretInfo(secret_id="secret-123"))
_verify_requests(mock_http, creds, "gateway/secrets/get", "GET", body, use_v3=True)
def test_update_gateway_secret():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.update_gateway_secret(
secret_id="secret-123",
secret_value={"api_key": "sk-new-value"},
auth_config={"region": "us-east-1"},
)
body = message_to_json(
UpdateGatewaySecret(
secret_id="secret-123",
secret_value={"api_key": "sk-new-value"},
auth_config={"region": "us-east-1"},
)
)
_verify_requests(mock_http, creds, "gateway/secrets/update", "POST", body, use_v3=True)
def test_delete_gateway_secret():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.delete_gateway_secret(secret_id="secret-123")
body = message_to_json(DeleteGatewaySecret(secret_id="secret-123"))
_verify_requests(mock_http, creds, "gateway/secrets/delete", "DELETE", body, use_v3=True)
def test_list_gateway_secret_infos():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.list_secret_infos()
body = message_to_json(ListGatewaySecretInfos())
_verify_requests(mock_http, creds, "gateway/secrets/list", "GET", body, use_v3=True)
def test_create_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.create_gateway_endpoint(
name="my-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id="model-def-123",
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=0,
),
GatewayEndpointModelConfig(
model_definition_id="model-def-789",
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=1,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT,
fallback_config=FallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL,
max_attempts=2,
),
usage_tracking=True,
)
body = message_to_json(
CreateGatewayEndpoint(
name="my-endpoint",
model_configs=[
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-123",
linkage_type=GatewayModelLinkageType.PRIMARY.to_proto(),
weight=1.0,
),
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.FALLBACK.to_proto(),
weight=1.0,
fallback_order=0,
),
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-789",
linkage_type=GatewayModelLinkageType.FALLBACK.to_proto(),
weight=1.0,
fallback_order=1,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT.to_proto(),
fallback_config=ProtoFallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL.to_proto(),
max_attempts=2,
),
usage_tracking=True,
)
)
_verify_requests(mock_http, creds, "gateway/endpoints/create", "POST", body, use_v3=True)
def test_get_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.get_gateway_endpoint(endpoint_id="endpoint-123")
body = message_to_json(GetGatewayEndpoint(endpoint_id="endpoint-123"))
_verify_requests(mock_http, creds, "gateway/endpoints/get", "GET", body, use_v3=True)
def test_update_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.update_gateway_endpoint(
endpoint_id="endpoint-123",
name="updated-endpoint",
model_configs=[
GatewayEndpointModelConfig(
model_definition_id="model-def-123",
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
GatewayEndpointModelConfig(
model_definition_id="model-def-fallback-1",
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=0,
),
GatewayEndpointModelConfig(
model_definition_id="model-def-fallback-2",
linkage_type=GatewayModelLinkageType.FALLBACK,
weight=1.0,
fallback_order=1,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT,
fallback_config=FallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL,
max_attempts=3,
),
)
from mlflow.protos.service_pb2 import (
FallbackConfig as ProtoFallbackConfig,
)
from mlflow.protos.service_pb2 import (
GatewayEndpointModelConfig as ProtoGatewayEndpointModelConfig,
)
body = message_to_json(
UpdateGatewayEndpoint(
endpoint_id="endpoint-123",
name="updated-endpoint",
model_configs=[
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-123",
linkage_type=GatewayModelLinkageType.PRIMARY.to_proto(),
weight=1.0,
),
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.PRIMARY.to_proto(),
weight=1.0,
),
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-fallback-1",
linkage_type=GatewayModelLinkageType.FALLBACK.to_proto(),
weight=1.0,
fallback_order=0,
),
ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-fallback-2",
linkage_type=GatewayModelLinkageType.FALLBACK.to_proto(),
weight=1.0,
fallback_order=1,
),
],
routing_strategy=RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT.to_proto(),
fallback_config=ProtoFallbackConfig(
strategy=FallbackStrategy.SEQUENTIAL.to_proto(),
max_attempts=3,
),
)
)
_verify_requests(mock_http, creds, "gateway/endpoints/update", "POST", body, use_v3=True)
def test_delete_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.delete_gateway_endpoint(endpoint_id="endpoint-123")
body = message_to_json(DeleteGatewayEndpoint(endpoint_id="endpoint-123"))
_verify_requests(mock_http, creds, "gateway/endpoints/delete", "DELETE", body, use_v3=True)
def test_list_gateway_endpoints():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.list_gateway_endpoints()
body = message_to_json(ListGatewayEndpoints())
_verify_requests(mock_http, creds, "gateway/endpoints/list", "GET", body, use_v3=True)
def test_create_gateway_model_definition():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.create_gateway_model_definition(
name="my-model-def",
secret_id="secret-456",
provider="anthropic",
model_name="claude-3-5-sonnet",
)
body = message_to_json(
CreateGatewayModelDefinition(
name="my-model-def",
secret_id="secret-456",
provider="anthropic",
model_name="claude-3-5-sonnet",
)
)
_verify_requests(
mock_http, creds, "gateway/model-definitions/create", "POST", body, use_v3=True
)
def test_get_gateway_model_definition():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.get_gateway_model_definition(model_definition_id="model-def-123")
body = message_to_json(GetGatewayModelDefinition(model_definition_id="model-def-123"))
_verify_requests(
mock_http, creds, "gateway/model-definitions/get", "GET", body, use_v3=True
)
def test_list_gateway_model_definitions():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.list_gateway_model_definitions()
body = message_to_json(ListGatewayModelDefinitions())
_verify_requests(
mock_http, creds, "gateway/model-definitions/list", "GET", body, use_v3=True
)
def test_update_gateway_model_definition():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.update_gateway_model_definition(
model_definition_id="model-def-123",
name="updated-name",
model_name="gpt-4o-mini",
)
body = message_to_json(
UpdateGatewayModelDefinition(
model_definition_id="model-def-123",
name="updated-name",
model_name="gpt-4o-mini",
)
)
_verify_requests(
mock_http, creds, "gateway/model-definitions/update", "POST", body, use_v3=True
)
def test_delete_gateway_model_definition():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.delete_gateway_model_definition(model_definition_id="model-def-123")
body = message_to_json(DeleteGatewayModelDefinition(model_definition_id="model-def-123"))
_verify_requests(
mock_http, creds, "gateway/model-definitions/delete", "DELETE", body, use_v3=True
)
def test_attach_model_to_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.attach_model_to_endpoint(
endpoint_id="endpoint-123",
model_config=GatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.PRIMARY,
weight=1.0,
),
)
from mlflow.protos.service_pb2 import (
GatewayEndpointModelConfig as ProtoGatewayEndpointModelConfig,
)
body = message_to_json(
AttachModelToGatewayEndpoint(
endpoint_id="endpoint-123",
model_config=ProtoGatewayEndpointModelConfig(
model_definition_id="model-def-456",
linkage_type=GatewayModelLinkageType.PRIMARY.to_proto(),
weight=1.0,
),
)
)
_verify_requests(
mock_http, creds, "gateway/endpoints/models/attach", "POST", body, use_v3=True
)
def test_detach_model_from_gateway_endpoint():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.detach_model_from_endpoint(
endpoint_id="endpoint-123",
model_definition_id="model-def-456",
)
body = message_to_json(
DetachModelFromGatewayEndpoint(
endpoint_id="endpoint-123",
model_definition_id="model-def-456",
)
)
_verify_requests(
mock_http, creds, "gateway/endpoints/models/detach", "POST", body, use_v3=True
)
def test_create_gateway_endpoint_binding():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response_json = json.dumps({"binding": {"resource_type": "scorer"}})
with mock.patch(
"mlflow.utils.rest_utils.http_request",
return_value=mock.MagicMock(status_code=200, text=response_json),
) as mock_http:
store.create_endpoint_binding(
endpoint_id="endpoint-123",
resource_type=GatewayResourceType.SCORER,
resource_id="job-456",
)
body = message_to_json(
CreateGatewayEndpointBinding(
endpoint_id="endpoint-123",
resource_type="scorer",
resource_id="job-456",
)
)
_verify_requests(
mock_http, creds, "gateway/endpoints/bindings/create", "POST", body, use_v3=True
)
def test_delete_gateway_endpoint_binding():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.delete_endpoint_binding(
endpoint_id="endpoint-123",
resource_type="scorer",
resource_id="job-456",
)
body = message_to_json(
DeleteGatewayEndpointBinding(
endpoint_id="endpoint-123",
resource_type="scorer",
resource_id="job-456",
)
)
_verify_requests(
mock_http, creds, "gateway/endpoints/bindings/delete", "DELETE", body, use_v3=True
)
def test_list_gateway_endpoint_bindings():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
with mock_http_request() as mock_http:
store.list_endpoint_bindings(
endpoint_id="endpoint-123",
resource_type=GatewayResourceType.SCORER,
resource_id="job-456",
)
body = message_to_json(
ListGatewayEndpointBindings(
endpoint_id="endpoint-123",
resource_type="scorer",
resource_id="job-456",
)
)
_verify_requests(
mock_http, creds, "gateway/endpoints/bindings/list", "GET", body, use_v3=True
)
def test_query_trace_metrics():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
# Format the response
response.text = json.dumps({
"data_points": [
{
"metric_name": "latency",
"dimensions": {"span_name": "chat", "status": "OK"},
"values": {"AVG": 123.45, "COUNT": 10.0},
},
{
"metric_name": "latency",
"dimensions": {"span_name": "embeddings", "status": "OK"},
"values": {"AVG": 50.0, "COUNT": 5.0},
},
],
"next_page_token": "next_token",
})
# Parameters for query_trace_metrics
experiment_ids = ["1234", "5678"]
view_type = MetricViewType.SPANS
metric_name = "latency"
aggregations = [
MetricAggregation(AggregationType.AVG),
MetricAggregation(AggregationType.COUNT),
]
dimensions = ["span_name", "status"]
filters = ["status = 'OK'"]
max_results = 100
page_token = "page_token_123"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
result = store.query_trace_metrics(
experiment_ids=experiment_ids,
view_type=view_type,
metric_name=metric_name,
aggregations=aggregations,
dimensions=dimensions,
filters=filters,
max_results=max_results,
page_token=page_token,
)
# Verify the correct endpoint was called
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V3_TRACE_REST_API_PATH_PREFIX}/metrics"
# Verify the correct parameters were passed
json_body = call_args["json"]
assert json_body["experiment_ids"] == experiment_ids
assert json_body["view_type"] == "SPANS"
assert json_body["metric_name"] == metric_name
assert json_body["max_results"] == max_results
assert json_body["dimensions"] == dimensions
assert json_body["filters"] == filters
assert json_body["page_token"] == page_token
assert len(json_body["aggregations"]) == 2
# Verify the correct data points were returned
assert len(result) == 2
assert isinstance(result[0], MetricDataPoint)
assert result[0].metric_name == "latency"
assert result[0].dimensions == {"span_name": "chat", "status": "OK"}
assert result[0].values == {"AVG": 123.45, "COUNT": 10.0}
assert result[1].metric_name == "latency"
assert result[1].dimensions == {"span_name": "embeddings", "status": "OK"}
assert result[1].values == {"AVG": 50.0, "COUNT": 5.0}
# Verify pagination token
assert result.token == "next_token"
def test_create_issue():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"issue": {
"issue_id": "issue-123",
"experiment_id": "exp-456",
"name": "Test Issue",
"description": "Test description",
"status": "pending",
"severity": "high",
"root_causes": ["cause1", "cause2"],
"source_run_id": "run-789",
"categories": ["hallucination", "tool_error"],
"created_timestamp": 1234567890000,
"last_updated_timestamp": 1234567890000,
"created_by": "test_user",
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
issue = store.create_issue(
experiment_id="exp-456",
name="Test Issue",
description="Test description",
status=IssueStatus.PENDING,
severity=IssueSeverity.HIGH,
root_causes=["cause1", "cause2"],
source_run_id="run-789",
categories=["hallucination", "tool_error"],
created_by="test_user",
)
expected_request_json = json.dumps({
"experiment_id": "exp-456",
"name": "Test Issue",
"description": "Test description",
"status": "pending",
"severity": "high",
"root_causes": ["cause1", "cause2"],
"source_run_id": "run-789",
"categories": ["hallucination", "tool_error"],
"created_by": "test_user",
})
_verify_requests(mock_http, creds, "issues", "POST", expected_request_json, use_v3=True)
assert isinstance(issue, Issue)
assert issue.issue_id == "issue-123"
assert issue.experiment_id == "exp-456"
assert issue.name == "Test Issue"
assert issue.description == "Test description"
assert issue.status == IssueStatus.PENDING
assert issue.severity == IssueSeverity.HIGH
assert issue.root_causes == ["cause1", "cause2"]
assert issue.source_run_id == "run-789"
assert issue.categories == ["hallucination", "tool_error"]
assert issue.created_by == "test_user"
def test_get_issue():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"issue": {
"issue_id": "issue-123",
"experiment_id": "exp-456",
"name": "Test Issue",
"description": "Test description",
"status": "resolved",
"severity": "medium",
"root_causes": ["cause1"],
"source_run_id": "run-789",
"created_timestamp": 1234567890000,
"last_updated_timestamp": 1234567900000,
"created_by": "test_user",
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
issue = store.get_issue(issue_id="issue-123")
# The proto endpoint uses a template path, so we need to verify with params instead of json
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V3_ISSUES_REST_API_PATH_PREFIX}/issue-123"
assert call_args["method"] == "GET"
assert call_args["params"] == {"issue_id": "issue-123"}
assert isinstance(issue, Issue)
assert issue.issue_id == "issue-123"
assert issue.experiment_id == "exp-456"
assert issue.name == "Test Issue"
assert issue.status == IssueStatus.RESOLVED
assert issue.severity == IssueSeverity.MEDIUM
def test_update_issue():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"issue": {
"issue_id": "issue-123",
"experiment_id": "exp-456",
"name": "Updated Issue",
"description": "Updated description",
"status": "rejected",
"severity": "low",
"root_causes": ["updated_cause"],
"source_run_id": "run-789",
"created_timestamp": 1234567890000,
"last_updated_timestamp": 1234567910000,
"created_by": "test_user",
}
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
issue = store.update_issue(
issue_id="issue-123",
status=IssueStatus.REJECTED,
name="Updated Issue",
description="Updated description",
severity=IssueSeverity.LOW,
)
# The proto endpoint uses a template path
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V3_ISSUES_REST_API_PATH_PREFIX}/issue-123"
assert call_args["method"] == "PATCH"
# Verify the JSON body contains the issue_id and update fields
json_body = call_args["json"]
assert json_body["issue_id"] == "issue-123"
assert json_body["status"] == "rejected"
assert json_body["name"] == "Updated Issue"
assert json_body["description"] == "Updated description"
assert json_body["severity"] == "low"
assert isinstance(issue, Issue)
assert issue.issue_id == "issue-123"
assert issue.name == "Updated Issue"
assert issue.description == "Updated description"
assert issue.status == IssueStatus.REJECTED
assert issue.severity == IssueSeverity.LOW
def test_search_issues():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"issues": [
{
"issue_id": "issue-123",
"experiment_id": "exp-456",
"name": "Issue 1",
"description": "Description 1",
"status": "pending",
"severity": "high",
"root_causes": [],
"source_run_id": None,
"created_timestamp": 1234567890000,
"last_updated_timestamp": 1234567890000,
"created_by": "user1",
},
{
"issue_id": "issue-124",
"experiment_id": "exp-456",
"name": "Issue 2",
"description": "Description 2",
"status": "resolved",
"severity": "medium",
"root_causes": ["cause"],
"source_run_id": "run-123",
"created_timestamp": 1234567900000,
"last_updated_timestamp": 1234567900000,
"created_by": "user2",
},
],
"next_page_token": "next_token_456",
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
result = store.search_issues(
experiment_id="exp-456",
filter_string="severity = 'high'",
max_results=100,
page_token="page_token_123",
)
expected_request_json = json.dumps({
"experiment_id": "exp-456",
"filter_string": "severity = 'high'",
"max_results": 100,
"page_token": "page_token_123",
"include_trace_count": False,
})
_verify_requests(mock_http, creds, "issues/search", "POST", expected_request_json, use_v3=True)
assert len(result) == 2
assert isinstance(result[0], Issue)
assert result[0].issue_id == "issue-123"
assert result[0].name == "Issue 1"
assert result[0].status == IssueStatus.PENDING
assert result[0].severity == IssueSeverity.HIGH
assert isinstance(result[1], Issue)
assert result[1].issue_id == "issue-124"
assert result[1].name == "Issue 2"
assert result[1].status == IssueStatus.RESOLVED
assert result[1].severity == IssueSeverity.MEDIUM
assert result.token == "next_token_456"
def test_search_issues_with_trace_count():
creds = MlflowHostCreds("https://hello")
store = RestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"issues": [
{
"issue_id": "issue-123",
"experiment_id": "exp-456",
"name": "Issue with traces",
"description": "Has 2 traces",
"status": "pending",
"created_timestamp": 1234567890000,
"last_updated_timestamp": 1234567890000,
"trace_count": 2,
},
{
"issue_id": "issue-124",
"experiment_id": "exp-456",
"name": "Issue without traces",
"description": "Has no traces",
"status": "pending",
"created_timestamp": 1234567900000,
"last_updated_timestamp": 1234567900000,
"trace_count": 0,
},
],
"next_page_token": "",
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
result = store.search_issues(
experiment_id="exp-456",
include_trace_count=True,
)
expected_request_json = json.dumps({
"experiment_id": "exp-456",
"include_trace_count": True,
})
_verify_requests(mock_http, creds, "issues/search", "POST", expected_request_json, use_v3=True)
assert len(result) == 2
assert result[0].trace_count == 2
assert result[1].trace_count == 0
assert result.token is None