3988 lines
139 KiB
Python
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
|