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

658 lines
23 KiB
Python

import os
import random
from typing import Any, NamedTuple
from unittest import mock
import numpy as np
import pandas as pd
import pytest
import sklearn.linear_model as logreg_module
from sklearn import datasets
import mlflow
from mlflow import MlflowClient
from mlflow.entities.model_registry import ModelVersion
from mlflow.environment_variables import MLFLOW_DISABLE_SCHEMA_DETAILS
from mlflow.exceptions import MlflowException
from mlflow.models import add_libraries_to_model
from mlflow.models.utils import (
_config_context,
_convert_llm_input_data,
_enforce_array,
_enforce_datatype,
_enforce_mlflow_datatype,
_enforce_object,
_enforce_property,
_flatten_nested_params,
_validate_and_get_model_code_path,
_validate_model_code_from_notebook,
get_model_version_from_model_uri,
)
from mlflow.pyfunc import _enforce_schema, _validate_prediction_input
from mlflow.types import DataType, Schema
from mlflow.types.schema import Array, ColSpec, Object, Property
class ModelWithData(NamedTuple):
model: Any
inference_data: Any
@pytest.fixture(scope="module")
def sklearn_knn_model():
iris = datasets.load_iris()
X = iris.data[:, :2] # we only take the first two features.
y = iris.target
logreg_model = logreg_module.LogisticRegression()
logreg_model.fit(X, y)
return ModelWithData(model=logreg_model, inference_data=X)
def random_int(lo=1, hi=1000000000):
return random.randint(int(lo), int(hi))
def test_adding_libraries_to_model_default(sklearn_knn_model):
model_name = f"wheels-test-{random_int()}"
artifact_path = "model"
model_uri = f"models:/{model_name}/1"
wheeled_model_uri = f"models:/{model_name}/2"
# Log a model
with mlflow.start_run():
run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
mlflow.sklearn.log_model(
sklearn_knn_model.model,
name=artifact_path,
registered_model_name=model_name,
)
wheeled_model_info = add_libraries_to_model(model_uri)
assert wheeled_model_info.run_id == run_id
# Verify new model version created
wheeled_model_version = get_model_version_from_model_uri(wheeled_model_uri)
assert wheeled_model_version.run_id == run_id
assert wheeled_model_version.name == model_name
def test_adding_libraries_to_model_new_run(sklearn_knn_model):
model_name = f"wheels-test-{random_int()}"
artifact_path = "model"
model_uri = f"models:/{model_name}/1"
wheeled_model_uri = f"models:/{model_name}/2"
# Log a model
with mlflow.start_run():
original_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
mlflow.sklearn.log_model(
sklearn_knn_model.model,
name=artifact_path,
registered_model_name=model_name,
)
with mlflow.start_run():
wheeled_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
wheeled_model_info = add_libraries_to_model(model_uri)
assert original_run_id != wheeled_run_id
assert wheeled_model_info.run_id == wheeled_run_id
# Verify new model version created
wheeled_model_version = get_model_version_from_model_uri(wheeled_model_uri)
assert wheeled_model_version.run_id == wheeled_run_id
assert wheeled_model_version.name == model_name
def test_adding_libraries_to_model_run_id_passed(sklearn_knn_model):
model_name = f"wheels-test-{random_int()}"
artifact_path = "model"
model_uri = f"models:/{model_name}/1"
wheeled_model_uri = f"models:/{model_name}/2"
# Log a model
with mlflow.start_run():
original_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
mlflow.sklearn.log_model(
sklearn_knn_model.model,
name=artifact_path,
registered_model_name=model_name,
)
with mlflow.start_run():
wheeled_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
wheeled_model_info = add_libraries_to_model(model_uri, run_id=wheeled_run_id)
assert original_run_id != wheeled_run_id
assert wheeled_model_info.run_id == wheeled_run_id
# Verify new model version created
wheeled_model_version = get_model_version_from_model_uri(wheeled_model_uri)
assert wheeled_model_version.run_id == wheeled_run_id
assert wheeled_model_version.name == model_name
def test_adding_libraries_to_model_new_model_name(sklearn_knn_model):
model_name = f"wheels-test-{random_int()}"
wheeled_model_name = f"wheels-test-{random_int()}"
artifact_path = "model"
model_uri = f"models:/{model_name}/1"
wheeled_model_uri = f"models:/{wheeled_model_name}/1"
# Log a model
with mlflow.start_run():
mlflow.sklearn.log_model(
sklearn_knn_model.model,
name=artifact_path,
registered_model_name=model_name,
)
with mlflow.start_run():
new_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
wheeled_model_info = add_libraries_to_model(
model_uri, registered_model_name=wheeled_model_name
)
assert wheeled_model_info.run_id == new_run_id
# Verify new model version created
wheeled_model_version = get_model_version_from_model_uri(wheeled_model_uri)
assert wheeled_model_version.run_id == new_run_id
assert wheeled_model_version.name == wheeled_model_name
assert wheeled_model_name != model_name
def test_adding_libraries_to_model_when_version_source_None(sklearn_knn_model):
model_name = f"wheels-test-{random_int()}"
artifact_path = "model"
model_uri = f"models:/{model_name}/1"
# Log a model
with mlflow.start_run():
original_run_id = mlflow.tracking.fluent._get_or_start_run().info.run_id
mlflow.sklearn.log_model(
sklearn_knn_model.model,
name=artifact_path,
registered_model_name=model_name,
)
model_version_without_source = ModelVersion(name=model_name, version=1, creation_timestamp=124)
assert model_version_without_source.run_id is None
with mock.patch.object(
MlflowClient, "get_model_version", return_value=model_version_without_source
) as mlflow_client_mock:
wheeled_model_info = add_libraries_to_model(model_uri)
assert wheeled_model_info.run_id is not None
assert wheeled_model_info.run_id != original_run_id
mlflow_client_mock.assert_called_once_with(model_name, "1")
@pytest.mark.parametrize(
("data", "data_type"),
[
("string", DataType.string),
(np.int32(1), DataType.integer),
(np.int32(1), DataType.long),
(np.int32(1), DataType.double),
(True, DataType.boolean),
(1.0, DataType.double),
(np.float32(0.1), DataType.float),
(np.float32(0.1), DataType.double),
(np.int64(100), DataType.long),
(np.datetime64("2023-10-13 00:00:00"), DataType.datetime),
],
)
def test_enforce_datatype(data, data_type):
assert _enforce_datatype(data, data_type) == data
def test_enforce_datatype_with_errors():
with pytest.raises(MlflowException, match=r"Expected dtype to be DataType, got str"):
_enforce_datatype("string", "string")
with pytest.raises(
MlflowException, match=r"Failed to enforce schema of data `123` with dtype `string`"
):
_enforce_datatype(123, DataType.string)
@pytest.mark.parametrize(
"dtype",
[
pd.StringDtype(),
"string",
object,
None, # infers object in pandas <3.0, StringDtype in pandas 3.0
],
)
def test_enforce_mlflow_datatype_with_string_dtype(dtype):
# Test that string dtypes are handled correctly (pandas 3.0 compatibility)
series = pd.Series(["a", "b", "c"], dtype=dtype)
result = _enforce_mlflow_datatype("col", series, DataType.string)
assert result is series
def test_enforce_object():
data = {
"a": "some_sentence",
"b": b"some_bytes",
"c": ["sentence1", "sentence2"],
"d": {"str": "value", "arr": [0.1, 0.2]},
}
obj = Object([
Property("a", DataType.string),
Property("b", DataType.binary, required=False),
Property("c", Array(DataType.string)),
Property(
"d",
Object([
Property("str", DataType.string),
Property("arr", Array(DataType.double), required=False),
]),
),
])
assert _enforce_object(data, obj) == data
data = {"a": "some_sentence", "c": ["sentence1", "sentence2"], "d": {"str": "some_value"}}
assert _enforce_object(data, obj) == data
def test_enforce_object_with_errors():
with pytest.raises(MlflowException, match=r"Expected data to be dictionary, got list"):
_enforce_object(["some_sentence"], Object([Property("a", DataType.string)]))
with pytest.raises(MlflowException, match=r"Expected obj to be Object, got Property"):
_enforce_object({"a": "some_sentence"}, Property("a", DataType.string))
obj = Object([Property("a", DataType.string), Property("b", DataType.string, required=False)])
with pytest.raises(MlflowException, match=r"Missing required properties: {'a'}"):
_enforce_object({}, obj)
with pytest.raises(
MlflowException, match=r"Invalid properties not defined in the schema found: {'c'}"
):
_enforce_object({"a": "some_sentence", "c": "some_sentence"}, obj)
with pytest.raises(
MlflowException,
match=r"Failed to enforce schema for key `a`. Expected type string, received type int",
):
_enforce_object({"a": 1}, obj)
def test_enforce_property():
data = "some_sentence"
prop = Property("a", DataType.string)
assert _enforce_property(data, prop) == data
data = ["some_sentence1", "some_sentence2"]
prop = Property("a", Array(DataType.string))
assert _enforce_property(data, prop) == data
prop = Property("a", Array(DataType.binary))
assert _enforce_property(data, prop) == [b"some_sentence1", b"some_sentence2"]
data = np.array([np.int32(1), np.int32(2)])
prop = Property("a", Array(DataType.integer))
assert (_enforce_property(data, prop) == data).all()
data = {
"a": "some_sentence",
"b": b"some_bytes",
"c": ["sentence1", "sentence2"],
"d": {"str": "value", "arr": [0.1, 0.2]},
}
prop = Property(
"any_name",
Object([
Property("a", DataType.string),
Property("b", DataType.binary, required=False),
Property("c", Array(DataType.string), required=False),
Property(
"d",
Object([
Property("str", DataType.string),
Property("arr", Array(DataType.double), required=False),
]),
),
]),
)
assert _enforce_property(data, prop) == data
data = {"a": "some_sentence", "d": {"str": "some_value"}}
assert _enforce_property(data, prop) == data
def test_enforce_property_with_errors():
with pytest.raises(
MlflowException, match=r"Failed to enforce schema of data `123` with dtype `string`"
):
_enforce_property(123, Property("a", DataType.string))
with pytest.raises(MlflowException, match=r"Missing required properties: {'a'}"):
_enforce_property(
{"b": ["some_sentence1", "some_sentence2"]},
Property(
"any_name",
Object([Property("a", DataType.string), Property("b", Array(DataType.string))]),
),
)
with pytest.raises(
MlflowException,
match=r"Failed to enforce schema for key `a`. Expected type string, received type list",
):
_enforce_property(
{"a": ["some_sentence1", "some_sentence2"]},
Property("any_name", Object([Property("a", DataType.string)])),
)
@pytest.mark.parametrize(
("data", "schema"),
[
# 1. Flat list
(["some_sentence1", "some_sentence2"], Array(DataType.string)),
# 2. Nested list
(
[
[["a", "b"], ["c", "d"]],
[["e", "f", "g"], ["h"]],
[[]],
],
Array(Array(Array(DataType.string))),
),
# 3. Array of Object
(
[
{"a": "some_sentence1", "b": "some_sentence2"},
{"a": "some_sentence3", "c": ["some_sentence4", "some_sentence5"]},
],
Array(
Object([
Property("a", DataType.string),
Property("b", DataType.string, required=False),
Property("c", Array(DataType.string), required=False),
])
),
),
# 4. Empty list
([], Array(DataType.string)),
],
)
def test_enforce_array_on_list(data, schema):
assert _enforce_array(data, schema) == data
@pytest.mark.parametrize(
("data", "schema"),
[
# 1. 1D array
(np.array(["some_sentence1", "some_sentence2"]), Array(DataType.string)),
# 2. 2D array
(
np.array([
["a", "b"],
["c", "d"],
]),
Array(Array(DataType.string)),
),
# 3. Empty array
(np.array([[], []]), Array(Array(DataType.string))),
],
)
def test_enforce_array_on_numpy_array(data, schema):
assert (_enforce_array(data, schema) == data).all()
def test_enforce_array_with_errors():
with pytest.raises(MlflowException, match=r"Expected data to be list or numpy array, got str"):
_enforce_array("abc", Array(DataType.string))
with pytest.raises(MlflowException, match=r"Incompatible input types"):
_enforce_array([123, 456, 789], Array(DataType.string))
# Nested array with mixed type elements
with pytest.raises(MlflowException, match=r"Incompatible input types"):
_enforce_array([["a", "b"], [1, 2]], Array(Array(DataType.string)))
# Nested array with different nest level
with pytest.raises(MlflowException, match=r"Expected data to be list or numpy array, got str"):
_enforce_array([["a", "b"], "c"], Array(Array(DataType.string)))
# Missing priperties in Object
with pytest.raises(MlflowException, match=r"Missing required properties: {'b'}"):
_enforce_array(
[
{"a": "some_sentence1", "b": "some_sentence2"},
{"a": "some_sentence3", "c": ["some_sentence4", "some_sentence5"]},
],
Array(Object([Property("a", DataType.string), Property("b", DataType.string)])),
)
# Extra properties
with pytest.raises(
MlflowException, match=r"Invalid properties not defined in the schema found: {'c'}"
):
_enforce_array(
[
{"a": "some_sentence1", "b": "some_sentence2"},
{"a": "some_sentence3", "c": ["some_sentence4", "some_sentence5"]},
],
Array(
Object([
Property("a", DataType.string),
Property("b", DataType.string, required=False),
])
),
)
def test_model_code_validation():
# Invalid code with dbutils
invalid_code = "dbutils.library.restartPython()\nsome_python_variable = 5"
with mock.patch("mlflow.models.utils._logger.warning") as mock_warning:
_validate_model_code_from_notebook(invalid_code)
mock_warning.assert_called_once_with(
"The model file uses 'dbutils' commands which are not supported. To ensure your "
"code functions correctly, make sure that it does not rely on these dbutils "
"commands for correctness."
)
# Code with commented magic commands displays warning
warning_code = "# dbutils.library.restartPython()\n# MAGIC %run ../wheel_installer"
with mock.patch("mlflow.models.utils._logger.warning") as mock_warning:
_validate_model_code_from_notebook(warning_code)
mock_warning.assert_called_once_with(
"The model file uses magic commands which have been commented out. To ensure your code "
"functions correctly, make sure that it does not rely on these magic commands for "
"correctness."
)
# Code with commented pip magic commands does not warn
warning_code = "# MAGIC %pip install mlflow"
with mock.patch("mlflow.models.utils._logger.warning") as mock_warning:
_validate_model_code_from_notebook(warning_code)
mock_warning.assert_not_called()
# Test valid code
valid_code = "some_valid_python_code = 'valid'"
validated_code = _validate_model_code_from_notebook(valid_code).decode("utf-8")
assert validated_code == valid_code
# Test uncommented magic commands
code_with_magic_command = (
"valid_python_code = 'valid'\n%pip install sqlparse\nvalid_python_code = 'valid'\n# Comment"
)
expected_validated_code = (
"valid_python_code = 'valid'\n# MAGIC %pip install sqlparse\nvalid_python_code = "
"'valid'\n# Comment"
)
validated_code_with_magic_command = _validate_model_code_from_notebook(
code_with_magic_command
).decode("utf-8")
assert validated_code_with_magic_command == expected_validated_code
def test_config_context():
with _config_context("tests/langchain/config.yml"):
assert mlflow.models.model_config.__mlflow_model_config__ == "tests/langchain/config.yml"
assert mlflow.models.model_config.__mlflow_model_config__ is None
def test_flatten_nested_params():
nested_params = {
"a": 1,
"b": {"c": 2, "d": {"e": 3}},
"f": {"g": {"h": 4}},
}
expected_flattened_params = {
"a": 1,
"b.c": 2,
"b.d.e": 3,
"f.g.h": 4,
}
assert _flatten_nested_params(nested_params, sep=".") == expected_flattened_params
assert _flatten_nested_params(nested_params, sep="/") == {
"a": 1,
"b/c": 2,
"b/d/e": 3,
"f/g/h": 4,
}
assert _flatten_nested_params({}) == {}
params = {"a": 1, "b": 2, "c": 3}
assert _flatten_nested_params(params) == params
params = {
"a": 1,
"b": {"c": 2, "d": {"e": 3, "f": [1, 2, 3]}, "g": "hello"},
"h": {"i": None},
}
expected_flattened_params = {
"a": 1,
"b/c": 2,
"b/d/e": 3,
"b/d/f": [1, 2, 3],
"b/g": "hello",
"h/i": None,
}
assert _flatten_nested_params(params) == expected_flattened_params
nested_params = {1: {2: {3: 4}}, "a": {"b": {"c": 5}}}
expected_flattened_params_mixed = {
"1/2/3": 4,
"a/b/c": 5,
}
assert _flatten_nested_params(nested_params) == expected_flattened_params_mixed
rag_params = {
"workspace_url": "https://e2-dogfood.staging.cloud.databricks.com",
"vector_search_endpoint_name": "dbdemos_vs_endpoint",
"vector_search_index": "monitoring.rag.databricks_docs_index",
"embedding_model_endpoint_name": "databricks-bge-large-en",
"embedding_model_query_instructions": "Represent this sentence for searching",
"llm_model": "databricks-dbrx-instruct",
"llm_prompt_template": "You are a trustful assistant for Databricks users.",
"retriever_config": {"k": 5, "use_mmr": "false"},
"llm_parameters": {"temperature": 0.01, "max_tokens": 200},
"llm_prompt_template_variables": ["chat_history", "context", "question"],
"secret_scope": "dbdemos",
"secret_key": "rag_sunish",
}
expected_rag_flattened_params = {
"workspace_url": "https://e2-dogfood.staging.cloud.databricks.com",
"vector_search_endpoint_name": "dbdemos_vs_endpoint",
"vector_search_index": "monitoring.rag.databricks_docs_index",
"embedding_model_endpoint_name": "databricks-bge-large-en",
"embedding_model_query_instructions": "Represent this sentence for searching",
"llm_model": "databricks-dbrx-instruct",
"llm_prompt_template": "You are a trustful assistant for Databricks users.",
"retriever_config/k": 5,
"retriever_config/use_mmr": "false",
"llm_parameters/temperature": 0.01,
"llm_parameters/max_tokens": 200,
"llm_prompt_template_variables": ["chat_history", "context", "question"],
"secret_scope": "dbdemos",
"secret_key": "rag_sunish",
}
assert _flatten_nested_params(rag_params) == expected_rag_flattened_params
@pytest.mark.parametrize(
("data", "target", "target_type"),
[
(pd.DataFrame([{"a": [1, 2, 3]}]), [{"a": [1, 2, 3]}], list),
(pd.DataFrame([{"a": np.array([1, 2, 3])}]), [{"a": [1, 2, 3]}], list),
(pd.DataFrame([{0: np.array(["abc"])[0]}]), ["abc"], list),
(np.array([1, 2, 3]), [1, 2, 3], list),
(np.array([123])[0], 123, int),
(np.array(["abc"])[0], "abc", str),
],
)
def test_convert_llm_input_data(data, target, target_type):
result = _convert_llm_input_data(data)
assert result == target
assert type(result) == target_type
@pytest.mark.parametrize(
("model_path", "error_message"),
[
(
"model.py",
f"The provided model path '{os.getcwd()}/model.py' does not exist. "
"Ensure the file path is valid and try again.",
),
(
"model",
f"The provided model path '{os.getcwd()}/model' does not exist. "
"Ensure the file path is valid and try again. "
f"Perhaps you meant '{os.getcwd()}/model.py'?",
),
],
)
def test_validate_and_get_model_code_path_not_found(model_path, error_message, tmp_path):
with pytest.raises(MlflowException, match=error_message):
_validate_and_get_model_code_path(model_path, tmp_path)
def test_validate_and_get_model_code_path_success(tmp_path):
# if the model file exists, return the path as is
model_path = os.path.abspath(__file__)
actual = _validate_and_get_model_code_path(model_path, tmp_path)
assert actual == model_path
def test_suppress_schema_error(monkeypatch):
schema = Schema([
ColSpec("double", "id"),
ColSpec("string", "name"),
])
monkeypatch.setenv(MLFLOW_DISABLE_SCHEMA_DETAILS.name, "true")
data = pd.DataFrame({"id": [1, 2]}, dtype="float64")
with pytest.raises(
MlflowException,
match=r"Failed to enforce model input schema. Please check your input data.",
):
_validate_prediction_input(data, None, schema, None)
def test_enforce_schema_with_missing_and_extra_columns(monkeypatch):
schema = Schema([
ColSpec("long", "id"),
ColSpec("string", "name"),
])
monkeypatch.setenv(MLFLOW_DISABLE_SCHEMA_DETAILS.name, "true")
input_data = pd.DataFrame({"id": [1, 2], "extra_col": ["mlflow", "oss"]})
with pytest.raises(
MlflowException, match=r"Input schema validation failed.*extra inputs provided"
):
_enforce_schema(input_data, schema)