1777 lines
63 KiB
Python
1777 lines
63 KiB
Python
import datetime
|
|
import os
|
|
import random
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Iterator, NamedTuple
|
|
from unittest import mock
|
|
|
|
import cloudpickle
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pyspark
|
|
import pytest
|
|
from packaging.version import Version
|
|
from pandas.testing import assert_frame_equal
|
|
from pyspark.sql.functions import col, pandas_udf, struct
|
|
from pyspark.sql.types import (
|
|
ArrayType,
|
|
BooleanType,
|
|
DoubleType,
|
|
FloatType,
|
|
IntegerType,
|
|
LongType,
|
|
StringType,
|
|
StructField,
|
|
StructType,
|
|
TimestampType,
|
|
)
|
|
from pyspark.sql.utils import AnalysisException
|
|
from sklearn import datasets
|
|
from sklearn.compose import ColumnTransformer
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.neighbors import KNeighborsClassifier
|
|
from sklearn.pipeline import Pipeline
|
|
from sklearn.preprocessing import FunctionTransformer
|
|
|
|
import mlflow
|
|
import mlflow.pyfunc
|
|
import mlflow.sklearn
|
|
from mlflow.environment_variables import MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.models import ModelSignature
|
|
from mlflow.models.signature import infer_signature
|
|
from mlflow.pyfunc import (
|
|
PyFuncModel,
|
|
PythonModel,
|
|
_check_udf_return_type,
|
|
_parse_spark_datatype,
|
|
build_model_env,
|
|
spark_udf,
|
|
)
|
|
from mlflow.pyfunc.spark_model_cache import SparkModelCache
|
|
from mlflow.types import ColSpec, Schema, TensorSpec
|
|
from mlflow.types.schema import Array, DataType, Object, Property
|
|
from mlflow.types.utils import _infer_schema
|
|
from mlflow.utils._spark_utils import modified_environ
|
|
|
|
import tests
|
|
|
|
prediction = [int(1), int(2), "class1", float(0.1), 0.2, True]
|
|
types = [np.int32, int, str, np.float32, np.double, bool]
|
|
|
|
|
|
def score_spark(spark, model_uri, pandas_df, result_type="double"):
|
|
spark_df = spark.createDataFrame(pandas_df).coalesce(1)
|
|
pyfunc_udf = spark_udf(
|
|
spark=spark, model_uri=model_uri, result_type=result_type, env_manager="local"
|
|
)
|
|
new_df = spark_df.withColumn("prediction", pyfunc_udf(*pandas_df.columns))
|
|
return [x["prediction"] for x in new_df.collect()]
|
|
|
|
|
|
def score_model_as_udf(model_uri, pandas_df, result_type="double"):
|
|
if spark := pyspark.sql.SparkSession.getActiveSession():
|
|
# Reuse the active SparkSession, don't kill it after use
|
|
return score_spark(spark, model_uri, pandas_df, result_type)
|
|
|
|
# Create a new SparkSession, kill it after use
|
|
with get_spark_session(pyspark.SparkConf()) as spark:
|
|
return score_spark(spark, model_uri, pandas_df, result_type)
|
|
|
|
|
|
class ConstantPyfuncWrapper:
|
|
@staticmethod
|
|
def predict(model_input):
|
|
m, _ = model_input.shape
|
|
return pd.DataFrame(
|
|
data={
|
|
str(i): np.array([prediction[i] for j in range(m)], dtype=types[i])
|
|
for i in range(len(prediction))
|
|
},
|
|
columns=[str(i) for i in range(len(prediction))],
|
|
)
|
|
|
|
|
|
def _load_pyfunc(_):
|
|
return ConstantPyfuncWrapper()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def configure_environment():
|
|
os.environ["PYSPARK_PYTHON"] = sys.executable
|
|
|
|
|
|
def get_spark_session(conf):
|
|
conf.set(key="spark.python.worker.reuse", value="true")
|
|
# disable task retry (i.e. make it fast fail)
|
|
conf.set(key="spark.task.maxFailures", value="1")
|
|
# Disable simplifying traceback from Python UDFs
|
|
conf.set(key="spark.sql.execution.pyspark.udf.simplifiedTraceback.enabled", value="false")
|
|
# Show jvm side stack trace.
|
|
conf.set(key="spark.sql.pyspark.jvmStacktrace.enabled", value="true")
|
|
# when local run test_spark.py
|
|
# you can set SPARK_MASTER=local[1]
|
|
# so that executor log will be printed as test process output
|
|
# which make debug easier.
|
|
# If running in local mode on certain OS configurations (M1 Mac ARM CPUs)
|
|
# adding `.config("spark.driver.bindAddress", "127.0.0.1")` to the SparkSession
|
|
# builder configuration will enable a SparkSession to start.
|
|
# For local testing, uncomment the following line:
|
|
# spark_master = os.environ.get("SPARK_MASTER", "local[1]")
|
|
# If doing local testing, comment-out the following line.
|
|
spark_master = os.environ.get("SPARK_MASTER", "local-cluster[2, 1, 1024]")
|
|
# Don't forget to revert these changes prior to pushing a branch!
|
|
return (
|
|
pyspark.sql.SparkSession.builder
|
|
.config(conf=conf)
|
|
.master(spark_master)
|
|
# Uncomment the following line for testing on Apple silicon locally
|
|
# .config("spark.driver.bindAddress", "127.0.0.1")
|
|
.config("spark.task.maxFailures", "1") # avoid retry failed spark tasks
|
|
.getOrCreate()
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def spark():
|
|
with get_spark_session(pyspark.SparkConf()) as session:
|
|
yield session
|
|
|
|
|
|
@pytest.fixture
|
|
def model_path(tmp_path):
|
|
return os.path.join(tmp_path, "model")
|
|
|
|
|
|
class ModelWithData(NamedTuple):
|
|
model: Any
|
|
inference_data: Any
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sklearn_model():
|
|
iris = datasets.load_iris()
|
|
X = iris.data[:, :2] # we only take the first two features.
|
|
y = iris.target
|
|
logreg_model = LogisticRegression()
|
|
logreg_model.fit(X, y)
|
|
return ModelWithData(model=logreg_model, inference_data=X)
|
|
|
|
|
|
def test_spark_udf(spark, model_path):
|
|
mlflow.pyfunc.save_model(
|
|
path=model_path,
|
|
loader_module=__name__,
|
|
code_paths=[os.path.dirname(tests.__file__)],
|
|
)
|
|
|
|
with mock.patch("mlflow.pyfunc.warn_dependency_requirement_mismatches") as mock_check_fn:
|
|
reloaded_pyfunc_model = mlflow.pyfunc.load_model(model_path)
|
|
mock_check_fn.assert_called_once()
|
|
|
|
pandas_df = pd.DataFrame(data=np.ones((10, 10)), columns=[str(i) for i in range(10)])
|
|
spark_df = spark.createDataFrame(pandas_df)
|
|
|
|
# Test all supported return types
|
|
type_map = {
|
|
"float": (FloatType(), np.number),
|
|
"int": (IntegerType(), np.int32),
|
|
"double": (DoubleType(), np.number),
|
|
"long": (LongType(), int),
|
|
"string": (StringType(), None),
|
|
"bool": (BooleanType(), bool),
|
|
"boolean": (BooleanType(), bool),
|
|
}
|
|
|
|
for tname, tdef in type_map.items():
|
|
spark_type, np_type = tdef
|
|
prediction_df = reloaded_pyfunc_model.predict(pandas_df)
|
|
for is_array in [True, False]:
|
|
t = ArrayType(spark_type) if is_array else spark_type
|
|
if tname == "string":
|
|
expected = prediction_df.applymap(str)
|
|
else:
|
|
expected = prediction_df.select_dtypes(np_type)
|
|
if tname == "float":
|
|
expected = expected.astype(np.float32)
|
|
if tname in {"bool", "boolean"}:
|
|
expected = expected.astype(bool)
|
|
|
|
expected = [list(row[1]) if is_array else row[1][0] for row in expected.iterrows()]
|
|
pyfunc_udf = spark_udf(spark, model_path, result_type=t, env_manager="local")
|
|
new_df = spark_df.withColumn("prediction", pyfunc_udf(*pandas_df.columns))
|
|
actual = list(new_df.select("prediction").toPandas()["prediction"])
|
|
assert expected == actual
|
|
if not is_array:
|
|
pyfunc_udf = spark_udf(spark, model_path, result_type=tname, env_manager="local")
|
|
new_df = spark_df.withColumn("prediction", pyfunc_udf(*pandas_df.columns))
|
|
actual = list(new_df.select("prediction").toPandas()["prediction"])
|
|
assert expected == actual
|
|
|
|
|
|
@pytest.mark.parametrize("env_manager", ["virtualenv", "conda", "uv"])
|
|
def test_spark_udf_env_manager_can_restore_env(spark, model_path, env_manager, monkeypatch):
|
|
class EnvRestoringTestModel(mlflow.pyfunc.PythonModel):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def predict(self, context, model_input, params=None):
|
|
import sklearn
|
|
|
|
return model_input.apply(lambda row: sklearn.__version__, axis=1)
|
|
|
|
sklearn_version = "1.4.2"
|
|
infer_spark_df = spark.createDataFrame(pd.DataFrame(data=[[1, 2]], columns=["a", "b"]))
|
|
|
|
mlflow.pyfunc.save_model(
|
|
path=model_path,
|
|
python_model=EnvRestoringTestModel(),
|
|
pip_requirements=[
|
|
f"pyspark=={pyspark.__version__}",
|
|
f"scikit-learn=={sklearn_version}",
|
|
# pytest is required to load the custom model from this file
|
|
f"pytest=={pytest.__version__}",
|
|
],
|
|
)
|
|
# tests/helper_functions.py
|
|
from tests.helper_functions import _get_mlflow_home
|
|
|
|
monkeypatch.setenv("MLFLOW_HOME", _get_mlflow_home())
|
|
python_udf = mlflow.pyfunc.spark_udf(
|
|
spark, model_path, env_manager=env_manager, result_type="string"
|
|
)
|
|
result = infer_spark_df.select(python_udf("a", "b").alias("result")).toPandas().result[0]
|
|
assert result == sklearn_version
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_manager", "force_stdin_scoring_server"),
|
|
[("virtualenv", False), ("conda", False), ("uv", False), ("uv", True)],
|
|
)
|
|
def test_spark_udf_env_manager_predict_sklearn_model(
|
|
spark, sklearn_model, model_path, env_manager, force_stdin_scoring_server, monkeypatch
|
|
):
|
|
monkeypatch.setenv(
|
|
"MLFLOW_ENFORCE_STDIN_SCORING_SERVER_FOR_SPARK_UDF",
|
|
str(force_stdin_scoring_server),
|
|
)
|
|
model, inference_data = sklearn_model
|
|
|
|
mlflow.sklearn.save_model(model, model_path)
|
|
expected_pred_result = model.predict(inference_data)
|
|
|
|
infer_data = pd.DataFrame(inference_data, columns=["a", "b"])
|
|
infer_spark_df = spark.createDataFrame(infer_data)
|
|
|
|
pyfunc_udf = spark_udf(spark, model_path, env_manager=env_manager)
|
|
result = (
|
|
infer_spark_df
|
|
.select(pyfunc_udf("a", "b").alias("predictions"))
|
|
.toPandas()
|
|
.predictions.to_numpy()
|
|
)
|
|
|
|
np.testing.assert_allclose(result, expected_pred_result, rtol=1e-5)
|
|
|
|
|
|
def test_spark_udf_with_single_arg(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [",".join(map(str, model_input.columns.tolist()))] * len(model_input)
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel())
|
|
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark, f"runs:/{run.info.run_id}/model", result_type=StringType()
|
|
)
|
|
|
|
data1 = spark.createDataFrame(pd.DataFrame({"a": [1], "b": [4]})).repartition(1)
|
|
|
|
result = data1.withColumn("res", udf("a")).select("res").toPandas()
|
|
assert result.res[0] == "0"
|
|
|
|
data2 = data1.select(struct("a", "b").alias("ab"))
|
|
result = data2.withColumn("res", udf("ab")).select("res").toPandas()
|
|
assert result.res[0] == "a,b"
|
|
|
|
|
|
def test_spark_udf_with_struct_return_type(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
input_len = len(model_input)
|
|
return {
|
|
"r1": [1] * input_len,
|
|
"r2": [1.5] * input_len,
|
|
"r3": [[1, 2]] * input_len,
|
|
"r4": [np.array([1.5, 2.5])] * input_len,
|
|
"r5": np.vstack([np.array([1.5, 2.5])] * input_len),
|
|
"r6": [True] * input_len,
|
|
"r7": ["abc"] * input_len,
|
|
}
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel())
|
|
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=(
|
|
"r1 int, r2 float, r3 array<long>, r4 array<double>, "
|
|
"r5 array<double>, r6 boolean, r7 string"
|
|
),
|
|
)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result = (
|
|
data1
|
|
.withColumn("res", udf("id"))
|
|
.select("res.r1", "res.r2", "res.r3", "res.r4", "res.r5", "res.r6", "res.r7")
|
|
.toPandas()
|
|
)
|
|
assert result.r1.tolist() == [1] * 2
|
|
np.testing.assert_almost_equal(result.r2.tolist(), [1.5] * 2)
|
|
assert result.r3.tolist() == [[1, 2]] * 2
|
|
np.testing.assert_almost_equal(
|
|
np.vstack(result.r4.tolist()), np.array([[1.5, 2.5], [1.5, 2.5]])
|
|
)
|
|
np.testing.assert_almost_equal(
|
|
np.vstack(result.r5.tolist()), np.array([[1.5, 2.5], [1.5, 2.5]])
|
|
)
|
|
assert result.r6.tolist() == [True] * 2
|
|
assert result.r7.tolist() == ["abc"] * 2
|
|
|
|
|
|
def test_spark_udf_colspec_struct_return_type_inference(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
input_len = len(model_input)
|
|
return {
|
|
"r1": [1] * input_len,
|
|
"r2": [1.5] * input_len,
|
|
"r3": [True] * input_len,
|
|
"r4": ["abc"] * input_len,
|
|
}
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=ModelSignature(
|
|
inputs=Schema([ColSpec("long")]),
|
|
outputs=Schema([
|
|
ColSpec("integer", "r1"),
|
|
ColSpec("float", "r2"),
|
|
ColSpec("boolean", "r3"),
|
|
ColSpec("string", "r4"),
|
|
]),
|
|
),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result_spark_df = data1.withColumn("res", udf("id")).select(
|
|
"res.r1", "res.r2", "res.r3", "res.r4"
|
|
)
|
|
assert (
|
|
result_spark_df.schema.simpleString() == "struct<r1:int,r2:float,r3:boolean,r4:string>"
|
|
)
|
|
|
|
result = result_spark_df.toPandas()
|
|
expected_data = {
|
|
"r1": [1] * 2,
|
|
"r2": [1.5] * 2,
|
|
"r3": [True] * 2,
|
|
"r4": ["abc"] * 2,
|
|
}
|
|
|
|
expected_df = pd.DataFrame(expected_data)
|
|
assert_frame_equal(result, expected_df, check_dtype=False)
|
|
|
|
|
|
def test_spark_udf_tensorspec_struct_return_type_inference(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
input_len = len(model_input)
|
|
return {
|
|
"r1": [[1, 2]] * input_len,
|
|
"r2": [np.array([1.5, 2.5])] * input_len,
|
|
"r3": np.vstack([np.array([1.5, 2.5])] * input_len),
|
|
"r4": [np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])] * input_len,
|
|
"r5": np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]] * input_len),
|
|
}
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=ModelSignature(
|
|
inputs=Schema([ColSpec("long")]),
|
|
outputs=Schema([
|
|
TensorSpec(np.dtype(np.int64), (2,), "r1"),
|
|
TensorSpec(np.dtype(np.float64), (2,), "r2"),
|
|
TensorSpec(np.dtype(np.float64), (2,), "r3"),
|
|
TensorSpec(np.dtype(np.float64), (2, 3), "r4"),
|
|
TensorSpec(np.dtype(np.float64), (2, 3), "r5"),
|
|
]),
|
|
),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result_spark_df = data1.withColumn("res", udf("id")).select(
|
|
"res.r1", "res.r2", "res.r3", "res.r4", "res.r5"
|
|
)
|
|
assert (
|
|
result_spark_df.schema.simpleString() == "struct<"
|
|
"r1:array<bigint>,"
|
|
"r2:array<double>,"
|
|
"r3:array<double>,"
|
|
"r4:array<array<double>>,"
|
|
"r5:array<array<double>>"
|
|
">"
|
|
)
|
|
result = result_spark_df.toPandas()
|
|
assert result["r1"].tolist() == [[1, 2]] * 2
|
|
np.testing.assert_almost_equal(
|
|
np.vstack(result["r2"].tolist()), np.array([[1.5, 2.5], [1.5, 2.5]])
|
|
)
|
|
np.testing.assert_almost_equal(
|
|
np.vstack(result["r3"].tolist()), np.array([[1.5, 2.5], [1.5, 2.5]])
|
|
)
|
|
np.testing.assert_almost_equal(list(result["r4"]), [[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]] * 2)
|
|
np.testing.assert_almost_equal(list(result["r5"]), [[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]] * 2)
|
|
|
|
|
|
def test_spark_udf_single_1d_array_return_type_inference(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
input_len = len(model_input)
|
|
return [[1, 2]] * input_len
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=ModelSignature(
|
|
inputs=Schema([ColSpec("long")]),
|
|
outputs=Schema([
|
|
TensorSpec(np.dtype(np.int64), (2,)),
|
|
]),
|
|
),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result_spark_df = data1.select(udf("id").alias("res"))
|
|
assert result_spark_df.schema.simpleString() == "struct<res:array<bigint>>"
|
|
result = result_spark_df.toPandas()
|
|
assert result["res"].tolist() == [[1, 2]] * 2
|
|
|
|
|
|
def test_spark_udf_single_2d_array_return_type_inference(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
input_len = len(model_input)
|
|
return [np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])] * input_len
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=ModelSignature(
|
|
inputs=Schema([ColSpec("long")]),
|
|
outputs=Schema([
|
|
TensorSpec(np.dtype(np.float64), (2, 3)),
|
|
]),
|
|
),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result_spark_df = data1.select(udf("id").alias("res"))
|
|
assert result_spark_df.schema.simpleString() == "struct<res:array<array<double>>>"
|
|
result = result_spark_df.toPandas()
|
|
np.testing.assert_almost_equal(
|
|
list(result["res"]), [[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]] * 2
|
|
)
|
|
|
|
|
|
def test_spark_udf_single_long_return_type_inference(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
input_len = len(model_input)
|
|
return [12] * input_len
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=ModelSignature(
|
|
inputs=Schema([ColSpec("long")]),
|
|
outputs=Schema([
|
|
ColSpec("long"),
|
|
]),
|
|
),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri)
|
|
|
|
data1 = spark.range(2).repartition(1)
|
|
result_spark_df = data1.select(udf("id").alias("res"))
|
|
assert result_spark_df.schema.simpleString() == "struct<res:bigint>"
|
|
result = result_spark_df.toPandas()
|
|
assert result["res"].tolist() == [12] * 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("type_str", "expected"),
|
|
[
|
|
# Good
|
|
("int", True),
|
|
("bigint", True),
|
|
("float", True),
|
|
("double", True),
|
|
("boolean", True),
|
|
("string", True),
|
|
("array<double>", True),
|
|
("array<array<double>>", True),
|
|
("a long, b boolean, c array<double>, d array<array<double>>", True),
|
|
("array<struct<a: int, b: boolean>>", True),
|
|
("array<struct<a: array<int>>>", True),
|
|
("array<array<array<float>>>", True),
|
|
("a array<array<array<int>>>", True),
|
|
("struct<x: struct<a: long, b: boolean>>", True),
|
|
("struct<x: array<struct<a: long, b: boolean>>>", True),
|
|
("struct<a: array<struct<a: int>>>", True),
|
|
# Bad
|
|
("timestamp", False),
|
|
("array<timestamp>", False),
|
|
("struct<a: int, b: timestamp>", False),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("spark")
|
|
def test_check_spark_udf_return_type(type_str, expected):
|
|
assert _check_udf_return_type(_parse_spark_datatype(type_str)) == expected
|
|
|
|
|
|
def test_spark_udf_autofills_no_arguments(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [model_input.columns] * len(model_input)
|
|
|
|
signature = ModelSignature(
|
|
inputs=Schema([ColSpec("long", "a"), ColSpec("long", "b"), ColSpec("long", "c")]),
|
|
outputs=Schema([ColSpec("integer")]),
|
|
)
|
|
|
|
good_data = spark.createDataFrame(
|
|
pd.DataFrame(columns=["a", "b", "c", "d"], data={"a": [1], "b": [2], "c": [3], "d": [4]})
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel(), signature=signature)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=ArrayType(StringType()),
|
|
env_manager="local",
|
|
)
|
|
res = good_data.withColumn("res", udf()).select("res").toPandas()
|
|
assert res["res"][0] == ["a", "b", "c"]
|
|
|
|
with pytest.raises(
|
|
pyspark.sql.utils.PythonException,
|
|
match=r"Model input is missing required columns. Expected 3 required input columns",
|
|
):
|
|
res = good_data.withColumn("res", udf("b", "c")).select("res").toPandas()
|
|
|
|
# this dataframe won't work because it's missing column a
|
|
bad_data = spark.createDataFrame(
|
|
pd.DataFrame(
|
|
columns=["x", "b", "c", "d"], data={"x": [1], "b": [2], "c": [3], "d": [4]}
|
|
)
|
|
)
|
|
with pytest.raises(
|
|
AnalysisException,
|
|
match=(
|
|
# PySpark < 3.3
|
|
r"cannot resolve 'a' given input columns|"
|
|
# PySpark 3.3
|
|
r"Column 'a' does not exist|"
|
|
# PySpark 3.4
|
|
r"A column or function parameter with name `a` cannot be resolved|"
|
|
# PySpark 4.0
|
|
r"A column, variable, or function parameter with name `a` cannot be resolved"
|
|
),
|
|
):
|
|
bad_data.withColumn("res", udf())
|
|
|
|
nameless_signature = ModelSignature(
|
|
inputs=Schema([ColSpec("long"), ColSpec("long"), ColSpec("long")]),
|
|
outputs=Schema([ColSpec("integer")]),
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model", python_model=TestModel(), signature=nameless_signature
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark, f"runs:/{run.info.run_id}/model", result_type=ArrayType(StringType())
|
|
)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Cannot apply udf because no column names specified",
|
|
):
|
|
good_data.withColumn("res", udf())
|
|
|
|
with mlflow.start_run() as run:
|
|
# model without signature
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel())
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark, f"runs:/{run.info.run_id}/model", result_type=ArrayType(StringType())
|
|
)
|
|
with pytest.raises(MlflowException, match="Attempting to apply udf on zero columns"):
|
|
res = good_data.withColumn("res", udf()).select("res").toPandas()
|
|
|
|
named_signature_with_optional_input = ModelSignature(
|
|
inputs=Schema([
|
|
ColSpec("long", "a"),
|
|
ColSpec("long", "b"),
|
|
ColSpec("long", "c"),
|
|
ColSpec("long", "d", required=False),
|
|
]),
|
|
outputs=Schema([ColSpec("integer")]),
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model", python_model=TestModel(), signature=named_signature_with_optional_input
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark, f"runs:/{run.info.run_id}/model", result_type=ArrayType(StringType())
|
|
)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match=r"Cannot apply UDF without column names specified when model "
|
|
r"signature contains optional columns",
|
|
):
|
|
good_data.withColumn("res", udf())
|
|
|
|
# Ensure optional inputs are not truncated
|
|
res = good_data.withColumn("res", udf(*good_data.columns)).select("res").toPandas()
|
|
assert res["res"][0] == ["a", "b", "c", "d"]
|
|
|
|
|
|
def test_spark_udf_autofills_column_names_with_schema(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [model_input.columns] * len(model_input)
|
|
|
|
signature = ModelSignature(
|
|
inputs=Schema([ColSpec("long", "a"), ColSpec("long", "b"), ColSpec("long", "c")]),
|
|
outputs=Schema([ColSpec("integer")]),
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel(), signature=signature)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=ArrayType(StringType()),
|
|
env_manager="local",
|
|
)
|
|
data = spark.createDataFrame(
|
|
pd.DataFrame(
|
|
columns=["a", "b", "c", "d"], data={"a": [1], "b": [2], "c": [3], "d": [4]}
|
|
)
|
|
)
|
|
|
|
res = data.withColumn("res2", udf("a", "b", "c")).select("res2").toPandas()
|
|
assert res["res2"][0] == ["a", "b", "c"]
|
|
res = data.withColumn("res4", udf("a", "b", "c", "d")).select("res4").toPandas()
|
|
assert res["res4"][0] == ["a", "b", "c"]
|
|
|
|
# Exception being thrown in udf process intermittently causes the SparkSession to crash
|
|
# which results in a `java.net.SocketException: Socket is closed` failure in subsequent
|
|
# tests if tests are conducted after this exception capture validation.
|
|
# Keep this at the end of this suite so that executor sockets don't get closed while
|
|
# processing is still being conducted.
|
|
with pytest.raises(pyspark.sql.utils.PythonException, match=r".+"):
|
|
data.withColumn("res1", udf("a", "b")).select("res1").toPandas()
|
|
|
|
|
|
def test_spark_udf_with_datetime_columns(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [model_input.columns] * len(model_input)
|
|
|
|
signature = ModelSignature(
|
|
inputs=Schema([ColSpec("datetime", "timestamp"), ColSpec("datetime", "date")]),
|
|
outputs=Schema([ColSpec("integer")]),
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel(), signature=signature)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=ArrayType(StringType()),
|
|
env_manager="local",
|
|
)
|
|
data = spark.range(10).selectExpr(
|
|
"current_timestamp() as timestamp", "current_date() as date"
|
|
)
|
|
|
|
res = data.withColumn("res", udf("timestamp", "date")).select("res")
|
|
res = res.toPandas()
|
|
assert res["res"][0] == ["timestamp", "date"]
|
|
|
|
|
|
def test_spark_udf_over_empty_partition(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
if len(model_input) == 0:
|
|
raise ValueError("Empty input is not allowed.")
|
|
else:
|
|
return model_input.a + model_input.b
|
|
|
|
signature = ModelSignature(
|
|
inputs=Schema([ColSpec("long", "a"), ColSpec("long", "b")]),
|
|
outputs=Schema([ColSpec("long")]),
|
|
)
|
|
|
|
# Create a spark dataframe with 2 partitions, one partition has one record and
|
|
# the other partition is empty.
|
|
spark_df = spark.createDataFrame(
|
|
pd.DataFrame(columns=["x", "y"], data={"x": [11], "y": [21]})
|
|
).repartition(2)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel(), signature=signature)
|
|
python_udf = mlflow.pyfunc.spark_udf(
|
|
spark, f"runs:/{run.info.run_id}/model", result_type=LongType()
|
|
)
|
|
res_df = spark_df.withColumn("res", python_udf("x", "y")).select("res").toPandas()
|
|
assert res_df.res[0] == 32
|
|
|
|
res_df2 = (
|
|
spark_df
|
|
.withColumn("res", python_udf(struct(col("x").alias("a"), col("y").alias("b"))))
|
|
.select("res")
|
|
.toPandas()
|
|
)
|
|
assert res_df2.res[0] == 32
|
|
|
|
|
|
def test_model_cache(spark, model_path):
|
|
mlflow.pyfunc.save_model(
|
|
path=model_path,
|
|
loader_module=__name__,
|
|
code_paths=[os.path.dirname(tests.__file__)],
|
|
)
|
|
|
|
archive_path = SparkModelCache.add_local_model(spark, model_path)
|
|
assert archive_path != model_path
|
|
|
|
# Define the model class name as a string so that each Spark executor can reference it
|
|
# without attempting to resolve ConstantPyfuncWrapper, which is only available on the driver.
|
|
constant_model_name = ConstantPyfuncWrapper.__name__
|
|
|
|
def check_get_or_load_return_value(model_from_cache, model_path_from_cache):
|
|
assert model_path_from_cache != model_path
|
|
assert os.path.isdir(model_path_from_cache)
|
|
model2 = mlflow.pyfunc.load_model(model_path_from_cache)
|
|
for model in [model_from_cache, model2]:
|
|
assert isinstance(model, PyFuncModel)
|
|
# NB: Can not use instanceof test as remote does not know about ConstantPyfuncWrapper
|
|
# class.
|
|
assert type(model._model_impl).__name__ == constant_model_name
|
|
|
|
# Ensure we can use the model locally.
|
|
local_model, local_model_path = SparkModelCache.get_or_load(archive_path)
|
|
|
|
check_get_or_load_return_value(local_model, local_model_path)
|
|
|
|
# Request the model on all executors, and see how many times we got cache hits.
|
|
def get_model(_):
|
|
executor_model, executor_model_path = SparkModelCache.get_or_load(archive_path)
|
|
check_get_or_load_return_value(executor_model, executor_model_path)
|
|
return SparkModelCache._cache_hits
|
|
|
|
# This will run 30 distinct tasks, and we expect most to reuse an already-loaded model.
|
|
# Note that we can't necessarily expect an even split, or even that there were only
|
|
# exactly 2 python processes launched, due to Spark and its mysterious ways, but we do
|
|
# expect significant reuse.
|
|
results = spark.sparkContext.parallelize(range(100), 30).map(get_model).collect()
|
|
assert max(results) > 10
|
|
# Running again should see no newly-loaded models.
|
|
results2 = spark.sparkContext.parallelize(range(100), 30).map(get_model).collect()
|
|
assert min(results2) > 0
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not sys.platform.startswith("linux"),
|
|
reason="Only Linux system support setting parent process death signal via prctl lib.",
|
|
)
|
|
@pytest.mark.parametrize("env_manager", ["virtualenv", "conda"])
|
|
def test_spark_udf_embedded_model_server_killed_when_job_canceled(
|
|
spark, sklearn_model, model_path, env_manager
|
|
):
|
|
from mlflow.models.flavor_backend_registry import get_flavor_backend
|
|
from mlflow.pyfunc.scoring_server.client import ScoringServerClient
|
|
|
|
mlflow.sklearn.save_model(sklearn_model.model, model_path)
|
|
|
|
server_port = 51234
|
|
timeout = 60
|
|
|
|
@pandas_udf("int")
|
|
def udf_with_model_server(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
|
|
from mlflow.models.flavor_backend_registry import get_flavor_backend
|
|
|
|
get_flavor_backend(
|
|
model_path, env_manager=env_manager, workers=1, install_mlflow=False
|
|
).serve(
|
|
model_uri=model_path,
|
|
port=server_port,
|
|
host="127.0.0.1",
|
|
timeout=timeout,
|
|
synchronous=False,
|
|
)
|
|
|
|
time.sleep(120)
|
|
yield from it
|
|
|
|
def run_job():
|
|
# Start a spark job with only one UDF task,
|
|
# and the udf task starts a mlflow model server process.
|
|
spark.range(1).repartition(1).select(udf_with_model_server("id")).collect()
|
|
|
|
get_flavor_backend(model_path, env_manager=env_manager, install_mlflow=False).prepare_env(
|
|
model_uri=model_path
|
|
)
|
|
|
|
job_thread = threading.Thread(name="pyfunc-spark-job", target=run_job)
|
|
job_thread.start()
|
|
|
|
client = ScoringServerClient("127.0.0.1", server_port)
|
|
client.wait_server_ready(timeout=20)
|
|
spark.sparkContext.cancelAllJobs()
|
|
job_thread.join()
|
|
|
|
time.sleep(10) # waiting server to exit and release the port.
|
|
|
|
# assert ping failed, i.e. the server process is killed successfully.
|
|
with pytest.raises(Exception, match=r".*"):
|
|
client.ping()
|
|
|
|
|
|
def test_spark_udf_datetime_with_model_schema(spark):
|
|
X, y = datasets.load_iris(as_frame=True, return_X_y=True)
|
|
X = X.assign(
|
|
timestamp=[datetime.datetime(2022, random.randint(1, 12), 1) for _ in range(len(X))]
|
|
)
|
|
|
|
month_extractor = FunctionTransformer(
|
|
lambda df: df.assign(month=df["timestamp"].map(lambda d: d.month)), validate=False
|
|
)
|
|
timestamp_remover = ColumnTransformer(
|
|
[("selector", "passthrough", X.columns.drop("timestamp"))], remainder="drop"
|
|
)
|
|
model = Pipeline([
|
|
("month_extractor", month_extractor),
|
|
("timestamp_remover", timestamp_remover),
|
|
("knn", KNeighborsClassifier()),
|
|
])
|
|
model.fit(X, y)
|
|
|
|
timestamp_dtype = {"timestamp": "datetime64[ns]"}
|
|
with mlflow.start_run():
|
|
signature = mlflow.models.infer_signature(X.astype(timestamp_dtype), y)
|
|
model_info = mlflow.sklearn.log_model(
|
|
model, name="model", signature=signature, serialization_format="cloudpickle"
|
|
)
|
|
|
|
inference_sample = X.sample(n=10, random_state=42)
|
|
infer_spark_df = spark.createDataFrame(inference_sample.astype(timestamp_dtype))
|
|
pyfunc_udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri, env_manager="conda")
|
|
result = infer_spark_df.select(pyfunc_udf(*X.columns).alias("predictions")).toPandas()
|
|
np.testing.assert_almost_equal(result.to_numpy().squeeze(), model.predict(inference_sample))
|
|
|
|
|
|
def test_spark_udf_string_datetime_with_model_schema(spark):
|
|
X, y = datasets.load_iris(as_frame=True, return_X_y=True)
|
|
X = X.assign(timestamp=[f"2022-{random.randint(1, 12):02d}-01" for _ in range(len(X))])
|
|
|
|
month_extractor = FunctionTransformer(
|
|
lambda df: df.assign(month=df["timestamp"].str.extract(r"^2022-0?(\d{1,2})-").astype(int)),
|
|
validate=False,
|
|
)
|
|
timestamp_remover = ColumnTransformer(
|
|
[("selector", "passthrough", X.columns.drop("timestamp"))], remainder="drop"
|
|
)
|
|
model = Pipeline([
|
|
("month_extractor", month_extractor),
|
|
("timestamp_remover", timestamp_remover),
|
|
("knn", KNeighborsClassifier()),
|
|
])
|
|
model.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
signature = mlflow.models.infer_signature(X, y)
|
|
model_info = mlflow.sklearn.log_model(
|
|
model, name="model", signature=signature, serialization_format="cloudpickle"
|
|
)
|
|
|
|
inference_sample = X.sample(n=10, random_state=42)
|
|
infer_spark_df = spark.createDataFrame(inference_sample)
|
|
pyfunc_udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri, env_manager="conda")
|
|
result = infer_spark_df.select(pyfunc_udf(*X.columns).alias("predictions")).toPandas()
|
|
np.testing.assert_almost_equal(result.to_numpy().squeeze(), model.predict(inference_sample))
|
|
|
|
|
|
def test_spark_udf_with_col_spec_type_input(spark):
|
|
input_pdf = pd.DataFrame({
|
|
"c_bool": [True],
|
|
"c_int": [10],
|
|
"c_long": [20],
|
|
"c_float": [1.5],
|
|
"c_double": [2.5],
|
|
"c_str": ["abc"],
|
|
"c_binary": [b"xyz"],
|
|
"c_datetime": [pd.to_datetime("2018-01-01")],
|
|
})
|
|
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
assert model_input.to_dict() == input_pdf.to_dict()
|
|
return model_input[["c_int", "c_float"]]
|
|
|
|
signature = ModelSignature(
|
|
inputs=Schema([
|
|
ColSpec("boolean", "c_bool"),
|
|
ColSpec("integer", "c_int"),
|
|
ColSpec("long", "c_long"),
|
|
ColSpec("float", "c_float"),
|
|
ColSpec("double", "c_double"),
|
|
ColSpec("string", "c_str"),
|
|
ColSpec("binary", "c_binary"),
|
|
ColSpec("datetime", "c_datetime"),
|
|
]),
|
|
)
|
|
|
|
spark_schema = (
|
|
"c_bool boolean, c_int int, c_long long, c_float float, c_double double, "
|
|
"c_str string, c_binary binary, c_datetime timestamp"
|
|
)
|
|
data = spark.createDataFrame(
|
|
data=input_pdf,
|
|
schema=spark_schema,
|
|
).repartition(1)
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(name="model", python_model=TestModel(), signature=signature)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type="c_int int, c_float float",
|
|
env_manager="local",
|
|
)
|
|
res = data.withColumn("res", udf()).select("res.c_int", "res.c_float").toPandas()
|
|
assert res.c_int.tolist() == [10]
|
|
np.testing.assert_almost_equal(res.c_float.tolist(), [1.5])
|
|
|
|
|
|
def test_spark_udf_stdin_scoring_server(spark):
|
|
X, y = datasets.load_iris(return_X_y=True, as_frame=True)
|
|
X = X[::5]
|
|
y = y[::5]
|
|
model = LogisticRegression().fit(X, y)
|
|
model.fit(X, y)
|
|
|
|
with mlflow.start_run():
|
|
signature = mlflow.models.infer_signature(X, y)
|
|
model_info = mlflow.sklearn.log_model(model, name="model", signature=signature)
|
|
|
|
with mock.patch("mlflow.pyfunc.check_port_connectivity", return_value=False):
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
model_info.model_uri,
|
|
env_manager="virtualenv",
|
|
)
|
|
df = spark.createDataFrame(X)
|
|
result = df.select(udf(*X.columns)).toPandas()
|
|
np.testing.assert_almost_equal(result.to_numpy().squeeze(), model.predict(X))
|
|
|
|
|
|
# TODO: Remove `skipif` once pyspark 3.4 is released
|
|
@pytest.mark.skipif(
|
|
Version(pyspark.__version__) < Version("3.4.0"), reason="requires spark >= 3.4.0"
|
|
)
|
|
def test_spark_udf_array_of_structs(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [[("str", 0, 1, 0.0, 0.1, True)]] * len(model_input)
|
|
|
|
signature = ModelSignature(inputs=Schema([ColSpec("long", "a")]))
|
|
good_data = spark.createDataFrame(pd.DataFrame({"a": [1, 2, 3]}))
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=ArrayType(
|
|
StructType([
|
|
StructField("str", StringType()),
|
|
StructField("int", IntegerType()),
|
|
StructField("long", LongType()),
|
|
StructField("float", FloatType()),
|
|
StructField("double", DoubleType()),
|
|
StructField("bool", BooleanType()),
|
|
])
|
|
),
|
|
)
|
|
res = good_data.withColumn("res", udf("a")).select("res").toPandas()
|
|
assert res["res"][0] == [("str", 0, 1, 0.0, 0.1, True)]
|
|
|
|
|
|
def test_spark_udf_return_nullable_array_field(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
values = [np.array([1.0, np.nan])] * (len(model_input) - 2) + [None, np.nan]
|
|
return pd.DataFrame({"a": values})
|
|
|
|
with mlflow.start_run():
|
|
mlflow_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
mlflow_info.model_uri,
|
|
result_type="a array<double>",
|
|
)
|
|
data1 = spark.range(3).repartition(1)
|
|
result = data1.select(udf("id").alias("res")).select("res.a").toPandas()
|
|
assert list(result["a"]) == [[1.0, None], None, None]
|
|
|
|
|
|
def test_spark_udf_with_params(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [[tuple(params.values())]] * len(model_input)
|
|
|
|
test_params = {
|
|
"str_param": "str_a",
|
|
"int_param": np.int32(1),
|
|
"bool_param": True,
|
|
"double_param": 1.0,
|
|
"float_param": np.float32(0.1),
|
|
"long_param": 100,
|
|
}
|
|
|
|
signature = mlflow.models.infer_signature(["input"], params=test_params)
|
|
spark_df = spark.createDataFrame(
|
|
[
|
|
("input1",),
|
|
("input2",),
|
|
("input3",),
|
|
],
|
|
["input_col"],
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=ArrayType(
|
|
StructType([
|
|
StructField("str_param", StringType()),
|
|
StructField("int_param", IntegerType()),
|
|
StructField("bool_param", BooleanType()),
|
|
StructField("double_param", DoubleType()),
|
|
StructField("float_param", FloatType()),
|
|
StructField("long_param", LongType()),
|
|
])
|
|
),
|
|
params=test_params,
|
|
)
|
|
|
|
res = spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
assert res["res"][0] == [tuple(test_params.values())]
|
|
|
|
|
|
def test_spark_udf_with_array_params(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return pd.DataFrame({k: [v] * len(model_input) for k, v in params.items()})
|
|
|
|
test_params = {
|
|
"str_array": np.array(["str_a", "str_b"]),
|
|
"int_array": np.array([np.int32(1), np.int32(2)]),
|
|
"double_array": np.array([1.0, 2.0]),
|
|
"bool_array": np.array([True, False]),
|
|
"float_array": np.array([np.float32(1.0), np.float32(2.0)]),
|
|
"long_array": np.array([1, 2]),
|
|
}
|
|
|
|
signature = mlflow.models.infer_signature(["input"], params=test_params)
|
|
spark_df = spark.createDataFrame(
|
|
[
|
|
("input1",),
|
|
("input2",),
|
|
("input3",),
|
|
],
|
|
["input_col"],
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=StructType([
|
|
StructField("str_array", ArrayType(StringType())),
|
|
StructField("int_array", ArrayType(IntegerType())),
|
|
StructField("double_array", ArrayType(DoubleType())),
|
|
StructField("bool_array", ArrayType(BooleanType())),
|
|
StructField("float_array", ArrayType(FloatType())),
|
|
StructField("long_array", ArrayType(LongType())),
|
|
]),
|
|
params=test_params,
|
|
)
|
|
|
|
res = spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
assert res["res"].values[0] == tuple(v.tolist() for v in test_params.values())
|
|
|
|
|
|
def test_spark_udf_with_params_with_errors(spark):
|
|
# datetime is not supported
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [params.values[0]] * len(model_input)
|
|
|
|
test_params = {"datetime_param": np.datetime64("2023-06-26 00:00:00")}
|
|
signature = mlflow.models.infer_signature(["input"], params=test_params)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
|
|
with pytest.raises(MlflowException, match=r"Invalid 'spark_udf' result type"):
|
|
mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=TimestampType(),
|
|
params=test_params,
|
|
)
|
|
|
|
|
|
def test_spark_udf_compatible_with_mlflow_2_4_0(tmp_path, spark):
|
|
"""
|
|
# Code for logging the model in mlflow 2.4.0
|
|
import mlflow
|
|
|
|
class TestModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input):
|
|
return ["string"] * len(model_input)
|
|
|
|
signature = mlflow.models.infer_signature(["input"])
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
"model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
"""
|
|
tmp_path.joinpath("MLmodel").write_text(
|
|
"""
|
|
artifact_path: model
|
|
flavors:
|
|
python_function:
|
|
cloudpickle_version: 2.2.1
|
|
env:
|
|
conda: conda.yaml
|
|
virtualenv: python_env.yaml
|
|
loader_module: mlflow.pyfunc.model
|
|
python_model: python_model.pkl
|
|
python_version: 3.8.16
|
|
mlflow_version: 2.4.0
|
|
model_uuid: 067c27bc09954838ad6d6bfc89c7eeed
|
|
run_id: 054cfd4d129849f88210568366fea24b
|
|
signature:
|
|
inputs: '[{"type": "string"}]'
|
|
outputs: null
|
|
utc_time_created: '2023-07-17 10:01:42.071952'
|
|
"""
|
|
)
|
|
tmp_path.joinpath("python_env.yaml").write_text(
|
|
"""
|
|
python: 3.8.16
|
|
build_dependencies:
|
|
- pip==23.1.2
|
|
- setuptools==56.0.0
|
|
- wheel==0.40.0
|
|
dependencies:
|
|
- -r requirements.txt
|
|
"""
|
|
)
|
|
tmp_path.joinpath("requirements.txt").write_text(
|
|
"""
|
|
mlflow==2.4.0
|
|
cloudpickle==2.2.1
|
|
"""
|
|
)
|
|
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input):
|
|
return ["string"] * len(model_input)
|
|
|
|
python_model = TestModel()
|
|
|
|
with open(tmp_path / "python_model.pkl", "wb") as out:
|
|
cloudpickle.dump(python_model, out)
|
|
|
|
assert Version(mlflow.__version__) > Version("2.4.0")
|
|
model_uri = str(tmp_path)
|
|
|
|
spark_df = spark.createDataFrame(
|
|
[("input1",), ("input2",), ("input3",)],
|
|
["input_col"],
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
model_uri,
|
|
result_type=StringType(),
|
|
)
|
|
res = spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
assert res["res"][0] == ("string")
|
|
|
|
|
|
def test_spark_udf_with_model_serving(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return ["string"] * len(model_input)
|
|
|
|
test_params = {
|
|
"str_param": "str_a",
|
|
}
|
|
|
|
signature = mlflow.models.infer_signature(["input"], params=test_params)
|
|
spark_df = spark.createDataFrame(
|
|
[
|
|
("input1",),
|
|
("input2",),
|
|
("input3",),
|
|
],
|
|
["input_col"],
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
|
|
with mock.patch("mlflow.pyfunc.check_port_connectivity", return_value=False):
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=StringType(),
|
|
params=test_params,
|
|
env_manager="conda",
|
|
)
|
|
|
|
res = spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
assert res["res"][0] == ("string")
|
|
|
|
|
|
def test_spark_udf_set_extra_udf_env_vars(spark):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [os.environ["TEST_ENV_VAR"]] * len(model_input)
|
|
|
|
signature = mlflow.models.infer_signature(["input"])
|
|
spark_df = spark.createDataFrame(
|
|
[("input1",), ("input2",), ("input3",)],
|
|
["input_col"],
|
|
)
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
model_info.model_uri,
|
|
result_type=StringType(),
|
|
env_manager="local",
|
|
extra_env={"TEST_ENV_VAR": "test"},
|
|
)
|
|
|
|
res = spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
assert res["res"][0] == ("test")
|
|
|
|
|
|
def test_modified_environ():
|
|
with modified_environ({"TEST_ENV_VAR": "test"}):
|
|
assert os.environ["TEST_ENV_VAR"] == "test"
|
|
assert os.environ.get("TEST_ENV_VAR") is None
|
|
|
|
|
|
def test_spark_df_schema_inference_for_map_type(spark):
|
|
data = [
|
|
{
|
|
"arr": ["a", "b"],
|
|
"map1": {"a": 1, "b": 2},
|
|
"map2": {"e": ["e", "e"]},
|
|
"string": "c",
|
|
}
|
|
]
|
|
df = spark.createDataFrame(data)
|
|
expected_schema = Schema([
|
|
ColSpec(Array(DataType.string), "arr"),
|
|
ColSpec(Object([Property("a", DataType.long), Property("b", DataType.long)]), "map1"),
|
|
ColSpec(Object([Property("e", Array(DataType.string))]), "map2"),
|
|
ColSpec(DataType.string, "string"),
|
|
])
|
|
inferred_schema = infer_signature(df).inputs
|
|
assert inferred_schema == expected_schema
|
|
|
|
complex_df = spark.createDataFrame([{"map": {"nested_map": {"a": 1}}}])
|
|
with pytest.raises(
|
|
MlflowException, match=r"Please construct spark DataFrame with schema using StructType"
|
|
):
|
|
_infer_schema(complex_df)
|
|
|
|
|
|
def test_spark_udf_structs_and_arrays(spark, tmp_path):
|
|
class MyModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input):
|
|
return [str(" | ".join(map(str, row))) for _, row in model_input.iterrows()]
|
|
|
|
df = spark.createDataFrame(
|
|
[
|
|
(
|
|
"a",
|
|
[0],
|
|
{"bool": True},
|
|
[{"double": 0.1}],
|
|
),
|
|
(
|
|
"b",
|
|
[1, 2],
|
|
{"bool": False},
|
|
[{"double": 0.2}, {"double": 0.3}],
|
|
),
|
|
],
|
|
schema=StructType([
|
|
StructField(
|
|
"str",
|
|
StringType(),
|
|
),
|
|
StructField(
|
|
"arr",
|
|
ArrayType(IntegerType()),
|
|
),
|
|
StructField(
|
|
"obj",
|
|
StructType([
|
|
StructField("bool", BooleanType()),
|
|
]),
|
|
),
|
|
StructField(
|
|
"obj_arr",
|
|
ArrayType(
|
|
StructType([
|
|
StructField("double", DoubleType()),
|
|
])
|
|
),
|
|
),
|
|
]),
|
|
)
|
|
save_path = tmp_path / "1"
|
|
mlflow.pyfunc.save_model(
|
|
path=save_path,
|
|
python_model=MyModel(),
|
|
signature=mlflow.models.infer_signature(df),
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark=spark, model_uri=save_path, result_type="string")
|
|
pdf = df.withColumn("output", udf("str", "arr", "obj", "obj_arr")).toPandas()
|
|
assert pdf["output"][0] == "a | [0] | {'bool': np.True_} | [{'double': np.float64(0.1)}]"
|
|
assert pdf["output"][1] == (
|
|
"b | [1 2] | {'bool': np.False_} | "
|
|
"[{'double': np.float64(0.2)} {'double': np.float64(0.3)}]"
|
|
)
|
|
|
|
# More complex nested structures
|
|
df = spark.createDataFrame(
|
|
[
|
|
([{"arr": [{"bool": True}]}],),
|
|
([{"arr": [{"bool": False}]}],),
|
|
],
|
|
schema=StructType([
|
|
StructField(
|
|
"test",
|
|
ArrayType(
|
|
StructType([
|
|
StructField(
|
|
"arr",
|
|
ArrayType(
|
|
StructType([
|
|
StructField("bool", BooleanType()),
|
|
])
|
|
),
|
|
),
|
|
])
|
|
),
|
|
),
|
|
]),
|
|
)
|
|
save_path = tmp_path / "2"
|
|
mlflow.pyfunc.save_model(
|
|
path=save_path,
|
|
python_model=MyModel(),
|
|
signature=mlflow.models.infer_signature(df),
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(spark=spark, model_uri=save_path, result_type="string")
|
|
pdf = df.withColumn("output", udf("test")).toPandas()
|
|
assert pdf["output"][0] == "[{'arr': array([{'bool': np.True_}], dtype=object)}]"
|
|
assert pdf["output"][1] == "[{'arr': array([{'bool': np.False_}], dtype=object)}]"
|
|
|
|
|
|
def test_spark_udf_infer_return_type(spark, tmp_path):
|
|
class MyModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input):
|
|
return model_input
|
|
|
|
schema = StructType([
|
|
StructField(
|
|
"str",
|
|
StringType(),
|
|
),
|
|
StructField(
|
|
"arr",
|
|
ArrayType(IntegerType()),
|
|
),
|
|
StructField(
|
|
"obj",
|
|
StructType([
|
|
StructField("bool", BooleanType()),
|
|
StructField("obj2", StructType([StructField("str", StringType())])),
|
|
]),
|
|
),
|
|
StructField(
|
|
"obj_arr",
|
|
ArrayType(
|
|
StructType([
|
|
StructField("double", DoubleType()),
|
|
])
|
|
),
|
|
),
|
|
])
|
|
df = spark.createDataFrame(
|
|
[
|
|
(
|
|
"a",
|
|
[0],
|
|
{"bool": True, "obj2": {"str": "some_string"}},
|
|
[{"double": 0.1}],
|
|
),
|
|
(
|
|
"b",
|
|
[1],
|
|
{"bool": False, "obj2": {"str": "another_string"}},
|
|
[{"double": 0.2}, {"double": 0.3}],
|
|
),
|
|
],
|
|
schema=schema,
|
|
)
|
|
|
|
signature = mlflow.models.infer_signature(df, df)
|
|
mlflow.pyfunc.save_model(
|
|
path=tmp_path,
|
|
python_model=MyModel(),
|
|
signature=signature,
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(spark=spark, model_uri=tmp_path)
|
|
df = df.withColumn("output", udf("str", "arr", "obj", "obj_arr"))
|
|
assert df.schema["output"] == StructField("output", schema)
|
|
pdf = df.toPandas()
|
|
assert pdf["output"][0] == ("a", [0], (True, ("some_string",)), [(0.1,)])
|
|
assert pdf["output"][1] == ("b", [1], (False, ("another_string",)), [(0.2,), (0.3,)])
|
|
|
|
|
|
def test_spark_udf_env_manager_with_invalid_pythonpath(
|
|
spark, sklearn_model, model_path, tmp_path, monkeypatch
|
|
):
|
|
# create an unreadable file
|
|
unreadable_file = tmp_path / "unreadable_file"
|
|
unreadable_file.write_text("unreadable file content")
|
|
unreadable_file.chmod(0o000)
|
|
with pytest.raises(PermissionError, match="Permission denied"):
|
|
with unreadable_file.open():
|
|
pass
|
|
non_exist_file = tmp_path / "does_not_exist"
|
|
origin_python_path = os.environ.get("PYTHONPATH", "")
|
|
monkeypatch.setenv("PYTHONPATH", f"{origin_python_path}:{non_exist_file}:{unreadable_file}")
|
|
|
|
model, inference_data = sklearn_model
|
|
|
|
mlflow.sklearn.save_model(model, model_path)
|
|
expected_pred_result = model.predict(inference_data)
|
|
|
|
infer_data = pd.DataFrame(inference_data, columns=["a", "b"])
|
|
infer_spark_df = spark.createDataFrame(infer_data)
|
|
|
|
with mock.patch("mlflow.utils.databricks_utils.is_in_databricks_runtime", return_value=True):
|
|
pyfunc_udf = spark_udf(spark, model_path, env_manager="virtualenv")
|
|
|
|
result = (
|
|
infer_spark_df
|
|
.select(pyfunc_udf("a", "b").alias("predictions"))
|
|
.toPandas()
|
|
.predictions.to_numpy()
|
|
)
|
|
|
|
np.testing.assert_allclose(result, expected_pred_result, rtol=1e-5)
|
|
|
|
|
|
def test_build_model_env(spark, sklearn_model, model_path, tmp_path, monkeypatch):
|
|
import sklearn
|
|
|
|
from mlflow.pyfunc.dbconnect_artifact_cache import extract_archive_to_dir
|
|
|
|
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "15.4.1")
|
|
spark.udf.register(
|
|
"current_version",
|
|
lambda: {"dbr_version": "15.4.1-scala2.12"},
|
|
returnType="dbr_version string",
|
|
)
|
|
model, inference_data = sklearn_model
|
|
|
|
with mlflow.start_run():
|
|
model_info = mlflow.sklearn.log_model(
|
|
model,
|
|
name="model",
|
|
pip_requirements=[
|
|
f"scikit-learn=={sklearn.__version__}",
|
|
# `build_model_env` doesn't support building env with dev version MLflow,
|
|
# so add MLflow as a required dependency here.
|
|
"mlflow",
|
|
],
|
|
)
|
|
|
|
model_uri = model_info.model_uri
|
|
model_env_path = build_model_env(model_uri, tmp_path)
|
|
archive_name = Path(model_env_path).name[:-7]
|
|
env_name = "-".join(archive_name.split("-")[:2])
|
|
extract_dir = Path("/tmp") / archive_name
|
|
try:
|
|
extract_archive_to_dir(model_env_path, extract_dir)
|
|
# Check the extracted python environment installs the expected sklearn package version.
|
|
subprocess.check_call([
|
|
"bash",
|
|
"-c",
|
|
f"source /tmp/{archive_name}/virtualenv_envs/{env_name}/bin/activate && "
|
|
f"python -c "
|
|
f"\"import sklearn; assert sklearn.__version__ == '{sklearn.__version__}'\"",
|
|
])
|
|
finally:
|
|
shutil.rmtree(f"/tmp/{archive_name}", ignore_errors=True)
|
|
|
|
|
|
class CustomModelWithMlflowConfig(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
alpha = context.model_config["alpha"]
|
|
return [x + alpha for x in model_input[model_input.columns[0]]]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_manager", "use_stdin_serve"),
|
|
[("local", None), ("virtualenv", False), ("virtualenv", True)],
|
|
)
|
|
def test_spark_udf_with_model_config(spark, model_path, monkeypatch, env_manager, use_stdin_serve):
|
|
model = CustomModelWithMlflowConfig()
|
|
mlflow.pyfunc.save_model(
|
|
model_path,
|
|
python_model=model,
|
|
model_config={"alpha": 0},
|
|
code_paths=[os.path.dirname(tests.__file__)],
|
|
)
|
|
with mock.patch("mlflow.pyfunc.check_port_connectivity", return_value=(not use_stdin_serve)):
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
model_path,
|
|
result_type="long",
|
|
model_config={"alpha": 3},
|
|
env_manager=env_manager,
|
|
)
|
|
result = spark.range(10).repartition(1).withColumn("prediction", udf(col("id"))).toPandas()
|
|
assert all(result.id + 3 == result.prediction)
|
|
|
|
|
|
def test_spark_udf_model_server_timeout(spark, monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT", "1")
|
|
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
time.sleep(2)
|
|
return pd.DataFrame({k: [v] * len(model_input) for k, v in params.items()})
|
|
|
|
test_params = {
|
|
"str_array": np.array(["str_a", "str_b"]),
|
|
}
|
|
|
|
signature = mlflow.models.infer_signature(["input"], params=test_params)
|
|
spark_df = spark.createDataFrame(
|
|
[
|
|
("input1",),
|
|
],
|
|
["input_col"],
|
|
)
|
|
with mlflow.start_run() as run:
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
f"runs:/{run.info.run_id}/model",
|
|
result_type=StructType([
|
|
StructField("str_array", ArrayType(StringType())),
|
|
]),
|
|
params=test_params,
|
|
env_manager="virtualenv",
|
|
extra_env={
|
|
"MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT": str(
|
|
MLFLOW_SCORING_SERVER_REQUEST_TIMEOUT.get()
|
|
)
|
|
},
|
|
)
|
|
# Raised from mlflow.pyfunc.scoring_server.client.StdinScoringServerClient,
|
|
# but handled as a PythonException from a subprocess failure for a timeout exception.
|
|
# Broad exception catching here due to PySpark / DBConnect stacktrace handling.
|
|
with pytest.raises(Exception, match="An exception was thrown from the Python worker"):
|
|
spark_df.withColumn("res", udf("input_col")).select("res").toPandas()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("numpy_type", "schema", "value"),
|
|
[
|
|
(np.int32, IntegerType(), 1),
|
|
(np.int64, LongType(), 1),
|
|
(np.float32, FloatType(), 1.0),
|
|
(np.float64, DoubleType(), 1.0),
|
|
(np.bool_, BooleanType(), True),
|
|
(np.str_, StringType(), "string"),
|
|
],
|
|
)
|
|
def test_spark_udf_preserve_model_output_type(spark, numpy_type, schema, value):
|
|
class TestModel(PythonModel):
|
|
def predict(self, context, model_input, params=None):
|
|
return [np.array([numpy_type(value)], dtype=numpy_type)] * len(model_input)
|
|
|
|
signature = mlflow.models.infer_signature(numpy_type(value), numpy_type(value))
|
|
with mlflow.start_run():
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
signature=signature,
|
|
)
|
|
spark_df = spark.createDataFrame(
|
|
[(value,), (value,), (value,)],
|
|
schema=StructType([StructField("input_col", schema)]),
|
|
)
|
|
udf = mlflow.pyfunc.spark_udf(
|
|
spark,
|
|
model_info.model_uri,
|
|
result_type=schema,
|
|
)
|
|
|
|
res = spark_df.withColumn("res", udf("input_col")).toPandas()
|
|
assert res["res"][0] == numpy_type(value)
|
|
|
|
|
|
class _GatePassed(Exception):
|
|
"""Raised by the sentinel to short-circuit `spark_udf` once the version gate is cleared."""
|
|
|
|
|
|
def _make_sandbox_info(runtime_version):
|
|
from mlflow.utils.databricks_utils import DBConnectUDFSandboxInfo
|
|
|
|
return DBConnectUDFSandboxInfo(
|
|
spark=None,
|
|
image_version=runtime_version,
|
|
runtime_version=runtime_version,
|
|
platform_machine="aarch64",
|
|
mlflow_version=mlflow.__version__,
|
|
)
|
|
|
|
|
|
# `get_dbconnect_udf_sandbox_info` normalizes runtime_version to '{major}.{minor}' / '{major}.x',
|
|
# so these are the values production actually delivers to the gate.
|
|
@pytest.mark.parametrize("runtime_version", ["18.x", "18.0", "15.4", "16.2"])
|
|
def test_spark_udf_dbconnect_version_gate_passes(runtime_version):
|
|
# An uncut "18.x" runtime must not raise InvalidVersion and must clear the >= 15.4 gate.
|
|
with (
|
|
mock.patch("mlflow.pyfunc.is_databricks_connect", return_value=True) as mock_dbconnect,
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_shared_cluster_runtime", return_value=False),
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_serverless_runtime", return_value=False),
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_runtime", return_value=True),
|
|
mock.patch(
|
|
"mlflow.pyfunc.get_dbconnect_udf_sandbox_info",
|
|
return_value=_make_sandbox_info(runtime_version),
|
|
) as mock_sandbox,
|
|
mock.patch("mlflow.pyfunc.get_nfs_cache_root_dir", side_effect=_GatePassed("gate passed")),
|
|
):
|
|
spark = mock.MagicMock()
|
|
spark.conf.get.return_value = "false"
|
|
with pytest.raises(_GatePassed, match="gate passed"):
|
|
mlflow.pyfunc.spark_udf(spark, "models:/m/1", env_manager="local")
|
|
mock_dbconnect.assert_called_once()
|
|
mock_sandbox.assert_called_once()
|
|
|
|
|
|
def test_spark_udf_dbconnect_version_gate_rejects_old_runtime():
|
|
with (
|
|
mock.patch("mlflow.pyfunc.is_databricks_connect", return_value=True) as mock_dbconnect,
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_shared_cluster_runtime", return_value=False),
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_serverless_runtime", return_value=False),
|
|
mock.patch("mlflow.pyfunc.is_in_databricks_runtime", return_value=True),
|
|
mock.patch(
|
|
"mlflow.pyfunc.get_dbconnect_udf_sandbox_info",
|
|
return_value=_make_sandbox_info("15.3"),
|
|
) as mock_sandbox,
|
|
):
|
|
spark = mock.MagicMock()
|
|
spark.conf.get.return_value = "false"
|
|
with pytest.raises(MlflowException, match="requires Databricks runtime version >= 15.4"):
|
|
mlflow.pyfunc.spark_udf(spark, "models:/m/1", env_manager="local")
|
|
mock_dbconnect.assert_called_once()
|
|
mock_sandbox.assert_called_once()
|