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

209 lines
7.2 KiB
Python

from __future__ import annotations
import json
from types import SimpleNamespace
from unittest import mock
import pytest
from mlflow.entities.workspace import Workspace, WorkspaceDeletionMode
from mlflow.exceptions import MlflowException, RestException
from mlflow.protos.service_pb2 import (
CreateWorkspace,
DeleteWorkspace,
GetWorkspace,
ListWorkspaces,
UpdateWorkspace,
)
from mlflow.store.workspace.rest_store import WORKSPACES_ENDPOINT, RestWorkspaceStore
@pytest.fixture
def host_creds():
return SimpleNamespace()
@pytest.fixture
def store(host_creds):
return RestWorkspaceStore(lambda: host_creds)
def test_list_workspaces_parses_response(store, host_creds):
response = ListWorkspaces.Response()
response.workspaces.add(name="default", description="Default workspace")
response.workspaces.add(name="team-a", description="Team A")
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
workspaces = store.list_workspaces()
assert [ws.name for ws in workspaces] == ["default", "team-a"]
call_endpoint.assert_called_once()
kwargs = call_endpoint.call_args.kwargs
assert kwargs["host_creds"] is host_creds
assert kwargs["endpoint"] == WORKSPACES_ENDPOINT
assert kwargs["method"] == "GET"
assert kwargs["json_body"] is None
assert kwargs.get("expected_status", 200) == 200
def test_get_workspace_returns_entity(store, host_creds):
response = GetWorkspace.Response()
response.workspace.name = "team-b"
response.workspace.description = "Team B"
response.workspace.trace_archival_config.location = "s3://archive/team-b"
response.workspace.trace_archival_config.retention = "30d"
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
workspace = store.get_workspace("team-b")
assert workspace.name == "team-b"
assert workspace.description == "Team B"
assert workspace.trace_archival_location == "s3://archive/team-b"
assert workspace.trace_archival_retention == "30d"
call_endpoint.assert_called_once()
kwargs = call_endpoint.call_args.kwargs
assert kwargs["endpoint"] == f"{WORKSPACES_ENDPOINT}/team-b"
assert kwargs["method"] == "GET"
def test_create_workspace_sends_payload(store, host_creds):
response = CreateWorkspace.Response()
response.workspace.name = "team-c"
response.workspace.description = "Team C"
response.workspace.trace_archival_config.location = "s3://archive/team-c"
response.workspace.trace_archival_config.retention = "14d"
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
workspace = store.create_workspace(
Workspace(
name="team-c",
description="Team C",
trace_archival_location="s3://archive/team-c",
trace_archival_retention="14d",
)
)
assert workspace.name == "team-c"
assert workspace.description == "Team C"
assert workspace.trace_archival_location == "s3://archive/team-c"
assert workspace.trace_archival_retention == "14d"
call_endpoint.assert_called_once()
kwargs = call_endpoint.call_args.kwargs
assert kwargs["endpoint"] == WORKSPACES_ENDPOINT
assert kwargs["method"] == "POST"
assert kwargs["expected_status"] == 201
assert json.loads(kwargs["json_body"]) == {
"name": "team-c",
"description": "Team C",
"trace_archival_config": {
"location": "s3://archive/team-c",
"retention": "14d",
},
}
def test_create_workspace_conflict_raises_resource_exists(store, monkeypatch):
exc = RestException({"error_code": "RESOURCE_ALREADY_EXISTS", "message": "already exists"})
monkeypatch.setattr(
"mlflow.store.workspace.rest_store.call_endpoint",
mock.Mock(side_effect=exc),
)
with pytest.raises(
MlflowException,
match="already exists",
) as exc_info:
store.create_workspace(Workspace(name="team-a"))
assert exc_info.value.error_code == "RESOURCE_ALREADY_EXISTS"
assert "already exists" in exc_info.value.message
def test_update_workspace_returns_new_description(store, host_creds):
response = UpdateWorkspace.Response()
response.workspace.name = "team-e"
response.workspace.description = "updated"
response.workspace.trace_archival_config.location = "s3://archive/team-e"
response.workspace.trace_archival_config.retention = "7d"
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
workspace = store.update_workspace(
Workspace(
name="team-e",
description="updated",
trace_archival_location="s3://archive/team-e",
trace_archival_retention="7d",
)
)
assert workspace.description == "updated"
assert workspace.trace_archival_location == "s3://archive/team-e"
assert workspace.trace_archival_retention == "7d"
call_endpoint.assert_called_once()
kwargs = call_endpoint.call_args.kwargs
assert kwargs["endpoint"] == f"{WORKSPACES_ENDPOINT}/team-e"
assert kwargs["method"] == "PATCH"
assert json.loads(kwargs["json_body"]) == {
"description": "updated",
"trace_archival_config": {
"location": "s3://archive/team-e",
"retention": "7d",
},
}
def test_delete_workspace_returns_on_success(store, host_creds):
response = DeleteWorkspace.Response()
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
store.delete_workspace("team-f")
call_endpoint.assert_called_once()
kwargs = call_endpoint.call_args.kwargs
assert kwargs["endpoint"] == f"{WORKSPACES_ENDPOINT}/team-f"
assert kwargs["method"] == "DELETE"
assert kwargs["expected_status"] == 204
assert kwargs["json_body"] is None
@pytest.mark.parametrize(
("mode", "expected_suffix"),
[
(WorkspaceDeletionMode.RESTRICT, ""),
(WorkspaceDeletionMode.CASCADE, "?mode=CASCADE"),
(WorkspaceDeletionMode.SET_DEFAULT, "?mode=SET_DEFAULT"),
],
)
def test_delete_workspace_sends_mode_query_param(store, host_creds, mode, expected_suffix):
response = DeleteWorkspace.Response()
with mock.patch(
"mlflow.store.workspace.rest_store.call_endpoint", return_value=response
) as call_endpoint:
store.delete_workspace("team-f", mode=mode)
kwargs = call_endpoint.call_args.kwargs
assert kwargs["endpoint"] == f"{WORKSPACES_ENDPOINT}/team-f{expected_suffix}"
def test_get_default_workspace_not_supported(store):
with pytest.raises(
NotImplementedError,
match="REST workspace provider does not expose a default workspace",
):
store.get_default_workspace()
def test_rest_store_validates_workspace_names_before_http(monkeypatch, store):
mock_call = mock.Mock()
monkeypatch.setattr("mlflow.store.workspace.rest_store.call_endpoint", mock_call)
with pytest.raises(MlflowException, match="must match the pattern"):
store.get_workspace("Invalid")
mock_call.assert_not_called()