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

226 lines
8.5 KiB
Python

import inspect
import warnings
from functools import lru_cache, wraps
from typing import Any, NamedTuple
import pydantic
from mlflow.exceptions import MlflowException
from mlflow.models.signature import (
_extract_type_hints,
_is_context_in_predict_function_signature,
)
from mlflow.types.type_hints import (
InvalidTypeHintException,
_convert_data_to_type_hint,
_infer_schema_from_list_type_hint,
_is_type_hint_from_example,
_signature_cannot_be_inferred_from_type_hint,
_validate_data_against_type_hint,
model_validate,
)
from mlflow.utils.annotations import filter_user_warnings_once
from mlflow.utils.warnings_utils import color_warning
_INVALID_SIGNATURE_ERROR_MSG = (
"Model's `{func_name}` method contains invalid parameters: {invalid_params}. "
"Only the following parameter names are allowed: context, model_input, and params. "
"Note that invalid parameters will no longer be permitted in future versions."
)
class FuncInfo(NamedTuple):
input_type_hint: type[Any] | None
input_param_name: str
def pyfunc(func):
"""
A decorator that forces data validation against type hint of the input data
in the wrapped method. It is no-op if the type hint is not supported by MLflow.
.. note::
The function that applies this decorator must be a valid `predict` function
of `mlflow.pyfunc.PythonModel`, or a callable that takes a single input.
"""
func_info = _get_func_info_if_type_hint_supported(func)
return _wrap_predict_with_pyfunc(func, func_info)
def _wrap_predict_with_pyfunc(func, func_info: FuncInfo | None):
if func_info is not None:
model_input_index = _model_input_index_in_function_signature(func)
@wraps(func)
def wrapper(*args, **kwargs):
try:
args, kwargs = _validate_model_input(
args,
kwargs,
model_input_index,
func_info.input_type_hint,
func_info.input_param_name,
)
except Exception as e:
if isinstance(e, MlflowException):
raise e
raise MlflowException(
"Failed to validate the input data against the type hint "
f"`{func_info.input_type_hint}`. Error: {e}"
)
return func(*args, **kwargs)
else:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._is_pyfunc = True
return wrapper
def wrap_non_list_predict_pydantic(func, input_pydantic_model, validation_error_msg, unpack=False):
"""
Used by MLflow defined subclasses of PythonModel that have non-list a pydantic model as input.
Takes in a dict input, validates it against `input_pydantic_model`, and then creates
the pydantic model.
If `unpack` is True, the validated dict is parsed into the function arguments.
Otherwise, the whole pydantic object is passed to the function.
Args:
func: The predict/predict_stream method of the PythonModel subclass.
input_pydantic_model: The pydantic model that the input should be validated against.
validation_error_msg: The error message to raise if the dict input fails to validate.
unpack: Whether to unpack the validated dict into the function arguments. Defaults to False.
Raises:
MlflowException: If the input fails to validate against the pydantic model.
Returns:
A function that can take either a dict input or a pydantic object as input.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
if len(args) == 1 and isinstance(args[0], dict):
try:
model_validate(input_pydantic_model, args[0])
pydantic_obj = input_pydantic_model(**args[0])
except pydantic.ValidationError as e:
raise MlflowException(
f"{validation_error_msg} Pydantic validation error: {e}"
) from e
else:
if unpack:
param_names = inspect.signature(func).parameters.keys() - {"self"}
kwargs = {k: getattr(pydantic_obj, k) for k in param_names}
return func(self, **kwargs)
else:
return func(self, pydantic_obj)
else:
# Before logging, this is equivalent to the behavior from the raw predict method
# After logging, signature enforcement happens in the _convert_input method
# of the wrapper class
return func(self, *args, **kwargs)
wrapper._is_pyfunc = True
return wrapper
def _check_func_signature(func, func_name) -> list[str]:
parameters = inspect.signature(func).parameters
param_names = [name for name in parameters.keys() if name != "self"]
if invalid_params := set(param_names) - {"self", "context", "model_input", "params"}:
warnings.warn(
_INVALID_SIGNATURE_ERROR_MSG.format(func_name=func_name, invalid_params=invalid_params),
FutureWarning,
stacklevel=2,
)
return param_names
@lru_cache
@filter_user_warnings_once
def _get_func_info_if_type_hint_supported(func) -> FuncInfo | None:
"""
Internal method to check if the predict function has type hints and if they are supported
by MLflow.
For PythonModel, the signature must be one of below:
- predict(self, context, model_input, params=None)
- predict(self, model_input, params=None)
For callables, the function must contain only one input argument.
"""
param_names = _check_func_signature(func, "predict")
input_arg_index = 1 if _is_context_in_predict_function_signature(func=func) else 0
type_hint = _extract_type_hints(func, input_arg_index=input_arg_index).input
input_param_name = param_names[input_arg_index]
if type_hint is not None:
if _signature_cannot_be_inferred_from_type_hint(type_hint) or _is_type_hint_from_example(
type_hint
):
return
try:
_infer_schema_from_list_type_hint(type_hint)
except InvalidTypeHintException as e:
raise MlflowException(
f"{e.message} To disable data validation, remove the type hint from the "
"predict function. Otherwise, fix the type hint."
)
# catch other exceptions to avoid breaking model usage
except Exception as e:
color_warning(
message="Type hint used in the model's predict function is not supported "
f"for MLflow's schema validation. {e} "
"Remove the type hint to disable this warning. "
"To enable validation for the input data, specify input example "
"or model signature when logging the model. ",
category=UserWarning,
stacklevel=3,
color="red",
)
else:
return FuncInfo(input_type_hint=type_hint, input_param_name=input_param_name)
else:
color_warning(
"Add type hints to the `predict` method to enable data validation "
"and automatic signature inference during model logging. "
"Check https://mlflow.org/docs/latest/model/python_model.html#type-hint-usage-in-pythonmodel"
" for more details.",
stacklevel=1,
color="yellow",
category=UserWarning,
)
def _model_input_index_in_function_signature(func):
parameters = inspect.signature(func).parameters
# we need to exclude the first argument if "self" is in the parameters
index = 1 if "self" in parameters else 0
if _is_context_in_predict_function_signature(parameters=parameters):
index += 1
return index
def _validate_model_input(
args, kwargs, model_input_index_in_sig, type_hint, model_input_param_name
):
model_input = None
input_pos = None
if model_input_param_name in kwargs:
model_input = kwargs[model_input_param_name]
input_pos = "kwargs"
elif len(args) >= model_input_index_in_sig + 1:
model_input = args[model_input_index_in_sig]
input_pos = model_input_index_in_sig
if input_pos is not None:
data = _convert_data_to_type_hint(model_input, type_hint)
data = _validate_data_against_type_hint(data, type_hint)
if input_pos == "kwargs":
kwargs[model_input_param_name] = data
else:
args = args[:input_pos] + (data,) + args[input_pos + 1 :]
return args, kwargs