Files
mlflow--mlflow/tests/server/test_workspace_endpoints.py
2026-07-13 13:22:34 +08:00

493 lines
18 KiB
Python

from __future__ import annotations
import json
from unittest import mock
import pytest
from flask import Flask
from mlflow.entities.workspace import Workspace, WorkspaceDeletionMode
from mlflow.server.handlers import get_endpoints
@pytest.fixture(autouse=True)
def enable_workspaces(monkeypatch):
monkeypatch.setenv("MLFLOW_ENABLE_WORKSPACES", "true")
@pytest.fixture
def app(monkeypatch):
flask_app = Flask(__name__)
for rule, view_func, methods in get_endpoints():
flask_app.add_url_rule(rule, view_func=view_func, methods=methods)
return flask_app
@pytest.fixture
def mock_workspace_store(monkeypatch):
store = mock.Mock()
monkeypatch.setattr(
"mlflow.server.handlers._get_workspace_store",
lambda *_, **__: store,
)
return store
@pytest.fixture
def mock_tracking_store(monkeypatch):
store = mock.Mock()
store.artifact_root_uri = "/default/artifact/root"
monkeypatch.setattr(
"mlflow.server.handlers._get_tracking_store",
lambda *_, **__: store,
)
return store
def _workspace_to_json(payload):
return json.loads(payload)
def test_list_workspaces_endpoint(app, mock_workspace_store):
mock_workspace_store.list_workspaces.return_value = [
Workspace(name="default", description="Default"),
Workspace(name="team-a", description=None),
]
with app.test_client() as client:
response = client.get("/api/3.0/mlflow/workspaces")
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload["workspaces"][0] == {"name": "default", "description": "Default"}
assert payload["workspaces"][1] == {"name": "team-a"}
mock_workspace_store.list_workspaces.assert_called_once_with()
def test_create_workspace_endpoint(app, mock_workspace_store, mock_tracking_store):
created = Workspace(
name="team-b",
description="Team B",
trace_archival_location="s3://archive/team-b",
trace_archival_retention="30d",
)
mock_workspace_store.create_workspace.return_value = created
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={
"name": "team-b",
"description": "Team B",
"trace_archival_config": {
"location": "s3://archive/team-b",
"retention": "30d",
},
},
)
assert response.status_code == 201
payload = _workspace_to_json(response.get_data(True))
assert payload == {
"workspace": {
"name": "team-b",
"description": "Team B",
"trace_archival_config": {
"location": "s3://archive/team-b",
"retention": "30d",
},
}
}
mock_workspace_store.create_workspace.assert_called_once()
args, _ = mock_workspace_store.create_workspace.call_args
assert args[0].trace_archival_location == "s3://archive/team-b"
assert args[0].trace_archival_retention == "30d"
mock_tracking_store.get_experiment_by_name.assert_not_called()
mock_tracking_store.create_experiment.assert_not_called()
def test_get_workspace_endpoint(app, mock_workspace_store):
mock_workspace_store.get_workspace.return_value = Workspace(name="team-c", description="Team C")
with app.test_client() as client:
response = client.get("/api/3.0/mlflow/workspaces/team-c")
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {"workspace": {"name": "team-c", "description": "Team C"}}
mock_workspace_store.get_workspace.assert_called_once_with("team-c")
def test_update_workspace_endpoint(app, mock_workspace_store):
updated = Workspace(
name="team-d",
description="Updated",
trace_archival_location="s3://archive/team-d",
trace_archival_retention="14d",
)
mock_workspace_store.update_workspace.return_value = updated
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-d",
json={
"description": "Updated",
"trace_archival_config": {
"location": "s3://archive/team-d",
"retention": "14d",
},
},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {
"workspace": {
"name": "team-d",
"description": "Updated",
"trace_archival_config": {
"location": "s3://archive/team-d",
"retention": "14d",
},
}
}
mock_workspace_store.update_workspace.assert_called_once()
args, _ = mock_workspace_store.update_workspace.call_args
assert args[0].trace_archival_location == "s3://archive/team-d"
assert args[0].trace_archival_retention == "14d"
def test_update_default_workspace_allows_reserved_name(app, mock_workspace_store):
updated = Workspace(name="default", default_artifact_root="s3://bucket/root")
mock_workspace_store.update_workspace.return_value = updated
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/default",
json={"default_artifact_root": "s3://bucket/root"},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {
"workspace": {"name": "default", "default_artifact_root": "s3://bucket/root"}
}
args, _ = mock_workspace_store.update_workspace.call_args
assert args[0].name == "default"
assert args[0].default_artifact_root == "s3://bucket/root"
def test_update_workspace_can_clear_default_artifact_root(
app, mock_workspace_store, mock_tracking_store
):
cleared = Workspace(name="team-clear", description=None, default_artifact_root=None)
mock_workspace_store.update_workspace.return_value = cleared
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-clear",
json={"default_artifact_root": " "},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {"workspace": {"name": "team-clear"}}
args, _ = mock_workspace_store.update_workspace.call_args
assert isinstance(args[0], Workspace)
assert args[0].name == "team-clear"
# Handler passes "" to indicate "clear"; the store converts "" to None
assert args[0].default_artifact_root == ""
def test_update_workspace_can_clear_trace_archival_location(app, mock_workspace_store):
cleared = Workspace(name="team-clear", description=None, trace_archival_location=None)
mock_workspace_store.update_workspace.return_value = cleared
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-clear",
json={"trace_archival_config": {"location": ""}},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {"workspace": {"name": "team-clear"}}
args, _ = mock_workspace_store.update_workspace.call_args
assert isinstance(args[0], Workspace)
assert args[0].name == "team-clear"
assert args[0].trace_archival_location == ""
def test_update_workspace_can_clear_trace_archival_retention(app, mock_workspace_store):
cleared = Workspace(name="team-clear", description=None, trace_archival_retention=None)
mock_workspace_store.update_workspace.return_value = cleared
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-clear",
json={"trace_archival_config": {"retention": ""}},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {"workspace": {"name": "team-clear"}}
args, _ = mock_workspace_store.update_workspace.call_args
assert isinstance(args[0], Workspace)
assert args[0].name == "team-clear"
assert args[0].trace_archival_retention == ""
def test_update_workspace_with_only_trace_archival_location(app, mock_workspace_store):
updated = Workspace(name="team-only-location", trace_archival_location="s3://archive/team-only")
mock_workspace_store.update_workspace.return_value = updated
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-only-location",
json={"trace_archival_config": {"location": "s3://archive/team-only"}},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {
"workspace": {
"name": "team-only-location",
"trace_archival_config": {"location": "s3://archive/team-only"},
}
}
args, _ = mock_workspace_store.update_workspace.call_args
assert args[0].trace_archival_location == "s3://archive/team-only"
assert args[0].trace_archival_retention is None
def test_update_workspace_with_only_trace_archival_retention(app, mock_workspace_store):
updated = Workspace(name="team-only-retention", trace_archival_retention="14d")
mock_workspace_store.update_workspace.return_value = updated
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-only-retention",
json={"trace_archival_config": {"retention": "14d"}},
)
assert response.status_code == 200
payload = _workspace_to_json(response.get_data(True))
assert payload == {
"workspace": {
"name": "team-only-retention",
"trace_archival_config": {"retention": "14d"},
}
}
args, _ = mock_workspace_store.update_workspace.call_args
assert args[0].trace_archival_location is None
assert args[0].trace_archival_retention == "14d"
def test_create_workspace_rejects_invalid_trace_archival_retention(
app, mock_workspace_store, mock_tracking_store
):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={"name": "team-bad", "trace_archival_config": {"retention": "90days"}},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.retention'. Expected a duration in the form "
"`<int><unit>`, where unit is one of 'm', 'h', or 'd' (for example '30d' or '12h')."
)
mock_workspace_store.create_workspace.assert_not_called()
def test_create_workspace_rejects_invalid_trace_archival_location(
app, mock_workspace_store, mock_tracking_store
):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={
"name": "team-bad",
"trace_archival_config": {"location": "s3://archive/team#fragment"},
},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert "trace_archival_config.location" in payload["message"]
mock_workspace_store.create_workspace.assert_not_called()
def test_create_workspace_rejects_proxy_only_trace_archival_location(
app, mock_workspace_store, mock_tracking_store
):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={
"name": "team-proxy",
"trace_archival_config": {"location": "mlflow-artifacts:/archive/team-proxy"},
},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.location'. Trace archival location cannot use "
"the proxy-only `mlflow-artifacts:` scheme."
)
mock_workspace_store.create_workspace.assert_not_called()
def test_create_workspace_rejects_non_uri_trace_archival_location(
app, mock_workspace_store, mock_tracking_store
):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={
"name": "team-local-path",
"trace_archival_config": {"location": "archive/team-local-path"},
},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.location'. Expected a URI string."
)
mock_workspace_store.create_workspace.assert_not_called()
def test_update_workspace_rejects_invalid_trace_archival_retention(app, mock_workspace_store):
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-bad",
json={"trace_archival_config": {"retention": "5w"}},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.retention'. Expected a duration in the form "
"`<int><unit>`, where unit is one of 'm', 'h', or 'd' (for example '30d' or '12h')."
)
mock_workspace_store.update_workspace.assert_not_called()
def test_create_workspace_rejects_trace_archival_retention_longer_than_32_chars(
app, mock_workspace_store, mock_tracking_store
):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={"name": "team-bad", "trace_archival_config": {"retention": f"{'1' * 32}d"}},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.retention'. Maximum length is 32 characters."
)
mock_workspace_store.create_workspace.assert_not_called()
def test_update_workspace_rejects_trace_archival_retention_longer_than_32_chars(
app, mock_workspace_store
):
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-bad",
json={"trace_archival_config": {"retention": f"{'1' * 32}d"}},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert payload["message"] == (
"Invalid value for 'trace_archival_config.retention'. Maximum length is 32 characters."
)
mock_workspace_store.update_workspace.assert_not_called()
def test_delete_workspace_endpoint(app, mock_workspace_store):
with app.test_client() as client:
response = client.delete("/api/3.0/mlflow/workspaces/team-e")
assert response.status_code == 204
mock_workspace_store.delete_workspace.assert_called_once_with(
"team-e", mode=WorkspaceDeletionMode.RESTRICT
)
def test_delete_default_workspace_rejected_by_validation(app, mock_workspace_store):
with app.test_client() as client:
response = client.delete("/api/3.0/mlflow/workspaces/default")
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert "cannot be deleted" in payload["message"]
mock_workspace_store.delete_workspace.assert_not_called()
def test_create_workspace_fails_without_artifact_root(app, mock_workspace_store, monkeypatch):
tracking_store = mock.Mock()
tracking_store.artifact_root_uri = None
monkeypatch.setattr(
"mlflow.server.handlers._get_tracking_store",
lambda *_, **__: tracking_store,
)
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={"name": "team-no-root"},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert "artifact root" in payload["message"].lower()
def test_create_workspace_with_artifact_root_succeeds_without_server_default(
app, mock_workspace_store, monkeypatch
):
tracking_store = mock.Mock()
tracking_store.artifact_root_uri = None
monkeypatch.setattr(
"mlflow.server.handlers._get_tracking_store",
lambda *_, **__: tracking_store,
)
created = Workspace(name="team-with-root", default_artifact_root="s3://bucket/path")
mock_workspace_store.create_workspace.return_value = created
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={"name": "team-with-root", "default_artifact_root": "s3://bucket/path"},
)
assert response.status_code == 201
def test_create_default_workspace_rejected(app, mock_workspace_store, mock_tracking_store):
with app.test_client() as client:
response = client.post(
"/api/3.0/mlflow/workspaces",
json={"name": "default"},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert "reserved" in payload["message"]
mock_workspace_store.create_workspace.assert_not_called()
def test_update_workspace_clear_artifact_root_fails_without_server_default(
app, mock_workspace_store, monkeypatch
):
tracking_store = mock.Mock()
tracking_store.artifact_root_uri = None
monkeypatch.setattr(
"mlflow.server.handlers._get_tracking_store",
lambda *_, **__: tracking_store,
)
with app.test_client() as client:
response = client.patch(
"/api/3.0/mlflow/workspaces/team-clear",
json={"default_artifact_root": ""},
)
assert response.status_code == 400
payload = _workspace_to_json(response.get_data(True))
assert "artifact root" in payload["message"].lower()