2519 lines
94 KiB
Python
2519 lines
94 KiB
Python
import concurrent.futures
|
|
import shutil
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, text
|
|
|
|
from mlflow.entities.model_registry import (
|
|
ModelVersion,
|
|
ModelVersionTag,
|
|
RegisteredModelTag,
|
|
)
|
|
from mlflow.entities.model_registry.prompt_version import IS_PROMPT_TAG_KEY
|
|
from mlflow.entities.webhook import WebhookAction, WebhookEntity, WebhookEvent, WebhookStatus
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_GO_STORE_TESTING,
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_TRACKING_URI,
|
|
)
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.prompt.constants import PROMPT_TEXT_TAG_KEY
|
|
from mlflow.protos.databricks_pb2 import (
|
|
INVALID_PARAMETER_VALUE,
|
|
RESOURCE_ALREADY_EXISTS,
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
ErrorCode,
|
|
)
|
|
from mlflow.store.model_registry.dbmodels.models import (
|
|
SqlModelVersion,
|
|
SqlModelVersionTag,
|
|
SqlRegisteredModel,
|
|
SqlRegisteredModelTag,
|
|
SqlWebhook,
|
|
)
|
|
from mlflow.store.model_registry.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.store.model_registry.sqlalchemy_workspace_store import (
|
|
WorkspaceAwareSqlAlchemyStore,
|
|
)
|
|
from mlflow.utils.workspace_context import WorkspaceContext
|
|
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
|
|
|
|
from tests.helper_functions import random_str
|
|
|
|
pytestmark = pytest.mark.notrackingurimock
|
|
|
|
GO_MOCK_TIME_TAG = "mock.time.go.testing.tag"
|
|
|
|
|
|
@pytest.fixture(autouse=True, params=[False, True], ids=["workspace-disabled", "workspace-enabled"])
|
|
def workspaces_enabled(request, monkeypatch, disable_workspace_mode_by_default):
|
|
"""
|
|
Run every test in this module with workspaces disabled and enabled to cover both code paths.
|
|
"""
|
|
enabled = request.param
|
|
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true" if enabled else "false")
|
|
if enabled:
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
yield enabled
|
|
else:
|
|
yield enabled
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path, cached_db: Path, workspaces_enabled):
|
|
store_cls = WorkspaceAwareSqlAlchemyStore if workspaces_enabled else SqlAlchemyStore
|
|
if db_uri_env := MLFLOW_TRACKING_URI.get():
|
|
s = store_cls(db_uri_env)
|
|
yield s
|
|
_cleanup_database(s)
|
|
else:
|
|
db_path = tmp_path / "mlflow.db"
|
|
shutil.copy(cached_db, db_path)
|
|
db_uri = f"sqlite:///{db_path}"
|
|
s = store_cls(db_uri)
|
|
yield s
|
|
|
|
# Dispose the engine to close all pooled connections
|
|
s.engine.dispose()
|
|
|
|
|
|
def _cleanup_database(store: SqlAlchemyStore):
|
|
with store.ManagedSessionMaker() as session:
|
|
# Delete all rows in all tables
|
|
for model in (
|
|
SqlModelVersionTag,
|
|
SqlRegisteredModelTag,
|
|
SqlModelVersion,
|
|
SqlRegisteredModel,
|
|
SqlWebhook,
|
|
):
|
|
session.query(model).delete()
|
|
|
|
|
|
def _rm_maker(store, name, tags=None, description=None):
|
|
return store.create_registered_model(name, tags, description)
|
|
|
|
|
|
def _add_go_test_tags(tags, val):
|
|
if _MLFLOW_GO_STORE_TESTING.get():
|
|
return tags + [RegisteredModelTag(GO_MOCK_TIME_TAG, val)]
|
|
return tags
|
|
|
|
|
|
def _mv_maker(
|
|
store,
|
|
name,
|
|
source="path/to/source",
|
|
run_id=uuid.uuid4().hex,
|
|
tags=None,
|
|
run_link=None,
|
|
description=None,
|
|
):
|
|
return store.create_model_version(
|
|
name, source, run_id, tags, run_link=run_link, description=description
|
|
)
|
|
|
|
|
|
def _extract_latest_by_stage(latest_versions):
|
|
return {mvd.current_stage: str(mvd.version) for mvd in latest_versions}
|
|
|
|
|
|
def test_create_registered_model(store):
|
|
name = random_str() + "abCD"
|
|
rm1 = _rm_maker(store, name)
|
|
assert rm1.name == name
|
|
assert rm1.description is None
|
|
|
|
# error on duplicate
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model \(name={name}\) already exists"
|
|
) as exception_context:
|
|
_rm_maker(store, name)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_ALREADY_EXISTS)
|
|
|
|
# slightly different name is ok
|
|
for name2 in [name + "extra", name + name]:
|
|
rm2 = _rm_maker(store, name2)
|
|
assert rm2.name == name2
|
|
|
|
# test create model with tags
|
|
name2 = random_str() + "tags"
|
|
tags = [
|
|
RegisteredModelTag("key", "value"),
|
|
RegisteredModelTag("anotherKey", "some other value"),
|
|
]
|
|
rm2 = _rm_maker(store, name2, tags)
|
|
rmd2 = store.get_registered_model(name2)
|
|
assert rm2.name == name2
|
|
assert rm2.tags == {tag.key: tag.value for tag in tags}
|
|
assert rmd2.name == name2
|
|
assert rmd2.tags == {tag.key: tag.value for tag in tags}
|
|
|
|
# create with description
|
|
name3 = random_str() + "-description"
|
|
description = "the best model ever"
|
|
rm3 = _rm_maker(store, name3, description=description)
|
|
rmd3 = store.get_registered_model(name3)
|
|
assert rm3.name == name3
|
|
assert rm3.description == description
|
|
assert rmd3.name == name3
|
|
assert rmd3.description == description
|
|
|
|
# invalid model name will fail
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'"
|
|
) as exception_context:
|
|
_rm_maker(store, None)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'"
|
|
) as exception_context:
|
|
_rm_maker(store, "")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_get_registered_model(store):
|
|
name = "model_1"
|
|
tags = [
|
|
RegisteredModelTag("key", "value"),
|
|
RegisteredModelTag("anotherKey", "some other value"),
|
|
]
|
|
# use fake clock
|
|
with mock.patch("time.time", return_value=1234):
|
|
rm = _rm_maker(store, name, _add_go_test_tags(tags, "1234000"))
|
|
assert rm.name == name
|
|
|
|
rmd = store.get_registered_model(name=name)
|
|
assert rmd.name == name
|
|
assert rmd.creation_timestamp == 1234000
|
|
assert rmd.last_updated_timestamp == 1234000
|
|
assert rmd.description is None
|
|
assert rmd.latest_versions == []
|
|
assert rmd.tags == {tag.key: tag.value for tag in tags}
|
|
|
|
|
|
def test_update_registered_model(store):
|
|
name = "model_for_update_RM"
|
|
rm1 = _rm_maker(store, name)
|
|
rmd1 = store.get_registered_model(name=name)
|
|
assert rm1.name == name
|
|
assert rmd1.description is None
|
|
|
|
# update description
|
|
rm2 = store.update_registered_model(name=name, description="test model")
|
|
rmd2 = store.get_registered_model(name=name)
|
|
assert rm2.name == "model_for_update_RM"
|
|
assert rmd2.name == "model_for_update_RM"
|
|
assert rmd2.description == "test model"
|
|
|
|
|
|
def test_rename_registered_model(store):
|
|
original_name = "original name"
|
|
new_name = "new name"
|
|
_rm_maker(store, original_name)
|
|
_mv_maker(store, original_name)
|
|
_mv_maker(store, original_name)
|
|
rm = store.get_registered_model(original_name)
|
|
mv1 = store.get_model_version(original_name, 1)
|
|
mv2 = store.get_model_version(original_name, 2)
|
|
assert rm.name == original_name
|
|
assert mv1.name == original_name
|
|
assert mv2.name == original_name
|
|
|
|
# test renaming registered model also updates its model versions
|
|
store.rename_registered_model(original_name, new_name)
|
|
rm = store.get_registered_model(new_name)
|
|
mv1 = store.get_model_version(new_name, 1)
|
|
mv2 = store.get_model_version(new_name, 2)
|
|
assert rm.name == new_name
|
|
assert mv1.name == new_name
|
|
assert mv2.name == new_name
|
|
|
|
# test accessing the model with the old name will fail
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={original_name} not found"
|
|
) as exception_context:
|
|
store.get_registered_model(original_name)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# test name another model with the replaced name is ok
|
|
_rm_maker(store, original_name)
|
|
# cannot rename model to conflict with an existing model
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Registered Model \(name={original_name}\) already exists",
|
|
) as exception_context:
|
|
store.rename_registered_model(new_name, original_name)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_ALREADY_EXISTS)
|
|
# invalid model name will fail
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'new_name'"
|
|
) as exception_context:
|
|
store.rename_registered_model(original_name, None)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'new_name'"
|
|
) as exception_context:
|
|
store.rename_registered_model(original_name, "")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_delete_registered_model(store):
|
|
name = "model_for_delete_RM"
|
|
_rm_maker(store, name)
|
|
_mv_maker(store, name)
|
|
rm1 = store.get_registered_model(name=name)
|
|
mv1 = store.get_model_version(name, 1)
|
|
assert rm1.name == name
|
|
assert mv1.name == name
|
|
|
|
# delete model
|
|
store.delete_registered_model(name=name)
|
|
|
|
# cannot get model
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={name} not found"
|
|
) as exception_context:
|
|
store.get_registered_model(name=name)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# cannot update a delete model
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={name} not found"
|
|
) as exception_context:
|
|
store.update_registered_model(name=name, description="deleted")
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# cannot delete it again
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={name} not found"
|
|
) as exception_context:
|
|
store.delete_registered_model(name=name)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# model versions are cascade deleted with the registered model
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Model Version \(name={name}, version=1\) not found"
|
|
) as exception_context:
|
|
store.get_model_version(name, 1)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
|
|
def test_get_latest_versions(store):
|
|
name = "test_for_latest_versions"
|
|
_rm_maker(store, name)
|
|
rmd1 = store.get_registered_model(name=name)
|
|
assert rmd1.latest_versions == []
|
|
|
|
mv1 = _mv_maker(store, name)
|
|
assert mv1.version == 1
|
|
rmd2 = store.get_registered_model(name=name)
|
|
assert _extract_latest_by_stage(rmd2.latest_versions) == {"None": "1"}
|
|
|
|
# add a bunch more
|
|
mv2 = _mv_maker(store, name)
|
|
assert mv2.version == 2
|
|
store.transition_model_version_stage(
|
|
name=mv2.name,
|
|
version=mv2.version,
|
|
stage="Production",
|
|
archive_existing_versions=False,
|
|
)
|
|
|
|
mv3 = _mv_maker(store, name)
|
|
assert mv3.version == 3
|
|
store.transition_model_version_stage(
|
|
name=mv3.name,
|
|
version=mv3.version,
|
|
stage="Production",
|
|
archive_existing_versions=False,
|
|
)
|
|
mv4 = _mv_maker(store, name)
|
|
assert mv4.version == 4
|
|
store.transition_model_version_stage(
|
|
name=mv4.name,
|
|
version=mv4.version,
|
|
stage="Staging",
|
|
archive_existing_versions=False,
|
|
)
|
|
|
|
# test that correct latest versions are returned for each stage
|
|
rmd4 = store.get_registered_model(name=name)
|
|
assert _extract_latest_by_stage(rmd4.latest_versions) == {
|
|
"None": "1",
|
|
"Production": "3",
|
|
"Staging": "4",
|
|
}
|
|
assert _extract_latest_by_stage(store.get_latest_versions(name=name, stages=None)) == {
|
|
"None": "1",
|
|
"Production": "3",
|
|
"Staging": "4",
|
|
}
|
|
assert _extract_latest_by_stage(store.get_latest_versions(name=name, stages=[])) == {
|
|
"None": "1",
|
|
"Production": "3",
|
|
"Staging": "4",
|
|
}
|
|
assert _extract_latest_by_stage(
|
|
store.get_latest_versions(name=name, stages=["Production"])
|
|
) == {"Production": "3"}
|
|
assert _extract_latest_by_stage(
|
|
store.get_latest_versions(name=name, stages=["production"])
|
|
) == {"Production": "3"} # The stages are case insensitive.
|
|
assert _extract_latest_by_stage(
|
|
store.get_latest_versions(name=name, stages=["pROduction"])
|
|
) == {"Production": "3"} # The stages are case insensitive.
|
|
assert _extract_latest_by_stage(
|
|
store.get_latest_versions(name=name, stages=["None", "Production"])
|
|
) == {"None": "1", "Production": "3"}
|
|
|
|
# delete latest Production, and should point to previous one
|
|
store.delete_model_version(name=mv3.name, version=mv3.version)
|
|
rmd5 = store.get_registered_model(name=name)
|
|
assert _extract_latest_by_stage(rmd5.latest_versions) == {
|
|
"None": "1",
|
|
"Production": "2",
|
|
"Staging": "4",
|
|
}
|
|
assert _extract_latest_by_stage(store.get_latest_versions(name=name, stages=None)) == {
|
|
"None": "1",
|
|
"Production": "2",
|
|
"Staging": "4",
|
|
}
|
|
assert _extract_latest_by_stage(
|
|
store.get_latest_versions(name=name, stages=["Production"])
|
|
) == {"Production": "2"}
|
|
|
|
|
|
def test_set_registered_model_tag(store):
|
|
name1 = "SetRegisteredModelTag_TestMod"
|
|
name2 = "SetRegisteredModelTag_TestMod 2"
|
|
initial_tags = [
|
|
RegisteredModelTag("key", "value"),
|
|
RegisteredModelTag("anotherKey", "some other value"),
|
|
]
|
|
_rm_maker(store, name1, initial_tags)
|
|
_rm_maker(store, name2, initial_tags)
|
|
new_tag = RegisteredModelTag("randomTag", "not a random value")
|
|
store.set_registered_model_tag(name1, new_tag)
|
|
rm1 = store.get_registered_model(name=name1)
|
|
all_tags = initial_tags + [new_tag]
|
|
assert rm1.tags == {tag.key: tag.value for tag in all_tags}
|
|
|
|
# test overriding a tag with the same key
|
|
overriding_tag = RegisteredModelTag("key", "overriding")
|
|
store.set_registered_model_tag(name1, overriding_tag)
|
|
all_tags = [tag for tag in all_tags if tag.key != "key"] + [overriding_tag]
|
|
rm1 = store.get_registered_model(name=name1)
|
|
assert rm1.tags == {tag.key: tag.value for tag in all_tags}
|
|
# does not affect other models with the same key
|
|
rm2 = store.get_registered_model(name=name2)
|
|
assert rm2.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# can not set tag on deleted (non-existed) registered model
|
|
store.delete_registered_model(name1)
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={name1} not found"
|
|
) as exception_context:
|
|
store.set_registered_model_tag(name1, overriding_tag)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
# test cannot set tags that are too long
|
|
long_tag = RegisteredModelTag("longTagKey", "a" * 100_001)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"'value' exceeds the maximum length of \d+ characters",
|
|
) as exception_context:
|
|
store.set_registered_model_tag(name2, long_tag)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# test can set tags that are somewhat long
|
|
long_tag = RegisteredModelTag("longTagKey", "a" * 4999)
|
|
store.set_registered_model_tag(name2, long_tag)
|
|
# can not set invalid tag
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'key'"
|
|
) as exception_context:
|
|
store.set_registered_model_tag(name2, RegisteredModelTag(key=None, value=""))
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# can not use invalid model name
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'"
|
|
) as exception_context:
|
|
store.set_registered_model_tag(None, RegisteredModelTag(key="key", value="value"))
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_delete_registered_model_tag(store):
|
|
name1 = "DeleteRegisteredModelTag_TestMod"
|
|
name2 = "DeleteRegisteredModelTag_TestMod 2"
|
|
initial_tags = [
|
|
RegisteredModelTag("key", "value"),
|
|
RegisteredModelTag("anotherKey", "some other value"),
|
|
]
|
|
_rm_maker(store, name1, initial_tags)
|
|
_rm_maker(store, name2, initial_tags)
|
|
new_tag = RegisteredModelTag("randomTag", "not a random value")
|
|
store.set_registered_model_tag(name1, new_tag)
|
|
store.delete_registered_model_tag(name1, "randomTag")
|
|
rm1 = store.get_registered_model(name=name1)
|
|
assert rm1.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# testing deleting a key does not affect other models with the same key
|
|
store.delete_registered_model_tag(name1, "key")
|
|
rm1 = store.get_registered_model(name=name1)
|
|
rm2 = store.get_registered_model(name=name2)
|
|
assert rm1.tags == {"anotherKey": "some other value"}
|
|
assert rm2.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# delete tag that is already deleted does nothing
|
|
store.delete_registered_model_tag(name1, "key")
|
|
rm1 = store.get_registered_model(name=name1)
|
|
assert rm1.tags == {"anotherKey": "some other value"}
|
|
|
|
# can not delete tag on deleted (non-existed) registered model
|
|
store.delete_registered_model(name1)
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Registered Model with name={name1} not found"
|
|
) as exception_context:
|
|
store.delete_registered_model_tag(name1, "anotherKey")
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
# can not delete tag with invalid key
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'key'"
|
|
) as exception_context:
|
|
store.delete_registered_model_tag(name2, None)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# can not use invalid model name
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'"
|
|
) as exception_context:
|
|
store.delete_registered_model_tag(None, "key")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_create_model_version(store):
|
|
name = "test_for_update_MV"
|
|
_rm_maker(store, name)
|
|
run_id = uuid.uuid4().hex
|
|
with mock.patch("time.time", return_value=456778):
|
|
mv1 = _mv_maker(store, name, "a/b/CD", run_id)
|
|
assert mv1.name == name
|
|
assert mv1.version == 1
|
|
|
|
mvd1 = store.get_model_version(mv1.name, mv1.version)
|
|
assert mvd1.name == name
|
|
assert int(mvd1.version) == 1
|
|
assert mvd1.current_stage == "None"
|
|
assert mvd1.creation_timestamp == 456778000
|
|
assert mvd1.last_updated_timestamp == 456778000
|
|
assert mvd1.description is None
|
|
assert mvd1.source == "a/b/CD"
|
|
assert mvd1.run_id == run_id
|
|
assert mvd1.status == "READY"
|
|
assert mvd1.status_message is None
|
|
assert mvd1.tags == {}
|
|
|
|
# new model versions for same name autoincrement versions
|
|
mv2 = _mv_maker(store, name)
|
|
mvd2 = store.get_model_version(name=mv2.name, version=mv2.version)
|
|
assert mv2.version == 2
|
|
assert int(mvd2.version) == 2
|
|
|
|
# create model version with tags return model version entity with tags
|
|
tags = [
|
|
ModelVersionTag("key", "value"),
|
|
ModelVersionTag("anotherKey", "some other value"),
|
|
]
|
|
mv3 = _mv_maker(store, name, tags=tags)
|
|
mvd3 = store.get_model_version(name=mv3.name, version=mv3.version)
|
|
assert mv3.version == 3
|
|
assert mv3.tags == {tag.key: tag.value for tag in tags}
|
|
assert int(mvd3.version) == 3
|
|
assert mvd3.tags == {tag.key: tag.value for tag in tags}
|
|
|
|
# create model versions with runLink
|
|
run_link = "http://localhost:3000/path/to/run/"
|
|
mv4 = _mv_maker(store, name, run_link=run_link)
|
|
mvd4 = store.get_model_version(name, mv4.version)
|
|
assert mv4.version == 4
|
|
assert mv4.run_link == run_link
|
|
assert int(mvd4.version) == 4
|
|
assert mvd4.run_link == run_link
|
|
|
|
# create model version with description
|
|
description = "the best model ever"
|
|
mv5 = _mv_maker(store, name, description=description)
|
|
mvd5 = store.get_model_version(name, mv5.version)
|
|
assert mv5.version == 5
|
|
assert mv5.description == description
|
|
assert int(mvd5.version) == 5
|
|
assert mvd5.description == description
|
|
|
|
# create model version without runId
|
|
mv6 = _mv_maker(store, name, run_id=None)
|
|
mvd6 = store.get_model_version(name, mv6.version)
|
|
assert mv6.version == 6
|
|
assert mv6.run_id is None
|
|
assert int(mvd6.version) == 6
|
|
assert mvd6.run_id is None
|
|
|
|
|
|
def test_update_model_version(store):
|
|
name = "test_for_update_MV"
|
|
_rm_maker(store, name)
|
|
mv1 = _mv_maker(store, name)
|
|
mvd1 = store.get_model_version(name=mv1.name, version=mv1.version)
|
|
assert mvd1.name == name
|
|
assert int(mvd1.version) == 1
|
|
assert mvd1.current_stage == "None"
|
|
|
|
# update stage
|
|
store.transition_model_version_stage(
|
|
name=mv1.name,
|
|
version=mv1.version,
|
|
stage="Production",
|
|
archive_existing_versions=False,
|
|
)
|
|
mvd2 = store.get_model_version(name=mv1.name, version=mv1.version)
|
|
assert mvd2.name == name
|
|
assert int(mvd2.version) == 1
|
|
assert mvd2.current_stage == "Production"
|
|
assert mvd2.description is None
|
|
|
|
# update description
|
|
store.update_model_version(name=mv1.name, version=mv1.version, description="test model version")
|
|
mvd3 = store.get_model_version(name=mv1.name, version=mv1.version)
|
|
assert mvd3.name == name
|
|
assert int(mvd3.version) == 1
|
|
assert mvd3.current_stage == "Production"
|
|
assert mvd3.description == "test model version"
|
|
|
|
# only valid stages can be set
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
"Invalid Model Version stage: unknown. "
|
|
"Value must be one of None, Staging, Production, Archived."
|
|
),
|
|
) as exception_context:
|
|
store.transition_model_version_stage(
|
|
mv1.name, mv1.version, stage="unknown", archive_existing_versions=False
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# stages are case-insensitive and auto-corrected to system stage names
|
|
for stage_name in ["STAGING", "staging", "StAgInG"]:
|
|
store.transition_model_version_stage(
|
|
name=mv1.name,
|
|
version=mv1.version,
|
|
stage=stage_name,
|
|
archive_existing_versions=False,
|
|
)
|
|
mvd5 = store.get_model_version(name=mv1.name, version=mv1.version)
|
|
assert mvd5.current_stage == "Staging"
|
|
|
|
|
|
def test_transition_model_version_stage_when_archive_existing_versions_is_false(store):
|
|
name = "model"
|
|
_rm_maker(store, name)
|
|
mv1 = _mv_maker(store, name)
|
|
mv2 = _mv_maker(store, name)
|
|
mv3 = _mv_maker(store, name)
|
|
|
|
# test that when `archive_existing_versions` is False, transitioning a model version
|
|
# to the inactive stages ("Archived" and "None") does not throw.
|
|
for stage in ["Archived", "None"]:
|
|
store.transition_model_version_stage(name, mv1.version, stage, False)
|
|
|
|
store.transition_model_version_stage(name, mv1.version, "Staging", False)
|
|
store.transition_model_version_stage(name, mv2.version, "Production", False)
|
|
store.transition_model_version_stage(name, mv3.version, "Staging", False)
|
|
|
|
mvd1 = store.get_model_version(name=name, version=mv1.version)
|
|
mvd2 = store.get_model_version(name=name, version=mv2.version)
|
|
mvd3 = store.get_model_version(name=name, version=mv3.version)
|
|
|
|
assert mvd1.current_stage == "Staging"
|
|
assert mvd2.current_stage == "Production"
|
|
assert mvd3.current_stage == "Staging"
|
|
|
|
store.transition_model_version_stage(name, mv3.version, "Production", False)
|
|
|
|
mvd1 = store.get_model_version(name=name, version=mv1.version)
|
|
mvd2 = store.get_model_version(name=name, version=mv2.version)
|
|
mvd3 = store.get_model_version(name=name, version=mv3.version)
|
|
|
|
assert mvd1.current_stage == "Staging"
|
|
assert mvd2.current_stage == "Production"
|
|
assert mvd3.current_stage == "Production"
|
|
|
|
|
|
def test_transition_model_version_stage_when_archive_existing_versions_is_true(store):
|
|
name = "model"
|
|
_rm_maker(store, name)
|
|
mv1 = _mv_maker(store, name)
|
|
mv2 = _mv_maker(store, name)
|
|
mv3 = _mv_maker(store, name)
|
|
|
|
msg = (
|
|
r"Model version transition cannot archive existing model versions "
|
|
r"because .+ is not an Active stage"
|
|
)
|
|
|
|
# test that when `archive_existing_versions` is True, transitioning a model version
|
|
# to the inactive stages ("Archived" and "None") throws.
|
|
for stage in ["Archived", "None"]:
|
|
with pytest.raises(MlflowException, match=msg):
|
|
store.transition_model_version_stage(name, mv1.version, stage, True)
|
|
|
|
store.transition_model_version_stage(name, mv1.version, "Staging", False)
|
|
store.transition_model_version_stage(name, mv2.version, "Production", False)
|
|
store.transition_model_version_stage(name, mv3.version, "Staging", True)
|
|
|
|
mvd1 = store.get_model_version(name=name, version=mv1.version)
|
|
mvd2 = store.get_model_version(name=name, version=mv2.version)
|
|
mvd3 = store.get_model_version(name=name, version=mv3.version)
|
|
|
|
assert mvd1.current_stage == "Archived"
|
|
assert mvd2.current_stage == "Production"
|
|
assert mvd3.current_stage == "Staging"
|
|
assert mvd1.last_updated_timestamp == mvd3.last_updated_timestamp
|
|
|
|
store.transition_model_version_stage(name, mv3.version, "Production", True)
|
|
|
|
mvd1 = store.get_model_version(name=name, version=mv1.version)
|
|
mvd2 = store.get_model_version(name=name, version=mv2.version)
|
|
mvd3 = store.get_model_version(name=name, version=mv3.version)
|
|
|
|
assert mvd1.current_stage == "Archived"
|
|
assert mvd2.current_stage == "Archived"
|
|
assert mvd3.current_stage == "Production"
|
|
assert mvd2.last_updated_timestamp == mvd3.last_updated_timestamp
|
|
|
|
for uncanonical_stage_name in ["STAGING", "staging", "StAgInG"]:
|
|
store.transition_model_version_stage(mv1.name, mv1.version, "Staging", False)
|
|
store.transition_model_version_stage(mv2.name, mv2.version, "None", False)
|
|
|
|
# stage names are case-insensitive and auto-corrected to system stage names
|
|
store.transition_model_version_stage(mv2.name, mv2.version, uncanonical_stage_name, True)
|
|
|
|
mvd1 = store.get_model_version(name=mv1.name, version=mv1.version)
|
|
mvd2 = store.get_model_version(name=mv2.name, version=mv2.version)
|
|
assert mvd1.current_stage == "Archived"
|
|
assert mvd2.current_stage == "Staging"
|
|
|
|
|
|
def test_delete_model_version(store):
|
|
name = "test_for_delete_MV"
|
|
initial_tags = [
|
|
ModelVersionTag("key", "value"),
|
|
ModelVersionTag("anotherKey", "some other value"),
|
|
]
|
|
_rm_maker(store, name)
|
|
mv = _mv_maker(store, name, tags=initial_tags)
|
|
mvd = store.get_model_version(name=mv.name, version=mv.version)
|
|
assert mvd.name == name
|
|
|
|
store.delete_model_version(name=mv.name, version=mv.version)
|
|
|
|
# cannot get a deleted model version
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Model Version \(name={mv.name}, version={mv.version}\) not found",
|
|
) as exception_context:
|
|
store.get_model_version(name=mv.name, version=mv.version)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# cannot update a delete
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Model Version \(name={mv.name}, version={mv.version}\) not found",
|
|
) as exception_context:
|
|
store.update_model_version(mv.name, mv.version, description="deleted!")
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
# cannot delete it again
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Model Version \(name={mv.name}, version={mv.version}\) not found",
|
|
) as exception_context:
|
|
store.delete_model_version(name=mv.name, version=mv.version)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
|
|
def test_delete_model_version_redaction(store):
|
|
name = "test_for_delete_MV_redaction"
|
|
run_link = "http://localhost:5000/path/to/run"
|
|
run_id = "12345"
|
|
source = "path/to/source"
|
|
_rm_maker(store, name)
|
|
mv = _mv_maker(store, name, source=source, run_id=run_id, run_link=run_link)
|
|
mvd = store.get_model_version(name=name, version=mv.version)
|
|
assert mvd.run_link == run_link
|
|
assert mvd.run_id == run_id
|
|
assert mvd.source == source
|
|
# delete the MV now
|
|
store.delete_model_version(name, mv.version)
|
|
# verify that the relevant fields are redacted
|
|
mvd_deleted = store._get_sql_model_version_including_deleted(name=name, version=mv.version)
|
|
assert "REDACTED" in mvd_deleted.run_link
|
|
assert "REDACTED" in mvd_deleted.source
|
|
assert "REDACTED" in mvd_deleted.run_id
|
|
|
|
|
|
def test_get_model_version_download_uri(store):
|
|
name = "test_for_update_MV"
|
|
_rm_maker(store, name)
|
|
source_path = "path/to/source"
|
|
mv = _mv_maker(store, name, source=source_path, run_id=uuid.uuid4().hex)
|
|
mvd1 = store.get_model_version(name=mv.name, version=mv.version)
|
|
assert mvd1.name == name
|
|
assert mvd1.source == source_path
|
|
|
|
# download location points to source
|
|
assert store.get_model_version_download_uri(name=mv.name, version=mv.version) == source_path
|
|
|
|
# download URI does not change even if model version is updated
|
|
store.transition_model_version_stage(
|
|
name=mv.name,
|
|
version=mv.version,
|
|
stage="Production",
|
|
archive_existing_versions=False,
|
|
)
|
|
store.update_model_version(name=mv.name, version=mv.version, description="Test for Path")
|
|
mvd2 = store.get_model_version(name=mv.name, version=mv.version)
|
|
assert mvd2.source == source_path
|
|
assert store.get_model_version_download_uri(name=mv.name, version=mv.version) == source_path
|
|
|
|
# cannot retrieve download URI for deleted model versions
|
|
store.delete_model_version(name=mv.name, version=mv.version)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Model Version \(name={mv.name}, version={mv.version}\) not found",
|
|
) as exception_context:
|
|
store.get_model_version_download_uri(name=mv.name, version=mv.version)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
|
|
|
|
def test_search_model_versions(store):
|
|
# create some model versions
|
|
name = "test_for_search_MV"
|
|
_rm_maker(store, name)
|
|
run_id_1 = uuid.uuid4().hex
|
|
run_id_2 = uuid.uuid4().hex
|
|
run_id_3 = uuid.uuid4().hex
|
|
mv1 = _mv_maker(store, name=name, source="A/B", run_id=run_id_1)
|
|
assert mv1.version == 1
|
|
mv2 = _mv_maker(store, name=name, source="A/C", run_id=run_id_2)
|
|
assert mv2.version == 2
|
|
mv3 = _mv_maker(store, name=name, source="A/D", run_id=run_id_2)
|
|
assert mv3.version == 3
|
|
mv4 = _mv_maker(store, name=name, source="A/D", run_id=run_id_3)
|
|
assert mv4.version == 4
|
|
|
|
def search_versions(filter_string, max_results=10, order_by=None, page_token=None):
|
|
return [
|
|
mvd.version
|
|
for mvd in store.search_model_versions(filter_string, max_results, order_by, page_token)
|
|
]
|
|
|
|
# search using name should return all 4 versions
|
|
assert set(search_versions(f"name='{name}'")) == {1, 2, 3, 4}
|
|
|
|
# search using version
|
|
assert set(search_versions("version_number=2")) == {2}
|
|
assert set(search_versions("version_number<=3")) == {1, 2, 3}
|
|
|
|
# search using run_id_1 should return version 1
|
|
assert set(search_versions(f"run_id='{run_id_1}'")) == {1}
|
|
|
|
# search using run_id_2 should return versions 2 and 3
|
|
assert set(search_versions(f"run_id='{run_id_2}'")) == {2, 3}
|
|
|
|
# search using the IN operator should return all versions
|
|
assert set(search_versions(f"run_id IN ('{run_id_1}','{run_id_2}')")) == {1, 2, 3}
|
|
|
|
# search IN operator is case sensitive
|
|
assert set(search_versions(f"run_id IN ('{run_id_1.upper()}','{run_id_2}')")) == {
|
|
2,
|
|
3,
|
|
}
|
|
|
|
# search IN operator with other conditions
|
|
assert set(
|
|
search_versions(f"version_number=2 AND run_id IN ('{run_id_1.upper()}','{run_id_2}')")
|
|
) == {2}
|
|
|
|
# search IN operator with right-hand side value containing whitespaces
|
|
assert set(search_versions(f"run_id IN ('{run_id_1}', '{run_id_2}')")) == {1, 2, 3}
|
|
|
|
# search using the IN operator with bad lists should return exceptions
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
r"While parsing a list in the query, "
|
|
r"expected string value, punctuation, or whitespace, "
|
|
r"but got different type in list"
|
|
),
|
|
) as exception_context:
|
|
search_versions("run_id IN (1,2,3)")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
assert set(search_versions(f"run_id LIKE '{run_id_2[:30]}%'")) == {2, 3}
|
|
|
|
assert set(search_versions(f"run_id ILIKE '{run_id_2[:30].upper()}%'")) == {2, 3}
|
|
|
|
# search using the IN operator with empty lists should return exceptions
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
r"While parsing a list in the query, "
|
|
r"expected a non-empty list of string values, "
|
|
r"but got empty list"
|
|
),
|
|
) as exception_context:
|
|
search_versions("run_id IN ()")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# search using an ill-formed IN operator correctly throws exception
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid clause\(s\) in filter string"
|
|
) as exception_context:
|
|
search_versions("run_id IN (")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid clause\(s\) in filter string"
|
|
) as exception_context:
|
|
search_versions("run_id IN")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid clause\(s\) in filter string"
|
|
) as exception_context:
|
|
search_versions("name LIKE")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
r"While parsing a list in the query, "
|
|
r"expected a non-empty list of string values, "
|
|
r"but got ill-formed list"
|
|
),
|
|
) as exception_context:
|
|
search_versions("run_id IN (,)")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=(
|
|
r"While parsing a list in the query, "
|
|
r"expected a non-empty list of string values, "
|
|
r"but got ill-formed list"
|
|
),
|
|
) as exception_context:
|
|
search_versions("run_id IN ('runid1',,'runid2')")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# search using source_path "A/D" should return version 3 and 4
|
|
assert set(search_versions("source_path = 'A/D'")) == {3, 4}
|
|
|
|
# search using source_path "A" should not return anything
|
|
assert len(search_versions("source_path = 'A'")) == 0
|
|
assert len(search_versions("source_path = 'A/'")) == 0
|
|
assert len(search_versions("source_path = ''")) == 0
|
|
|
|
# delete mv4. search should not return version 4
|
|
store.delete_model_version(name=mv4.name, version=mv4.version)
|
|
assert set(search_versions("")) == {1, 2, 3}
|
|
|
|
assert set(search_versions(None)) == {1, 2, 3}
|
|
|
|
assert set(search_versions(f"name='{name}'")) == {1, 2, 3}
|
|
|
|
assert set(search_versions("source_path = 'A/D'")) == {3}
|
|
|
|
store.transition_model_version_stage(
|
|
name=mv1.name,
|
|
version=mv1.version,
|
|
stage="production",
|
|
archive_existing_versions=False,
|
|
)
|
|
|
|
store.update_model_version(
|
|
name=mv1.name, version=mv1.version, description="Online prediction model!"
|
|
)
|
|
|
|
mvds = store.search_model_versions(f"run_id = '{run_id_1}'", max_results=10)
|
|
assert len(mvds) == 1
|
|
assert isinstance(mvds[0], ModelVersion)
|
|
assert mvds[0].current_stage == "Production"
|
|
assert mvds[0].run_id == run_id_1
|
|
assert mvds[0].source == "A/B"
|
|
assert mvds[0].description == "Online prediction model!"
|
|
|
|
|
|
def test_search_model_versions_order_by_simple(store):
|
|
# create some model versions
|
|
names = ["RM1", "RM2", "RM3", "RM4", "RM1", "RM4"]
|
|
sources = ["A"] * 3 + ["B"] * 3
|
|
run_ids = [uuid.uuid4().hex for _ in range(6)]
|
|
for name in set(names):
|
|
_rm_maker(store, name)
|
|
for i in range(6):
|
|
time.sleep(0.001) # sleep to ensure each model version has a different creation_time
|
|
_mv_maker(store, name=names[i], source=sources[i], run_id=run_ids[i])
|
|
|
|
# by default order by last_updated_timestamp DESC
|
|
mvs = store.search_model_versions(filter_string=None)
|
|
assert [mv.name for mv in mvs] == names[::-1]
|
|
assert [mv.version for mv in mvs] == [2, 2, 1, 1, 1, 1]
|
|
|
|
# order by name DESC
|
|
mvs = store.search_model_versions(filter_string=None, order_by=["name DESC"])
|
|
assert [mv.name for mv in mvs] == sorted(names)[::-1]
|
|
assert [mv.version for mv in mvs] == [2, 1, 1, 1, 2, 1]
|
|
|
|
# order by version DESC
|
|
mvs = store.search_model_versions(filter_string=None, order_by=["version_number DESC"])
|
|
assert [mv.name for mv in mvs] == ["RM1", "RM4", "RM1", "RM2", "RM3", "RM4"]
|
|
assert [mv.version for mv in mvs] == [2, 2, 1, 1, 1, 1]
|
|
|
|
# order by creation_timestamp DESC
|
|
mvs = store.search_model_versions(filter_string=None, order_by=["creation_timestamp DESC"])
|
|
assert [mv.name for mv in mvs] == names[::-1]
|
|
assert [mv.version for mv in mvs] == [2, 2, 1, 1, 1, 1]
|
|
|
|
# order by last_updated_timestamp ASC
|
|
store.update_model_version(names[0], 1, "latest updated")
|
|
mvs = store.search_model_versions(filter_string=None, order_by=["last_updated_timestamp ASC"])
|
|
assert mvs[-1].name == names[0]
|
|
assert mvs[-1].version == 1
|
|
|
|
|
|
def test_search_model_versions_order_by_errors(store):
|
|
# create some model versions
|
|
name = "RM1"
|
|
_rm_maker(store, name)
|
|
for _ in range(6):
|
|
_mv_maker(store, name=name)
|
|
query = "name LIKE 'RM%'"
|
|
# test that invalid columns throw even if they come after valid columns
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid attribute key '.+' specified"
|
|
) as exception_context:
|
|
store.search_model_versions(
|
|
query,
|
|
page_token=None,
|
|
order_by=["name ASC", "run_id DESC"],
|
|
max_results=5,
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# test that invalid columns with random text throw even if they come after valid columns
|
|
with pytest.raises(MlflowException, match=r"Invalid order_by clause '.+'") as exception_context:
|
|
store.search_model_versions(
|
|
query,
|
|
page_token=None,
|
|
order_by=["name ASC", "last_updated_timestamp DESC blah"],
|
|
max_results=5,
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_search_model_versions_pagination(store):
|
|
def search_versions(filter_string, page_token=None, max_results=10):
|
|
result = store.search_model_versions(
|
|
filter_string=filter_string, page_token=page_token, max_results=max_results
|
|
)
|
|
return result.to_list(), result.token
|
|
|
|
name = "test_for_search_MV_pagination"
|
|
_rm_maker(store, name)
|
|
mvs = [_mv_maker(store, name) for _ in range(50)][::-1]
|
|
|
|
# test flow with fixed max_results
|
|
returned_mvs = []
|
|
query = "name LIKE 'test_for_search_MV_pagination%'"
|
|
result, token = search_versions(query, page_token=None, max_results=5)
|
|
returned_mvs.extend(result)
|
|
while token:
|
|
result, token = search_versions(query, page_token=token, max_results=5)
|
|
returned_mvs.extend(result)
|
|
assert mvs == returned_mvs
|
|
|
|
# test that pagination will return all valid results in sorted order
|
|
# by name ascending
|
|
result, token1 = search_versions(query, max_results=5)
|
|
assert token1 is not None
|
|
assert result == mvs[0:5]
|
|
|
|
result, token2 = search_versions(query, page_token=token1, max_results=10)
|
|
assert token2 is not None
|
|
assert result == mvs[5:15]
|
|
|
|
result, token3 = search_versions(query, page_token=token2, max_results=20)
|
|
assert token3 is not None
|
|
assert result == mvs[15:35]
|
|
|
|
result, token4 = search_versions(query, page_token=token3, max_results=100)
|
|
# assert that page token is None
|
|
assert token4 is None
|
|
assert result == mvs[35:]
|
|
|
|
# test that providing a completely invalid page token throws
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid page token, could not base64-decode"
|
|
) as exception_context:
|
|
search_versions(query, page_token="evilhax", max_results=20)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# test that providing too large of a max_results throws
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid value for max_results\."
|
|
) as exception_context:
|
|
search_versions(query, page_token="evilhax", max_results=1e15)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_search_model_versions_by_tag(store):
|
|
# create some model versions
|
|
name = "test_for_search_MV_by_tag"
|
|
_rm_maker(store, name)
|
|
run_id_1 = uuid.uuid4().hex
|
|
run_id_2 = uuid.uuid4().hex
|
|
|
|
mv1 = _mv_maker(
|
|
store,
|
|
name=name,
|
|
source="A/B",
|
|
run_id=run_id_1,
|
|
tags=[ModelVersionTag("t1", "abc"), ModelVersionTag("t2", "xyz")],
|
|
)
|
|
assert mv1.version == 1
|
|
mv2 = _mv_maker(
|
|
store,
|
|
name=name,
|
|
source="A/C",
|
|
run_id=run_id_2,
|
|
tags=[ModelVersionTag("t1", "abc"), ModelVersionTag("t2", "x123")],
|
|
)
|
|
assert mv2.version == 2
|
|
|
|
def search_versions(filter_string):
|
|
return [mvd.version for mvd in store.search_model_versions(filter_string)]
|
|
|
|
assert search_versions(f"name = '{name}' and tag.t2 = 'xyz'") == [1]
|
|
assert search_versions("name = 'wrong_name' and tag.t2 = 'xyz'") == []
|
|
assert search_versions("tag.`t2` = 'xyz'") == [1]
|
|
assert search_versions("tag.t3 = 'xyz'") == []
|
|
assert search_versions("tag.t2 != 'xy'") == [2, 1]
|
|
assert search_versions("tag.t2 LIKE 'xy%'") == [1]
|
|
assert search_versions("tag.t2 LIKE 'xY%'") == []
|
|
assert search_versions("tag.t2 ILIKE 'xY%'") == [1]
|
|
assert search_versions("tag.t2 LIKE 'x%'") == [2, 1]
|
|
assert search_versions("tag.T2 = 'xyz'") == []
|
|
assert search_versions("tag.t1 = 'abc' and tag.t2 = 'xyz'") == [1]
|
|
assert search_versions("tag.t1 = 'abc' and tag.t2 LIKE 'x%'") == [2, 1]
|
|
assert search_versions("tag.t1 = 'abc' and tag.t2 LIKE 'y%'") == []
|
|
# test filter with duplicated keys
|
|
assert search_versions("tag.t2 like 'x%' and tag.t2 != 'xyz'") == [2]
|
|
|
|
|
|
def _assert_workspace_in_main_queries(captured_sql, table_name, context_label):
|
|
"""Assert that every main search query (ORDER BY + LIMIT) includes workspace in WHERE."""
|
|
main_queries = [s for s in captured_sql if "ORDER BY" in s and table_name in s and "LIMIT" in s]
|
|
assert main_queries, f"No main search query found for {context_label}"
|
|
for sql in main_queries:
|
|
where_clause = sql.split("WHERE", 1)[-1] if "WHERE" in sql else ""
|
|
assert "workspace" in where_clause.lower(), (
|
|
f"{context_label} missing workspace predicate in WHERE — "
|
|
f"causes full table scan on (workspace, ...) PK:\n{sql}"
|
|
)
|
|
|
|
|
|
def test_search_model_versions_includes_workspace_predicate(store):
|
|
from sqlalchemy import event
|
|
|
|
name = "test_ws_predicate_mv"
|
|
_rm_maker(store, name)
|
|
_mv_maker(store, name=name, source="A/B", tags=[ModelVersionTag("t1", "abc")])
|
|
|
|
for filter_string in [
|
|
f"name = '{name}'",
|
|
"tag.t1 = 'abc'",
|
|
f"name = '{name}' AND tag.t1 = 'abc'",
|
|
]:
|
|
captured_sql: list[str] = []
|
|
|
|
def _capture(conn, cursor, statement, parameters, context, executemany):
|
|
captured_sql.append(statement)
|
|
|
|
event.listen(store.engine, "before_cursor_execute", _capture)
|
|
try:
|
|
store.search_model_versions(filter_string)
|
|
finally:
|
|
event.remove(store.engine, "before_cursor_execute", _capture)
|
|
|
|
_assert_workspace_in_main_queries(
|
|
captured_sql, "model_versions", f"search_model_versions('{filter_string}')"
|
|
)
|
|
|
|
|
|
def test_search_registered_models_includes_workspace_predicate(store):
|
|
from sqlalchemy import event
|
|
|
|
name = "test_ws_predicate_rm"
|
|
_rm_maker(store, name, tags=[RegisteredModelTag("t1", "abc")])
|
|
|
|
for filter_string in [
|
|
f"name = '{name}'",
|
|
"tag.t1 = 'abc'",
|
|
f"name = '{name}' AND tag.t1 = 'abc'",
|
|
]:
|
|
captured_sql: list[str] = []
|
|
|
|
def _capture(conn, cursor, statement, parameters, context, executemany):
|
|
captured_sql.append(statement)
|
|
|
|
event.listen(store.engine, "before_cursor_execute", _capture)
|
|
try:
|
|
store.search_registered_models(filter_string)
|
|
finally:
|
|
event.remove(store.engine, "before_cursor_execute", _capture)
|
|
|
|
_assert_workspace_in_main_queries(
|
|
captured_sql,
|
|
"registered_models",
|
|
f"search_registered_models('{filter_string}')",
|
|
)
|
|
|
|
|
|
def _search_registered_models(store, filter_string, max_results=10, order_by=None, page_token=None):
|
|
result = store.search_registered_models(
|
|
filter_string=filter_string,
|
|
max_results=max_results,
|
|
order_by=order_by,
|
|
page_token=page_token,
|
|
)
|
|
return [registered_model.name for registered_model in result], result.token
|
|
|
|
|
|
def test_search_registered_models(store):
|
|
# create some registered models
|
|
prefix = "test_for_search_"
|
|
names = [prefix + name for name in ["RM1", "RM2", "RM3", "RM4", "RM4A", "RM4ab"]]
|
|
for name in names:
|
|
_rm_maker(store, name)
|
|
|
|
# search with no filter should return all registered models
|
|
rms, _ = _search_registered_models(store, None)
|
|
assert rms == names
|
|
|
|
# equality search using name should return exactly the 1 name
|
|
rms, _ = _search_registered_models(store, f"name='{names[0]}'")
|
|
assert rms == [names[0]]
|
|
|
|
# equality search using name that is not valid should return nothing
|
|
rms, _ = _search_registered_models(store, "name='{}'".format(names[0] + "cats"))
|
|
assert rms == []
|
|
|
|
# case-sensitive prefix search using LIKE should return all the RMs
|
|
rms, _ = _search_registered_models(store, f"name LIKE '{prefix}%'")
|
|
assert rms == names
|
|
|
|
# case-sensitive prefix search using LIKE with surrounding % should return all the RMs
|
|
rms, _ = _search_registered_models(store, "name LIKE '%RM%'")
|
|
assert rms == names
|
|
|
|
# case-sensitive prefix search using LIKE with surrounding % should return all the RMs
|
|
# _e% matches test_for_search_ , so all RMs should match
|
|
rms, _ = _search_registered_models(store, "name LIKE '_e%'")
|
|
assert rms == names
|
|
|
|
# case-sensitive prefix search using LIKE should return just rm4
|
|
rms, _ = _search_registered_models(store, "name LIKE '{}%'".format(prefix + "RM4A"))
|
|
assert rms == [names[4]]
|
|
|
|
# case-sensitive prefix search using LIKE should return no models if no match
|
|
rms, _ = _search_registered_models(store, "name LIKE '{}%'".format(prefix + "cats"))
|
|
assert rms == []
|
|
|
|
# confirm that LIKE is not case-sensitive
|
|
rms, _ = _search_registered_models(store, "name lIkE '%blah%'")
|
|
assert rms == []
|
|
|
|
rms, _ = _search_registered_models(store, "name like '{}%'".format(prefix + "RM4A"))
|
|
assert rms == [names[4]]
|
|
|
|
# case-insensitive prefix search using ILIKE should return both rm5 and rm6
|
|
rms, _ = _search_registered_models(store, "name ILIKE '{}%'".format(prefix + "RM4A"))
|
|
assert rms == names[4:]
|
|
|
|
# case-insensitive postfix search with ILIKE
|
|
rms, _ = _search_registered_models(store, "name ILIKE '%RM4a%'")
|
|
assert rms == names[4:]
|
|
|
|
# case-insensitive prefix search using ILIKE should return both rm5 and rm6
|
|
rms, _ = _search_registered_models(store, "name ILIKE '{}%'".format(prefix + "cats"))
|
|
assert rms == []
|
|
|
|
# confirm that ILIKE is not case-sensitive
|
|
rms, _ = _search_registered_models(store, "name iLike '%blah%'")
|
|
assert rms == []
|
|
|
|
# confirm that ILIKE works for empty query
|
|
rms, _ = _search_registered_models(store, "name iLike '%%'")
|
|
assert rms == names
|
|
|
|
rms, _ = _search_registered_models(store, "name ilike '%RM4a%'")
|
|
assert rms == names[4:]
|
|
|
|
# cannot search by invalid comparator types
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="Parameter value is either not quoted or unidentified quote types used for "
|
|
"string value something",
|
|
) as exception_context:
|
|
_search_registered_models(store, "name!=something")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# cannot search by run_id
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid attribute key 'run_id' specified."
|
|
) as exception_context:
|
|
_search_registered_models(store, "run_id='somerunID'")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# cannot search by source_path
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid attribute key 'source_path' specified."
|
|
) as exception_context:
|
|
_search_registered_models(store, "source_path = 'A/D'")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# cannot search by other params
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid clause\(s\) in filter string"
|
|
) as exception_context:
|
|
_search_registered_models(store, "evilhax = true")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# delete last registered model. search should not return the first 5
|
|
store.delete_registered_model(name=names[-1])
|
|
assert _search_registered_models(store, None, max_results=1000) == (
|
|
names[:-1],
|
|
None,
|
|
)
|
|
|
|
# equality search using name should return no names
|
|
assert _search_registered_models(store, f"name='{names[-1]}'") == ([], None)
|
|
|
|
# case-sensitive prefix search using LIKE should return all the RMs
|
|
assert _search_registered_models(store, f"name LIKE '{prefix}%'") == (
|
|
names[0:5],
|
|
None,
|
|
)
|
|
|
|
# case-insensitive prefix search using ILIKE should return both rm5 and rm6
|
|
assert _search_registered_models(store, "name ILIKE '{}%'".format(prefix + "RM4A")) == (
|
|
[names[4]],
|
|
None,
|
|
)
|
|
|
|
|
|
def test_search_registered_models_by_tag(store):
|
|
name1 = "test_for_search_RM_by_tag1"
|
|
name2 = "test_for_search_RM_by_tag2"
|
|
tags1 = [
|
|
RegisteredModelTag("t1", "abc"),
|
|
RegisteredModelTag("t2", "xyz"),
|
|
]
|
|
tags2 = [
|
|
RegisteredModelTag("t1", "abcd"),
|
|
RegisteredModelTag("t2", "xyz123"),
|
|
RegisteredModelTag("t3", "XYZ"),
|
|
]
|
|
_rm_maker(store, name1, tags1)
|
|
_rm_maker(store, name2, tags2)
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t3 = 'XYZ'")
|
|
assert rms == [name2]
|
|
|
|
rms, _ = _search_registered_models(store, f"name = '{name1}' and tag.t1 = 'abc'")
|
|
assert rms == [name1]
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t1 LIKE 'ab%'")
|
|
assert rms == [name1, name2]
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t1 ILIKE 'aB%'")
|
|
assert rms == [name1, name2]
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t1 LIKE 'ab%' AND tag.t2 LIKE 'xy%'")
|
|
assert rms == [name1, name2]
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t3 = 'XYz'")
|
|
assert rms == []
|
|
|
|
rms, _ = _search_registered_models(store, "tag.T3 = 'XYZ'")
|
|
assert rms == []
|
|
|
|
rms, _ = _search_registered_models(store, "tag.t1 != 'abc'")
|
|
assert rms == [name2]
|
|
|
|
# test filter with duplicated keys
|
|
rms, _ = _search_registered_models(store, "tag.t1 != 'abcd' and tag.t1 LIKE 'ab%'")
|
|
assert rms == [name1]
|
|
|
|
|
|
def test_parse_search_registered_models_order_by():
|
|
# test that "registered_models.name ASC" is returned by default
|
|
parsed = SqlAlchemyStore._parse_search_registered_models_order_by([])
|
|
assert [str(x) for x in parsed] == ["registered_models.name ASC"]
|
|
|
|
# test that the given 'name' replaces the default one ('registered_models.name ASC')
|
|
parsed = SqlAlchemyStore._parse_search_registered_models_order_by(["name DESC"])
|
|
assert [str(x) for x in parsed] == ["registered_models.name DESC"]
|
|
|
|
# test that an exception is raised when order_by contains duplicate fields
|
|
msg = "`order_by` contains duplicate fields:"
|
|
with pytest.raises(MlflowException, match=msg):
|
|
SqlAlchemyStore._parse_search_registered_models_order_by([
|
|
"last_updated_timestamp",
|
|
"last_updated_timestamp",
|
|
])
|
|
|
|
with pytest.raises(MlflowException, match=msg):
|
|
SqlAlchemyStore._parse_search_registered_models_order_by(["timestamp", "timestamp"])
|
|
|
|
with pytest.raises(MlflowException, match=msg):
|
|
SqlAlchemyStore._parse_search_registered_models_order_by(
|
|
["timestamp", "last_updated_timestamp"],
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=msg):
|
|
SqlAlchemyStore._parse_search_registered_models_order_by(
|
|
["last_updated_timestamp ASC", "last_updated_timestamp DESC"],
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=msg):
|
|
SqlAlchemyStore._parse_search_registered_models_order_by(
|
|
["last_updated_timestamp", "last_updated_timestamp DESC"],
|
|
)
|
|
|
|
|
|
def test_search_registered_model_pagination(store):
|
|
rms = [_rm_maker(store, f"RM{i:03}").name for i in range(50)]
|
|
|
|
# test flow with fixed max_results
|
|
returned_rms = []
|
|
query = "name LIKE 'RM%'"
|
|
result, token = _search_registered_models(store, query, page_token=None, max_results=5)
|
|
returned_rms.extend(result)
|
|
while token:
|
|
result, token = _search_registered_models(store, query, page_token=token, max_results=5)
|
|
returned_rms.extend(result)
|
|
assert rms == returned_rms
|
|
|
|
# test that pagination will return all valid results in sorted order
|
|
# by name ascending
|
|
result, token1 = _search_registered_models(store, query, max_results=5)
|
|
assert token1 is not None
|
|
assert result == rms[0:5]
|
|
|
|
result, token2 = _search_registered_models(store, query, page_token=token1, max_results=10)
|
|
assert token2 is not None
|
|
assert result == rms[5:15]
|
|
|
|
result, token3 = _search_registered_models(store, query, page_token=token2, max_results=20)
|
|
assert token3 is not None
|
|
assert result == rms[15:35]
|
|
|
|
result, token4 = _search_registered_models(store, query, page_token=token3, max_results=100)
|
|
# assert that page token is None
|
|
assert token4 is None
|
|
assert result == rms[35:]
|
|
|
|
# test that providing a completely invalid page token throws
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid page token, could not base64-decode"
|
|
) as exception_context:
|
|
_search_registered_models(store, query, page_token="evilhax", max_results=20)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
# test that providing too large of a max_results throws
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid value for request parameter max_results"
|
|
) as exception_context:
|
|
_search_registered_models(store, query, page_token="evilhax", max_results=1e15)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_search_registered_model_order_by(store):
|
|
rms = []
|
|
# explicitly mock the creation_timestamps because timestamps seem to be unstable in Windows
|
|
for i in range(50):
|
|
with mock.patch(
|
|
"mlflow.store.model_registry.sqlalchemy_store.get_current_time_millis",
|
|
return_value=i,
|
|
):
|
|
rms.append(_rm_maker(store, f"RM{i:03}", _add_go_test_tags([], f"{i}")).name)
|
|
|
|
# test flow with fixed max_results and order_by (test stable order across pages)
|
|
returned_rms = []
|
|
query = "name LIKE 'RM%'"
|
|
result, token = _search_registered_models(
|
|
store, query, page_token=None, order_by=["name DESC"], max_results=5
|
|
)
|
|
returned_rms.extend(result)
|
|
while token:
|
|
result, token = _search_registered_models(
|
|
store, query, page_token=token, order_by=["name DESC"], max_results=5
|
|
)
|
|
returned_rms.extend(result)
|
|
# name descending should be the opposite order of the current order
|
|
assert rms[::-1] == returned_rms
|
|
# last_updated_timestamp descending should have the newest RMs first
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["last_updated_timestamp DESC"],
|
|
max_results=100,
|
|
)
|
|
assert rms[::-1] == result
|
|
# timestamp returns same result as last_updated_timestamp
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp DESC"], max_results=100
|
|
)
|
|
assert rms[::-1] == result
|
|
# last_updated_timestamp ascending should have the oldest RMs first
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["last_updated_timestamp ASC"],
|
|
max_results=100,
|
|
)
|
|
assert rms == result
|
|
# timestamp returns same result as last_updated_timestamp
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp ASC"], max_results=100
|
|
)
|
|
assert rms == result
|
|
# timestamp returns same result as last_updated_timestamp
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp"], max_results=100
|
|
)
|
|
assert rms == result
|
|
# name ascending should have the original order
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["name ASC"], max_results=100
|
|
)
|
|
assert rms == result
|
|
# test that no ASC/DESC defaults to ASC
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["last_updated_timestamp"],
|
|
max_results=100,
|
|
)
|
|
assert rms == result
|
|
with mock.patch(
|
|
"mlflow.store.model_registry.sqlalchemy_store.get_current_time_millis",
|
|
return_value=1,
|
|
):
|
|
rm1 = _rm_maker(store, "MR1", _add_go_test_tags([], "1")).name
|
|
rm2 = _rm_maker(store, "MR2", _add_go_test_tags([], "1")).name
|
|
with mock.patch(
|
|
"mlflow.store.model_registry.sqlalchemy_store.get_current_time_millis",
|
|
return_value=2,
|
|
):
|
|
rm3 = _rm_maker(store, "MR3", _add_go_test_tags([], "2")).name
|
|
rm4 = _rm_maker(store, "MR4", _add_go_test_tags([], "2")).name
|
|
query = "name LIKE 'MR%'"
|
|
# test with multiple clauses
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["last_updated_timestamp ASC", "name DESC"],
|
|
max_results=100,
|
|
)
|
|
assert result == [rm2, rm1, rm4, rm3]
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["timestamp ASC", "name DESC"],
|
|
max_results=100,
|
|
)
|
|
assert result == [rm2, rm1, rm4, rm3]
|
|
# confirm that name ascending is the default, even if ties exist on other fields
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=[], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
# test default tiebreak with descending timestamps
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["last_updated_timestamp DESC"],
|
|
max_results=100,
|
|
)
|
|
assert result == [rm3, rm4, rm1, rm2]
|
|
# test timestamp parsing
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp\tASC"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp\r\rASC"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp\nASC"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp ASC"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
# validate order by key is case-insensitive
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp asc"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
result, _ = _search_registered_models(
|
|
store, query, page_token=None, order_by=["timestamp aSC"], max_results=100
|
|
)
|
|
assert result == [rm1, rm2, rm3, rm4]
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["timestamp desc", "name desc"],
|
|
max_results=100,
|
|
)
|
|
assert result == [rm4, rm3, rm2, rm1]
|
|
result, _ = _search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["timestamp deSc", "name deSc"],
|
|
max_results=100,
|
|
)
|
|
assert result == [rm4, rm3, rm2, rm1]
|
|
|
|
|
|
def test_search_registered_model_order_by_errors(store):
|
|
query = "name LIKE 'RM%'"
|
|
# test that invalid columns throw even if they come after valid columns
|
|
with pytest.raises(
|
|
MlflowException, match=r"Invalid order by key '.+' specified"
|
|
) as exception_context:
|
|
_search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["name ASC", "creation_timestamp DESC"],
|
|
max_results=5,
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# test that invalid columns with random text throw even if they come after valid columns
|
|
with pytest.raises(MlflowException, match=r"Invalid order_by clause '.+'") as exception_context:
|
|
_search_registered_models(
|
|
store,
|
|
query,
|
|
page_token=None,
|
|
order_by=["name ASC", "last_updated_timestamp DESC blah"],
|
|
max_results=5,
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_set_model_version_tag(store):
|
|
name1 = "SetModelVersionTag_TestMod"
|
|
name2 = "SetModelVersionTag_TestMod 2"
|
|
initial_tags = [
|
|
ModelVersionTag("key", "value"),
|
|
ModelVersionTag("anotherKey", "some other value"),
|
|
]
|
|
_rm_maker(store, name1)
|
|
_rm_maker(store, name2)
|
|
run_id_1 = uuid.uuid4().hex
|
|
run_id_2 = uuid.uuid4().hex
|
|
run_id_3 = uuid.uuid4().hex
|
|
_mv_maker(store, name1, "A/B", run_id_1, initial_tags)
|
|
_mv_maker(store, name1, "A/C", run_id_2, initial_tags)
|
|
_mv_maker(store, name2, "A/D", run_id_3, initial_tags)
|
|
new_tag = ModelVersionTag("randomTag", "not a random value")
|
|
store.set_model_version_tag(name1, 1, new_tag)
|
|
all_tags = initial_tags + [new_tag]
|
|
rm1mv1 = store.get_model_version(name1, 1)
|
|
assert rm1mv1.tags == {tag.key: tag.value for tag in all_tags}
|
|
|
|
# test overriding a tag with the same key
|
|
overriding_tag = ModelVersionTag("key", "overriding")
|
|
store.set_model_version_tag(name1, 1, overriding_tag)
|
|
all_tags = [tag for tag in all_tags if tag.key != "key"] + [overriding_tag]
|
|
rm1mv1 = store.get_model_version(name1, 1)
|
|
assert rm1mv1.tags == {tag.key: tag.value for tag in all_tags}
|
|
# does not affect other model versions with the same key
|
|
rm1mv2 = store.get_model_version(name1, 2)
|
|
rm2mv1 = store.get_model_version(name2, 1)
|
|
assert rm1mv2.tags == {tag.key: tag.value for tag in initial_tags}
|
|
assert rm2mv1.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# can not set tag on deleted (non-existed) model version
|
|
store.delete_model_version(name1, 2)
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Model Version \(name={name1}, version=2\) not found"
|
|
) as exception_context:
|
|
store.set_model_version_tag(name1, 2, overriding_tag)
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
# test cannot set tags that are too long
|
|
long_tag = ModelVersionTag("longTagKey", "a" * 100_001)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"'value' exceeds the maximum length of \d+ characters",
|
|
) as exception_context:
|
|
store.set_model_version_tag(name1, 1, long_tag)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# test can set tags that are somewhat long
|
|
long_tag = ModelVersionTag("longTagKey", "a" * 4999)
|
|
store.set_model_version_tag(name1, 1, long_tag)
|
|
# can not set invalid tag
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'key'"
|
|
) as exception_context:
|
|
store.set_model_version_tag(name2, 1, ModelVersionTag(key=None, value=""))
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# can not use invalid model name or version
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'"
|
|
) as exception_context:
|
|
store.set_model_version_tag(None, 1, ModelVersionTag(key="key", value="value"))
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
with pytest.raises(
|
|
MlflowException, match=r"Parameter 'version' must be an integer, got 'I am not a version'"
|
|
) as exception_context:
|
|
store.set_model_version_tag(
|
|
name2, "I am not a version", ModelVersionTag(key="key", value="value")
|
|
)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def test_delete_model_version_tag(store):
|
|
name1 = "DeleteModelVersionTag_TestMod"
|
|
name2 = "DeleteModelVersionTag_TestMod 2"
|
|
initial_tags = [
|
|
ModelVersionTag("key", "value"),
|
|
ModelVersionTag("anotherKey", "some other value"),
|
|
]
|
|
_rm_maker(store, name1)
|
|
_rm_maker(store, name2)
|
|
run_id_1 = uuid.uuid4().hex
|
|
run_id_2 = uuid.uuid4().hex
|
|
run_id_3 = uuid.uuid4().hex
|
|
_mv_maker(store, name1, "A/B", run_id_1, initial_tags)
|
|
_mv_maker(store, name1, "A/C", run_id_2, initial_tags)
|
|
_mv_maker(store, name2, "A/D", run_id_3, initial_tags)
|
|
new_tag = ModelVersionTag("randomTag", "not a random value")
|
|
store.set_model_version_tag(name1, 1, new_tag)
|
|
store.delete_model_version_tag(name1, 1, "randomTag")
|
|
rm1mv1 = store.get_model_version(name1, 1)
|
|
assert rm1mv1.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# testing deleting a key does not affect other model versions with the same key
|
|
store.delete_model_version_tag(name1, 1, "key")
|
|
rm1mv1 = store.get_model_version(name1, 1)
|
|
rm1mv2 = store.get_model_version(name1, 2)
|
|
rm2mv1 = store.get_model_version(name2, 1)
|
|
assert rm1mv1.tags == {"anotherKey": "some other value"}
|
|
assert rm1mv2.tags == {tag.key: tag.value for tag in initial_tags}
|
|
assert rm2mv1.tags == {tag.key: tag.value for tag in initial_tags}
|
|
|
|
# delete tag that is already deleted does nothing
|
|
store.delete_model_version_tag(name1, 1, "key")
|
|
rm1mv1 = store.get_model_version(name1, 1)
|
|
assert rm1mv1.tags == {"anotherKey": "some other value"}
|
|
|
|
# can not delete tag on deleted (non-existed) model version
|
|
store.delete_model_version(name2, 1)
|
|
with pytest.raises(
|
|
MlflowException, match=rf"Model Version \(name={name2}, version=1\) not found"
|
|
) as exception_context:
|
|
store.delete_model_version_tag(name2, 1, "key")
|
|
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
|
|
# can not delete tag with invalid key
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'key'"
|
|
) as exception_context:
|
|
store.delete_model_version_tag(name1, 2, None)
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
# can not use invalid model name or version
|
|
with pytest.raises(
|
|
MlflowException, match=r"Missing value for required parameter 'name'."
|
|
) as exception_context:
|
|
store.delete_model_version_tag(None, 2, "key")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
with pytest.raises(
|
|
MlflowException, match=r"Parameter 'version' must be an integer, got 'I am not a version'"
|
|
) as exception_context:
|
|
store.delete_model_version_tag(name1, "I am not a version", "key")
|
|
assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def _setup_and_test_aliases(store, model_name):
|
|
store.create_registered_model(model_name)
|
|
run_id_1 = uuid.uuid4().hex
|
|
run_id_2 = uuid.uuid4().hex
|
|
store.create_model_version(model_name, "v1", run_id_1)
|
|
store.create_model_version(model_name, "v2", run_id_2)
|
|
store.set_registered_model_alias(model_name, "test_alias", "2")
|
|
model = store.get_registered_model(model_name)
|
|
assert model.aliases == {"test_alias": 2}
|
|
mv1 = store.get_model_version(model_name, 1)
|
|
mv2 = store.get_model_version(model_name, 2)
|
|
assert mv1.aliases == []
|
|
assert mv2.aliases == ["test_alias"]
|
|
|
|
|
|
def test_set_registered_model_alias(store):
|
|
_setup_and_test_aliases(store, "SetRegisteredModelAlias_TestMod")
|
|
|
|
|
|
def test_delete_registered_model_alias(store):
|
|
model_name = "DeleteRegisteredModelAlias_TestMod"
|
|
_setup_and_test_aliases(store, model_name)
|
|
store.delete_registered_model_alias(model_name, "test_alias")
|
|
model = store.get_registered_model(model_name)
|
|
assert model.aliases == {}
|
|
mv2 = store.get_model_version(model_name, 2)
|
|
assert mv2.aliases == []
|
|
|
|
|
|
def test_get_model_version_by_alias(store):
|
|
model_name = "GetModelVersionByAlias_TestMod"
|
|
_setup_and_test_aliases(store, model_name)
|
|
mv = store.get_model_version_by_alias(model_name, "test_alias")
|
|
assert mv.aliases == ["test_alias"]
|
|
|
|
|
|
def test_delete_model_version_deletes_alias(store):
|
|
model_name = "DeleteModelVersionDeletesAlias_TestMod"
|
|
_setup_and_test_aliases(store, model_name)
|
|
store.delete_model_version(model_name, 2)
|
|
model = store.get_registered_model(model_name)
|
|
assert model.aliases == {}
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Registered model alias test_alias not found.",
|
|
):
|
|
store.get_model_version_by_alias(model_name, "test_alias")
|
|
|
|
|
|
def test_delete_model_deletes_alias(store):
|
|
model_name = "DeleteModelDeletesAlias_TestMod"
|
|
_setup_and_test_aliases(store, model_name)
|
|
store.delete_registered_model(model_name)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=rf"Registered Model with name={model_name} not found",
|
|
):
|
|
store.get_model_version_by_alias(model_name, "test_alias")
|
|
|
|
|
|
@pytest.mark.parametrize("copy_to_same_model", [False, True])
|
|
def test_copy_model_version(store, copy_to_same_model):
|
|
name1 = "test_for_copy_MV1"
|
|
store.create_registered_model(name1)
|
|
src_tags = [
|
|
ModelVersionTag("key", "value"),
|
|
ModelVersionTag("anotherKey", "some other value"),
|
|
]
|
|
src_mv = _mv_maker(
|
|
store,
|
|
name1,
|
|
tags=src_tags,
|
|
run_link="dummylink",
|
|
description="test description",
|
|
)
|
|
|
|
# Make some changes to the src MV that won't be copied over
|
|
store.transition_model_version_stage(
|
|
name1, src_mv.version, "Production", archive_existing_versions=False
|
|
)
|
|
|
|
copy_rm_name = name1 if copy_to_same_model else "test_for_copy_MV2"
|
|
copy_mv_version = 2 if copy_to_same_model else 1
|
|
timestamp = time.time()
|
|
dst_mv = store.copy_model_version(src_mv, copy_rm_name)
|
|
assert dst_mv.name == copy_rm_name
|
|
assert dst_mv.version == copy_mv_version
|
|
|
|
copied_mv = store.get_model_version(dst_mv.name, dst_mv.version)
|
|
assert copied_mv.name == copy_rm_name
|
|
assert int(copied_mv.version) == copy_mv_version
|
|
assert copied_mv.current_stage == "None"
|
|
assert copied_mv.creation_timestamp >= timestamp
|
|
assert copied_mv.last_updated_timestamp >= timestamp
|
|
assert copied_mv.description == "test description"
|
|
assert copied_mv.source == f"models:/{src_mv.name}/{src_mv.version}"
|
|
assert store.get_model_version_download_uri(dst_mv.name, dst_mv.version) == src_mv.source
|
|
assert copied_mv.run_link == "dummylink"
|
|
assert copied_mv.run_id == src_mv.run_id
|
|
assert copied_mv.status == "READY"
|
|
assert copied_mv.status_message is None
|
|
assert copied_mv.tags == {"key": "value", "anotherKey": "some other value"}
|
|
|
|
# Copy a model version copy
|
|
double_copy_mv = store.copy_model_version(copied_mv, "test_for_copy_MV3")
|
|
assert double_copy_mv.source == f"models:/{copied_mv.name}/{copied_mv.version}"
|
|
assert store.get_model_version_download_uri(dst_mv.name, dst_mv.version) == src_mv.source
|
|
|
|
|
|
def test_search_prompts(store):
|
|
store.create_registered_model("model", tags=[RegisteredModelTag(key="fruit", value="apple")])
|
|
|
|
store.create_registered_model(
|
|
"prompt_1", tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
)
|
|
store.create_registered_model(
|
|
"prompt_2",
|
|
tags=[
|
|
RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true"),
|
|
RegisteredModelTag(key="fruit", value="apple"),
|
|
],
|
|
)
|
|
|
|
# By default, should not return prompts
|
|
rms = store.search_registered_models(max_results=10)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "model"
|
|
|
|
rms = store.search_registered_models(filter_string="tags.fruit = 'apple'", max_results=10)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "model"
|
|
|
|
rms = store.search_registered_models(filter_string="name = 'prompt_1'", max_results=10)
|
|
assert len(rms) == 0
|
|
|
|
rms = store.search_registered_models(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'false'", max_results=10
|
|
)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "model"
|
|
|
|
rms = store.search_registered_models(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` != 'true'", max_results=10
|
|
)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "model"
|
|
|
|
# Search for prompts
|
|
rms = store.search_registered_models(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'true'", max_results=10
|
|
)
|
|
assert len(rms) == 2
|
|
assert {rm.name for rm in rms} == {"prompt_1", "prompt_2"}
|
|
|
|
rms = store.search_registered_models(
|
|
filter_string="name = 'prompt_1' and tags.`mlflow.prompt.is_prompt` = 'true'",
|
|
max_results=10,
|
|
)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "prompt_1"
|
|
|
|
rms = store.search_registered_models(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'true' and tags.fruit = 'apple'",
|
|
max_results=10,
|
|
)
|
|
assert len(rms) == 1
|
|
assert rms[0].name == "prompt_2"
|
|
|
|
|
|
def test_search_prompts_versions(store):
|
|
# A Model
|
|
store.create_registered_model("model")
|
|
store.create_model_version(
|
|
"model", "1", "dummy_source", tags=[ModelVersionTag(key="fruit", value="apple")]
|
|
)
|
|
|
|
# A Prompt with 1 version
|
|
store.create_registered_model(
|
|
"prompt_1", tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
)
|
|
store.create_model_version(
|
|
"prompt_1", "1", "dummy_source", tags=[ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
)
|
|
|
|
# A Prompt with 2 versions
|
|
store.create_registered_model(
|
|
"prompt_2",
|
|
tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")],
|
|
)
|
|
store.create_model_version(
|
|
"prompt_2",
|
|
"1",
|
|
"dummy_source",
|
|
tags=[
|
|
ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true"),
|
|
ModelVersionTag(key="fruit", value="apple"),
|
|
],
|
|
)
|
|
store.create_model_version(
|
|
"prompt_2",
|
|
"2",
|
|
"dummy_source",
|
|
tags=[
|
|
ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true"),
|
|
ModelVersionTag(key="fruit", value="orange"),
|
|
],
|
|
)
|
|
|
|
# Searching model versions should not return prompts by default either
|
|
mvs = store.search_model_versions(max_results=10)
|
|
assert len(mvs) == 1
|
|
assert mvs[0].name == "model"
|
|
|
|
mvs = store.search_model_versions(filter_string="tags.fruit = 'apple'", max_results=10)
|
|
assert len(mvs) == 1
|
|
assert mvs[0].name == "model"
|
|
|
|
mvs = store.search_model_versions(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'false'", max_results=10
|
|
)
|
|
assert len(mvs) == 1
|
|
assert mvs[0].name == "model"
|
|
|
|
mvs = store.search_model_versions(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` != 'true'", max_results=10
|
|
)
|
|
assert len(mvs) == 1
|
|
assert mvs[0].name == "model"
|
|
|
|
# Search for prompts via search_model_versions
|
|
mvs = store.search_model_versions(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'true'", max_results=10
|
|
)
|
|
assert len(mvs) == 3
|
|
|
|
mvs = store.search_model_versions(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'true' and name = 'prompt_2'",
|
|
max_results=10,
|
|
)
|
|
assert len(mvs) == 2
|
|
|
|
mvs = store.search_model_versions(
|
|
filter_string="tags.`mlflow.prompt.is_prompt` = 'true' and tags.fruit = 'apple'",
|
|
max_results=10,
|
|
)
|
|
assert len(mvs) == 1
|
|
assert mvs[0].name == "prompt_2"
|
|
|
|
|
|
def test_search_prompt_versions(store):
|
|
# Create a prompt with 3 versions
|
|
store.create_registered_model(
|
|
"my_prompt", tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
)
|
|
for i in range(1, 4):
|
|
store.create_model_version(
|
|
"my_prompt",
|
|
str(i),
|
|
"dummy_source",
|
|
tags=[
|
|
ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true"),
|
|
ModelVersionTag(key=PROMPT_TEXT_TAG_KEY, value=f"Hello {{{{name}}}} v{i}"),
|
|
],
|
|
)
|
|
|
|
# Create a different prompt to verify filtering by name
|
|
store.create_registered_model(
|
|
"other_prompt", tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
)
|
|
store.create_model_version(
|
|
"other_prompt",
|
|
"1",
|
|
"dummy_source",
|
|
tags=[
|
|
ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true"),
|
|
ModelVersionTag(key=PROMPT_TEXT_TAG_KEY, value="Other prompt text"),
|
|
],
|
|
)
|
|
|
|
# Search all versions of my_prompt
|
|
results = store.search_prompt_versions("my_prompt")
|
|
assert len(results) == 3
|
|
# Should be ordered by version descending
|
|
assert [pv.version for pv in results] == [3, 2, 1]
|
|
assert all(pv.name == "my_prompt" for pv in results)
|
|
|
|
# Pagination with max_results
|
|
page1 = store.search_prompt_versions("my_prompt", max_results=2)
|
|
assert len(page1) == 2
|
|
assert [pv.version for pv in page1] == [3, 2]
|
|
assert page1.token is not None
|
|
|
|
# Fetch next page
|
|
page2 = store.search_prompt_versions("my_prompt", max_results=2, page_token=page1.token)
|
|
assert len(page2) == 1
|
|
assert page2[0].version == 1
|
|
assert page2.token is None
|
|
|
|
# Search other_prompt returns only its versions
|
|
results = store.search_prompt_versions("other_prompt")
|
|
assert len(results) == 1
|
|
assert results[0].name == "other_prompt"
|
|
|
|
# Searching a non-existent prompt raises
|
|
with pytest.raises(MlflowException, match="not found"):
|
|
store.search_prompt_versions("nonexistent_prompt")
|
|
|
|
# Searching a model (not a prompt) raises
|
|
store.create_registered_model("a_model")
|
|
with pytest.raises(MlflowException, match="registered as a model, not a prompt"):
|
|
store.search_prompt_versions("a_model")
|
|
|
|
|
|
def test_create_registered_model_handle_prompt_properly(store):
|
|
prompt_tags = [RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
|
|
|
|
store.create_registered_model("model")
|
|
|
|
store.create_registered_model("prompt", tags=prompt_tags)
|
|
|
|
with pytest.raises(MlflowException, match=r"Registered Model \(name=model\) already exists"):
|
|
store.create_registered_model("model")
|
|
|
|
with pytest.raises(MlflowException, match=r"Prompt \(name=prompt\) already exists"):
|
|
store.create_registered_model("prompt", tags=prompt_tags)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Tried to create a prompt with name 'model', "
|
|
r"but the name is already taken by a registered model.",
|
|
):
|
|
store.create_registered_model("model", tags=prompt_tags)
|
|
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Tried to create a registered model with name 'prompt', "
|
|
r"but the name is already taken by a prompt.",
|
|
):
|
|
store.create_registered_model("prompt")
|
|
|
|
|
|
def test_create_webhook(store):
|
|
events = [
|
|
WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED),
|
|
WebhookEvent(WebhookEntity.REGISTERED_MODEL, WebhookAction.CREATED),
|
|
]
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=events,
|
|
description="Test webhook",
|
|
secret="secret123",
|
|
status=WebhookStatus.ACTIVE,
|
|
)
|
|
|
|
assert webhook.name == "test_webhook"
|
|
assert webhook.url == "https://example.com/webhook"
|
|
assert webhook.events == events
|
|
assert webhook.description == "Test webhook"
|
|
assert webhook.status == WebhookStatus.ACTIVE
|
|
assert webhook.webhook_id is not None
|
|
assert webhook.creation_timestamp is not None
|
|
assert webhook.last_updated_timestamp is not None
|
|
assert webhook.secret == "secret123"
|
|
|
|
|
|
# Shared test data for invalid webhook names
|
|
INVALID_WEBHOOK_NAMES = [
|
|
("", r"is invalid"),
|
|
(" ", r"is invalid"),
|
|
("webhook<script>", r"is invalid"),
|
|
("webhook@test", r"is invalid"),
|
|
("webhook#hash", r"is invalid"),
|
|
("webhook/slash", r"is invalid"),
|
|
("webhook\\backslash", r"is invalid"),
|
|
("-webhook", r"is invalid"), # Must start with letter or digit
|
|
("webhook-", r"is invalid"), # Must end with letter or digit
|
|
("_webhook", r"is invalid"), # Must start with letter or digit
|
|
("webhook_", r"is invalid"), # Must end with letter or digit
|
|
(".webhook", r"is invalid"), # Must start with letter or digit
|
|
("webhook.", r"is invalid"), # Must end with letter or digit
|
|
("a" * 64, r"is invalid"), # Too long (max 63 chars)
|
|
]
|
|
|
|
|
|
# Shared test data for valid webhook names
|
|
VALID_WEBHOOK_NAMES = [
|
|
"a", # Single character letter
|
|
"1", # Single character digit
|
|
"a1", # Two characters
|
|
"1a", # Start with digit, end with letter
|
|
"webhook123", # Alphanumeric
|
|
"web_hook", # With underscore
|
|
"web-hook", # With hyphen
|
|
"web.hook", # With dot
|
|
"web_hook-123.test", # Mixed special chars
|
|
"A" * 63, # Maximum length
|
|
"1" + "a" * 61 + "1", # Maximum length with digit start/end
|
|
"WebHook123", # Mixed case
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("invalid_name", "expected_match"), INVALID_WEBHOOK_NAMES)
|
|
def test_create_webhook_invalid_names(store, invalid_name, expected_match):
|
|
with pytest.raises(MlflowException, match=expected_match):
|
|
store.create_webhook(
|
|
name=invalid_name,
|
|
url="https://example.com",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("valid_name", VALID_WEBHOOK_NAMES)
|
|
def test_create_webhook_valid_names(store, valid_name):
|
|
webhook = store.create_webhook(
|
|
name=valid_name,
|
|
url="https://example.com",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
assert webhook.name == valid_name
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("invalid_url", "expected_match"),
|
|
[
|
|
("", r"Webhook URL cannot be empty or just whitespace"),
|
|
(" ", r"Webhook URL cannot be empty or just whitespace"),
|
|
("example.com/webhook", r"Invalid webhook URL"),
|
|
("ftp://example.com/webhook", r"Invalid webhook URL scheme"),
|
|
("http://[invalid-url", r"Invalid webhook URL"),
|
|
("invalid_url", r"Invalid webhook URL"),
|
|
],
|
|
)
|
|
def test_create_webhook_invalid_urls(store, invalid_url, expected_match):
|
|
with pytest.raises(MlflowException, match=expected_match):
|
|
store.create_webhook(
|
|
name="test",
|
|
url=invalid_url,
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
|
|
def test_create_webhook_invalid_events(store):
|
|
# Test empty events
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.create_webhook(name="test", url="https://example.com", events=[])
|
|
|
|
# Test non-list events
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.create_webhook(name="test", url="https://example.com", events=())
|
|
|
|
# Test list with non-WebhookEvent items
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.create_webhook(name="test", url="https://example.com", events=[1, 2, 3])
|
|
|
|
|
|
def test_get_webhook(store):
|
|
events = [WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)]
|
|
created_webhook = store.create_webhook(
|
|
name="test_webhook", url="https://example.com/webhook", events=events
|
|
)
|
|
|
|
retrieved_webhook = store.get_webhook(created_webhook.webhook_id)
|
|
|
|
assert retrieved_webhook.webhook_id == created_webhook.webhook_id
|
|
assert retrieved_webhook.name == "test_webhook"
|
|
assert retrieved_webhook.url == "https://example.com/webhook"
|
|
assert retrieved_webhook.events == events
|
|
|
|
|
|
def test_get_webhook_not_found(store):
|
|
with pytest.raises(MlflowException, match="Webhook with ID nonexistent not found"):
|
|
store.get_webhook("nonexistent")
|
|
|
|
|
|
def test_list_webhooks(store):
|
|
# Create multiple webhooks
|
|
webhook1 = store.create_webhook(
|
|
name="webhook1",
|
|
url="https://example.com/1",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
webhook2 = store.create_webhook(
|
|
name="webhook2",
|
|
url="https://example.com/2",
|
|
events=[WebhookEvent(WebhookEntity.REGISTERED_MODEL, WebhookAction.CREATED)],
|
|
)
|
|
|
|
webhooks_page = store.list_webhooks()
|
|
|
|
assert len(webhooks_page) == 2
|
|
assert webhooks_page.token is None
|
|
webhook_ids = {w.webhook_id for w in webhooks_page}
|
|
assert webhook1.webhook_id in webhook_ids
|
|
assert webhook2.webhook_id in webhook_ids
|
|
|
|
|
|
def test_list_webhooks_pagination(store):
|
|
# Create more webhooks than max_results
|
|
for i in range(5):
|
|
store.create_webhook(
|
|
name=f"webhook{i}",
|
|
url=f"https://example.com/{i}",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
# Test pagination with max_results=2
|
|
webhooks_page = store.list_webhooks(max_results=2)
|
|
assert len(webhooks_page) == 2
|
|
assert webhooks_page.token is not None
|
|
|
|
# Get next page
|
|
next_webhooks_page = store.list_webhooks(max_results=2, page_token=webhooks_page.token)
|
|
assert len(next_webhooks_page) == 2
|
|
assert next_webhooks_page.token is not None
|
|
|
|
# Verify we don't get duplicates
|
|
first_page_ids = {w.webhook_id for w in webhooks_page}
|
|
second_page_ids = {w.webhook_id for w in next_webhooks_page}
|
|
assert first_page_ids.isdisjoint(second_page_ids)
|
|
|
|
|
|
def test_list_webhooks_invalid_max_results(store):
|
|
with pytest.raises(MlflowException, match="max_results must be between 1 and 1000"):
|
|
store.list_webhooks(max_results=1001)
|
|
|
|
|
|
def test_update_webhook(store):
|
|
events = [WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)]
|
|
webhook = store.create_webhook(
|
|
name="original_name", url="https://example.com/original", events=events
|
|
)
|
|
|
|
# Update webhook
|
|
new_events = [
|
|
WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED),
|
|
WebhookEvent(WebhookEntity.REGISTERED_MODEL, WebhookAction.CREATED),
|
|
]
|
|
updated_webhook = store.update_webhook(
|
|
webhook_id=webhook.webhook_id,
|
|
name="updated_name",
|
|
url="https://example.com/updated",
|
|
events=new_events,
|
|
description="Updated description",
|
|
secret="new_secret",
|
|
status=WebhookStatus.DISABLED,
|
|
)
|
|
|
|
assert updated_webhook.webhook_id == webhook.webhook_id
|
|
assert updated_webhook.name == "updated_name"
|
|
assert updated_webhook.url == "https://example.com/updated"
|
|
assert updated_webhook.events == new_events
|
|
assert updated_webhook.description == "Updated description"
|
|
assert updated_webhook.status == WebhookStatus.DISABLED
|
|
assert updated_webhook.last_updated_timestamp > webhook.last_updated_timestamp
|
|
|
|
|
|
def test_update_webhook_partial(store):
|
|
events = [WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)]
|
|
webhook = store.create_webhook(
|
|
name="original_name", url="https://example.com/original", events=events
|
|
)
|
|
|
|
# Update only name
|
|
updated_webhook = store.update_webhook(webhook_id=webhook.webhook_id, name="new_name")
|
|
|
|
assert updated_webhook.name == "new_name"
|
|
assert updated_webhook.url == "https://example.com/original" # Should remain unchanged
|
|
assert updated_webhook.events == events # Should remain unchanged
|
|
|
|
|
|
def test_update_webhook_not_found(store):
|
|
with pytest.raises(MlflowException, match="Webhook with ID nonexistent not found"):
|
|
store.update_webhook(
|
|
webhook_id="nonexistent", name="new_name", url="https://example.com/new"
|
|
)
|
|
|
|
|
|
def test_update_webhook_invalid_events(store):
|
|
# Create a valid webhook first
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.update_webhook(webhook_id=webhook.webhook_id, events=[])
|
|
|
|
# Test non-list events
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.update_webhook(webhook_id=webhook.webhook_id, events=())
|
|
|
|
# Test list with non-WebhookEvent items
|
|
with pytest.raises(MlflowException, match="Webhook events must be a non-empty list"):
|
|
store.update_webhook(webhook_id=webhook.webhook_id, events=[1, 2, 3])
|
|
|
|
|
|
@pytest.mark.parametrize(("invalid_name", "expected_match"), INVALID_WEBHOOK_NAMES)
|
|
def test_update_webhook_invalid_names(store, invalid_name, expected_match):
|
|
# Create a valid webhook first
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=expected_match):
|
|
store.update_webhook(webhook_id=webhook.webhook_id, name=invalid_name)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("invalid_url", "expected_match"),
|
|
[
|
|
(" ", r"Webhook URL cannot be empty or just whitespace"),
|
|
("ftp://example.com", r"Invalid webhook URL scheme"),
|
|
("http://[invalid", r"Invalid webhook URL"),
|
|
],
|
|
)
|
|
def test_update_webhook_invalid_urls(store, invalid_url, expected_match):
|
|
# Create a valid webhook first
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=expected_match):
|
|
store.update_webhook(webhook_id=webhook.webhook_id, url=invalid_url)
|
|
|
|
|
|
def test_delete_webhook(store):
|
|
events = [WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)]
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=events,
|
|
)
|
|
|
|
store.delete_webhook(webhook.webhook_id)
|
|
|
|
with pytest.raises(MlflowException, match=r"Webhook with ID .* not found"):
|
|
store.get_webhook(webhook.webhook_id)
|
|
|
|
webhooks_page = store.list_webhooks()
|
|
webhook_ids = {w.webhook_id for w in webhooks_page}
|
|
assert webhook.webhook_id not in webhook_ids
|
|
|
|
|
|
def test_delete_webhook_not_found(store):
|
|
with pytest.raises(MlflowException, match="Webhook with ID nonexistent not found"):
|
|
store.delete_webhook("nonexistent")
|
|
|
|
|
|
def test_webhook_status_transitions(store):
|
|
events = [WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)]
|
|
|
|
webhook = store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=events,
|
|
status=WebhookStatus.ACTIVE,
|
|
)
|
|
assert webhook.status == WebhookStatus.ACTIVE
|
|
|
|
# Update to inactive
|
|
updated_webhook = store.update_webhook(
|
|
webhook_id=webhook.webhook_id, status=WebhookStatus.DISABLED
|
|
)
|
|
assert updated_webhook.status == WebhookStatus.DISABLED
|
|
|
|
# Update back to active
|
|
updated_webhook = store.update_webhook(
|
|
webhook_id=webhook.webhook_id, status=WebhookStatus.ACTIVE
|
|
)
|
|
assert updated_webhook.status == WebhookStatus.ACTIVE
|
|
|
|
|
|
def test_webhook_secret_encryption(store):
|
|
store.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
secret="my_secret",
|
|
)
|
|
engine = create_engine(store.db_uri)
|
|
with engine.connect() as conn:
|
|
(raw_secret,) = conn.execute(text("SELECT secret FROM webhooks")).fetchone()
|
|
assert raw_secret is not None
|
|
assert raw_secret != "my_secret" # Should be encrypted
|
|
|
|
|
|
def test_create_model_version_with_model_id_and_no_run_id(store):
|
|
name = "test_model_with_model_id"
|
|
_rm_maker(store, name)
|
|
|
|
mock_run_id = "mock-run-id-123"
|
|
mock_logged_model = mock.MagicMock()
|
|
mock_logged_model.source_run_id = mock_run_id
|
|
|
|
with mock.patch(
|
|
"mlflow.store.model_registry.sqlalchemy_store.MlflowClient"
|
|
) as mock_client_class:
|
|
mock_client = mock.MagicMock()
|
|
mock_client_class.return_value = mock_client
|
|
mock_client.get_logged_model.return_value = mock_logged_model
|
|
|
|
mv = store.create_model_version(
|
|
name=name,
|
|
source="path/to/source",
|
|
run_id=None,
|
|
model_id="test-model-id-456",
|
|
)
|
|
|
|
mock_client.get_logged_model.assert_called_once_with("test-model-id-456")
|
|
|
|
assert mv.run_id == mock_run_id
|
|
|
|
mvd = store.get_model_version(name=mv.name, version=mv.version)
|
|
assert mvd.run_id == mock_run_id
|
|
|
|
|
|
def test_create_model_version_concurrent(store):
|
|
name = "test_concurrent_mv"
|
|
_rm_maker(store, name)
|
|
|
|
num_threads = 4
|
|
versions_per_thread = 5
|
|
results = []
|
|
|
|
def create_versions():
|
|
return [
|
|
store.create_model_version(name, "path/to/source", uuid.uuid4().hex).version
|
|
for _ in range(versions_per_thread)
|
|
]
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=num_threads, thread_name_prefix="create_model_version"
|
|
) as executor:
|
|
futures = [executor.submit(create_versions) for _ in range(num_threads)]
|
|
for f in concurrent.futures.as_completed(futures):
|
|
results.extend(f.result())
|
|
|
|
# All versions should be unique
|
|
assert len(results) == len(set(results))
|
|
assert len(results) == num_threads * versions_per_thread
|