import datetime import json import os import sys from unittest import mock import numpy as np import pandas as pd import pytest import scipy.sparse import mlflow from mlflow.exceptions import MlflowException from mlflow.models.python_api import ( _CONTENT_TYPE_CSV, _CONTENT_TYPE_JSON, _serialize_input_data, ) from mlflow.tracing.constant import TraceMetadataKey from mlflow.utils.env_manager import CONDA, LOCAL, UV, VIRTUALENV from tests.tracing.helper import get_traces @pytest.mark.parametrize( ("input_data", "expected_data", "content_type"), [ ( "x,y\n1,3\n2,4", pd.DataFrame({"x": [1, 2], "y": [3, 4]}), _CONTENT_TYPE_CSV, ), ( {"a": [1]}, {"a": np.array([1])}, _CONTENT_TYPE_JSON, ), ( 1, np.array(1), _CONTENT_TYPE_JSON, ), ( np.array([1, 2, 3]), np.array([1, 2, 3]), _CONTENT_TYPE_JSON, ), ( scipy.sparse.csc_matrix([[1, 2], [3, 4]]), np.array([[1, 2], [3, 4]]), _CONTENT_TYPE_JSON, ), ( # uLLM input, no change {"input": "some_data"}, {"input": "some_data"}, _CONTENT_TYPE_JSON, ), ], ) @pytest.mark.parametrize( "env_manager", [VIRTUALENV, UV], ) def test_predict(input_data, expected_data, content_type, env_manager): class TestModel(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): if isinstance(model_input, pd.DataFrame): assert model_input.equals(expected_data) elif isinstance(model_input, np.ndarray): assert np.array_equal(model_input, expected_data) else: assert model_input == expected_data return {} with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), extra_pip_requirements=["pytest"], ) mlflow.models.predict( model_uri=model_info.model_uri, input_data=input_data, content_type=content_type, env_manager=env_manager, ) @pytest.mark.parametrize( "env_manager", [VIRTUALENV, CONDA, UV], ) def test_predict_with_pip_requirements_override(env_manager): if env_manager == CONDA: if sys.platform == "win32": pytest.skip("Skipping conda tests on Windows") class TestModel(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): # XGBoost should be installed by pip_requirements_override import xgboost assert xgboost.__version__ == "1.7.3" # Scikit-learn version should be overridden to 1.3.0 by pip_requirements_override import sklearn assert sklearn.__version__ == "1.3.0" with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), extra_pip_requirements=["scikit-learn==1.3.2", "pytest"], ) requirements_override = ["xgboost==1.7.3", "scikit-learn==1.3.0"] if env_manager == CONDA: # Install charset-normalizer with conda-forge to work around pip-vs-conda issue during # CI tests. At the beginning of the CI test, it installs MLflow dependencies via pip, # which includes charset-normalizer. Then when it runs this test case, the conda env # is created but charset-normalizer is installed via the default channel, which is one # major version behind the version installed via pip (as of 2024 Jan). As a result, # Python env confuses pip and conda versions and cause errors like "ImportError: cannot # import name 'COMMON_SAFE_ASCII_CHARACTERS' from 'charset_normalizer.constant'". # To work around this, we install the latest cversion from the conda-forge. # TODO: Implement better isolation approach for pip and conda environments during testing. requirements_override.append("conda-forge::charset-normalizer") mlflow.models.predict( model_uri=model_info.model_uri, input_data={"inputs": [1, 2, 3]}, content_type=_CONTENT_TYPE_JSON, pip_requirements_override=requirements_override, env_manager=env_manager, ) @pytest.mark.parametrize("env_manager", [VIRTUALENV, CONDA, UV]) def test_predict_with_model_alias(env_manager): class TestModel(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): assert os.environ["TEST"] == "test" return model_input with mlflow.start_run(): mlflow.pyfunc.log_model( name="model", python_model=TestModel(), registered_model_name="model_name", ) client = mlflow.MlflowClient() client.set_registered_model_alias("model_name", "test_alias", 1) mlflow.models.predict( model_uri="models:/model_name@test_alias", input_data="abc", env_manager=env_manager, extra_envs={"TEST": "test"}, ) @pytest.mark.parametrize("env_manager", [VIRTUALENV, CONDA, UV]) def test_predict_with_extra_envs(env_manager): class TestModel(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): assert os.environ["TEST"] == "test" return model_input with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), ) mlflow.models.predict( model_uri=model_info.model_uri, input_data="abc", content_type=_CONTENT_TYPE_JSON, env_manager=env_manager, extra_envs={"TEST": "test"}, ) def test_predict_with_extra_envs_errors(): class TestModel(mlflow.pyfunc.PythonModel): def predict(self, context, model_input): assert os.environ["TEST"] == "test" return model_input with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), ) with pytest.raises( MlflowException, match=r"Extra environment variables are only " r"supported when env_manager is set to 'virtualenv', 'conda' or 'uv'", ): mlflow.models.predict( model_uri=model_info.model_uri, input_data="abc", content_type=_CONTENT_TYPE_JSON, env_manager=LOCAL, extra_envs={"TEST": "test"}, ) with pytest.raises( MlflowException, match=r"An exception occurred while running model prediction" ): mlflow.models.predict( model_uri=model_info.model_uri, input_data="abc", content_type=_CONTENT_TYPE_JSON, ) @pytest.fixture def mock_backend(): mock_backend = mock.MagicMock() with mock.patch("mlflow.models.python_api.get_flavor_backend", return_value=mock_backend): yield mock_backend def test_predict_with_both_input_data_and_path_raise(mock_backend): with pytest.raises(MlflowException, match=r"Both input_data and input_path are provided"): mlflow.models.predict( model_uri="runs:/test/Model", input_data={"inputs": [1, 2, 3]}, input_path="input.csv", content_type=_CONTENT_TYPE_CSV, ) def test_predict_invalid_content_type(mock_backend): with pytest.raises(MlflowException, match=r"Content type must be one of"): mlflow.models.predict( model_uri="runs:/test/Model", input_data={"inputs": [1, 2, 3]}, content_type="any", ) def test_predict_with_input_none(mock_backend): mlflow.models.predict( model_uri="runs:/test/Model", content_type=_CONTENT_TYPE_CSV, ) mock_backend.predict.assert_called_once_with( model_uri="runs:/test/Model", input_path=None, output_path=None, content_type=_CONTENT_TYPE_CSV, pip_requirements_override=None, extra_envs=None, ) @pytest.mark.parametrize( ("input_data", "content_type", "expected"), [ # String (convert to serving input) ("[1, 2, 3]", _CONTENT_TYPE_JSON, '{"inputs": "[1, 2, 3]"}'), # uLLM String (no change) ({"input": "data"}, _CONTENT_TYPE_JSON, '{"input": "data"}'), ("x,y,z\n1,2,3\n4,5,6", _CONTENT_TYPE_CSV, "x,y,z\n1,2,3\n4,5,6"), # Bool (True, _CONTENT_TYPE_JSON, '{"inputs": true}'), # Int (1, _CONTENT_TYPE_JSON, '{"inputs": 1}'), # Float (1.0, _CONTENT_TYPE_JSON, '{"inputs": 1.0}'), # Datetime ( datetime.datetime(2021, 1, 1, 0, 0, 0), _CONTENT_TYPE_JSON, '{"inputs": "2021-01-01T00:00:00"}', ), # List ([1, 2, 3], _CONTENT_TYPE_CSV, "0\n1\n2\n3\n"), # a header '0' is added by pandas ([[1, 2, 3], [4, 5, 6]], _CONTENT_TYPE_CSV, "0,1,2\n1,2,3\n4,5,6\n"), # Dict (pandas) ( { "x": [ 1, 2, ], "y": [3, 4], }, _CONTENT_TYPE_CSV, "x,y\n1,3\n2,4\n", ), # Dict (json) ({"a": [1, 2, 3]}, _CONTENT_TYPE_JSON, '{"inputs": {"a": [1, 2, 3]}}'), # Pandas DataFrame (csv) (pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}), _CONTENT_TYPE_CSV, "x,y\n1,4\n2,5\n3,6\n"), # Pandas DataFrame (json) ( pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}), _CONTENT_TYPE_JSON, '{"dataframe_split": {"columns": ["x", "y"], "data": [[1, 4], [2, 5], [3, 6]]}}', ), # Numpy Array (np.array([1, 2, 3]), _CONTENT_TYPE_JSON, '{"inputs": [1, 2, 3]}'), # CSC Matrix ( scipy.sparse.csc_matrix([[1, 2], [3, 4]]), _CONTENT_TYPE_JSON, '{"inputs": [[1, 2], [3, 4]]}', ), # CSR Matrix ( scipy.sparse.csr_matrix([[1, 2], [3, 4]]), _CONTENT_TYPE_JSON, '{"inputs": [[1, 2], [3, 4]]}', ), ], ) def test_serialize_input_data(input_data, content_type, expected): if content_type == _CONTENT_TYPE_JSON: assert json.loads(_serialize_input_data(input_data, content_type)) == json.loads(expected) else: assert _serialize_input_data(input_data, content_type) == expected @pytest.mark.parametrize( ("input_data", "content_type"), [ # Invalid input datatype for the content type (1, _CONTENT_TYPE_CSV), ({1, 2, 3}, _CONTENT_TYPE_CSV), # Invalid string ("x,y\n1,2\n3,4,5\n", _CONTENT_TYPE_CSV), # Invalid list ([[1, 2], [3, 4], 5], _CONTENT_TYPE_CSV), # Invalid dict (unserealizable) ({"x": 1, "y": {1, 2, 3}}, _CONTENT_TYPE_JSON), ], ) def test_serialize_input_data_invalid_format(input_data, content_type): with pytest.raises(MlflowException): # noqa: PT011 _serialize_input_data(input_data, content_type) def test_predict_use_current_experiment(): class TestModel(mlflow.pyfunc.PythonModel): @mlflow.trace def predict(self, context, model_input: list[str]): return model_input exp_id = mlflow.set_experiment("test_experiment").experiment_id client = mlflow.MlflowClient() with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), ) assert len(client.search_traces(locations=[exp_id])) == 0 mlflow.models.predict( model_uri=model_info.model_uri, input_data=["a", "b", "c"], env_manager=VIRTUALENV, ) traces = client.search_traces(locations=[exp_id]) assert len(traces) == 1 assert json.loads(traces[0].data.request)["model_input"] == ["a", "b", "c"] def test_predict_traces_link_to_active_model(): model = mlflow.set_active_model(name="test_model") class TestModel(mlflow.pyfunc.PythonModel): @mlflow.trace def predict(self, context, model_input: list[str]): return model_input with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( name="model", python_model=TestModel(), ) traces = get_traces() assert len(traces) == 0 mlflow.models.predict( model_uri=model_info.model_uri, input_data=["a", "b", "c"], env_manager=VIRTUALENV, ) traces = get_traces() assert len(traces) == 1 assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model.model_id