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

536 lines
20 KiB
Python

import contextlib
import json
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Any
import yaml
from mlflow.exceptions import MlflowException
from mlflow.models import Model
from mlflow.models.model import MLMODEL_FILE_NAME
from mlflow.protos.databricks_pb2 import (
INVALID_PARAMETER_VALUE,
RESOURCE_ALREADY_EXISTS,
RESOURCE_DOES_NOT_EXIST,
)
from mlflow.store.artifact.artifact_repository_registry import get_artifact_repository
from mlflow.store.artifact.models_artifact_repo import ModelsArtifactRepository
from mlflow.store.artifact.runs_artifact_repo import RunsArtifactRepository
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.utils import get_parent_module
from mlflow.utils.databricks_utils import is_in_databricks_runtime
from mlflow.utils.file_utils import TempDir, _copy_file_or_tree
from mlflow.utils.requirements_utils import _capture_imported_modules
from mlflow.utils.uri import append_to_uri_path
FLAVOR_CONFIG_CODE = "code"
EXTRA_FILES_KEY = "extra_files"
_logger = logging.getLogger(__name__)
def _copy_extra_files(extra_files, path):
"""Validates and copies extra files to the model directory.
Downloads extra files from URIs and copies them to an 'extra_files' subdirectory
within the model path.
Args:
extra_files: A list of URIs or local paths to extra files that should be saved
alongside the model. Remote URIs are resolved to absolute filesystem paths.
path: The local model path where the extra files will be stored.
Returns:
A dictionary with the extra_files configuration that should be added to the
flavor config. Returns an empty dict if extra_files is None or empty.
Raises:
TypeError: If extra_files is not a list.
Example:
>>> extra_files_config = _copy_extra_files(
... ["s3://bucket/f1.txt", "/local/f2.txt"], "/path/to/model"
... )
>>> # extra_files_config will be:
>>> # {"extra_files": [{"path": "extra_files/f1.txt"}, {"path": "extra_files/f2.txt"}]}
"""
if not extra_files:
return {}
if not isinstance(extra_files, list):
raise TypeError("Extra files argument should be a list")
extra_files_config = {EXTRA_FILES_KEY: []}
with TempDir() as tmp_extra_files_dir:
for extra_file in extra_files:
_download_artifact_from_uri(
artifact_uri=extra_file, output_path=tmp_extra_files_dir.path()
)
rel_path = os.path.join(EXTRA_FILES_KEY, os.path.basename(extra_file))
extra_files_config[EXTRA_FILES_KEY].append({"path": rel_path})
shutil.move(
tmp_extra_files_dir.path(),
os.path.join(path, EXTRA_FILES_KEY),
)
return extra_files_config
def _get_all_flavor_configurations(model_path):
"""Obtains all the flavor configurations from the specified MLflow model path.
Args:
model_path: The path to the root directory of the MLflow model for which to load
the specified flavor configuration.
Returns:
The dictionary contains all flavor configurations with flavor name as key.
"""
return Model.load(model_path).flavors
def _get_flavor_configuration(model_path, flavor_name):
"""Obtains the configuration for the specified flavor from the specified
MLflow model path. If the model does not contain the specified flavor,
an exception will be thrown.
Args:
model_path: The path to the root directory of the MLflow model for which to load
the specified flavor configuration.
flavor_name: The name of the flavor configuration to load.
Returns:
The flavor configuration as a dictionary.
"""
try:
return Model.load(model_path).flavors[flavor_name]
except KeyError as ex:
raise MlflowException(
f'Model does not have the "{flavor_name}" flavor', RESOURCE_DOES_NOT_EXIST
) from ex
def _get_flavor_configuration_from_uri(model_uri, flavor_name, logger):
"""Obtains the configuration for the specified flavor from the specified
MLflow model uri. If the model does not contain the specified flavor,
an exception will be thrown.
Args:
model_uri: The path to the root directory of the MLflow model for which to load
the specified flavor configuration.
flavor_name: The name of the flavor configuration to load.
logger: The local flavor's logger to report the resolved path of the model uri.
Returns:
The flavor configuration as a dictionary.
"""
try:
resolved_uri = model_uri
if RunsArtifactRepository.is_runs_uri(model_uri):
resolved_uri = RunsArtifactRepository.get_underlying_uri(model_uri)
logger.info("'%s' resolved as '%s'", model_uri, resolved_uri)
elif ModelsArtifactRepository.is_models_uri(model_uri):
resolved_uri = ModelsArtifactRepository.get_underlying_uri(model_uri)
logger.info("'%s' resolved as '%s'", model_uri, resolved_uri)
try:
ml_model_file = _download_artifact_from_uri(
artifact_uri=append_to_uri_path(resolved_uri, MLMODEL_FILE_NAME)
)
except Exception:
logger.debug(
f'Failed to download an "{MLMODEL_FILE_NAME}" model file from '
f"resolved URI {resolved_uri}. "
f"Falling back to downloading from original model URI {model_uri}",
exc_info=True,
)
ml_model_file = get_artifact_repository(artifact_uri=model_uri).download_artifacts(
artifact_path=MLMODEL_FILE_NAME
)
except Exception as ex:
raise MlflowException(
f'Failed to download an "{MLMODEL_FILE_NAME}" model file from "{model_uri}"',
RESOURCE_DOES_NOT_EXIST,
) from ex
return _get_flavor_configuration_from_ml_model_file(ml_model_file, flavor_name)
def _get_flavor_configuration_from_ml_model_file(ml_model_file, flavor_name):
model_conf = Model.load(ml_model_file)
if flavor_name not in model_conf.flavors:
raise MlflowException(
f'Model does not have the "{flavor_name}" flavor',
RESOURCE_DOES_NOT_EXIST,
)
return model_conf.flavors[flavor_name]
def _validate_code_paths(code_paths):
if code_paths is not None:
if not isinstance(code_paths, list):
raise TypeError(f"Argument code_paths should be a list, not {type(code_paths)}")
def _validate_and_copy_code_paths(code_paths, path, default_subpath="code"):
"""Validates that a code path is a valid list and copies the code paths to a directory. This
can later be used to log custom code as an artifact.
Args:
code_paths: A list of files or directories containing code that should be logged
as artifacts.
path: The local model path.
default_subpath: The default directory name used to store code artifacts.
"""
_validate_code_paths(code_paths)
if code_paths is not None:
code_dir_subpath = default_subpath
for code_path in code_paths:
try:
_copy_file_or_tree(src=code_path, dst=path, dst_dir=code_dir_subpath)
except OSError as e:
# A common error is code-paths includes Databricks Notebook. We include it in error
# message when running in Databricks, but not in other envs tp avoid confusion.
example = ", such as Databricks Notebooks" if is_in_databricks_runtime() else ""
raise MlflowException(
message=(
f"Failed to copy the specified code path '{code_path}' into the model "
"artifacts. It appears that your code path includes file(s) that cannot "
f"be copied{example}. Please specify a code path that does not include "
"such files and try again.",
),
error_code=INVALID_PARAMETER_VALUE,
) from e
else:
code_dir_subpath = None
return code_dir_subpath
def _infer_and_copy_code_paths(flavor, path, default_subpath="code"):
# Capture all imported modules with full module name during loading model.
modules = _capture_imported_modules(path, flavor, record_full_module=True)
all_modules = set(modules)
for module in modules:
parent_module = module
while "." in parent_module:
parent_module = get_parent_module(parent_module)
all_modules.add(parent_module)
# Generate code_paths set from the imported modules full name list.
# It only picks necessary files, because:
# 1. Reduce risk of logging files containing user credentials to MLflow
# artifact repository.
# 2. In databricks runtime, notebook files might exist under a code_paths directory,
# if logging the whole directory to MLflow artifact repository, these
# notebook files are not accessible and trigger exceptions. On the other
# hand, these notebook files are not used as code_paths modules because
# code in notebook files are loaded into python `__main__` module.
code_paths = set()
for full_module_name in all_modules:
relative_path_str = full_module_name.replace(".", os.sep)
relative_path = Path(relative_path_str)
if relative_path.is_dir():
init_file_path = relative_path / "__init__.py"
if init_file_path.exists():
code_paths.add(init_file_path)
py_module_path = Path(relative_path_str + ".py")
if py_module_path.is_file():
code_paths.add(py_module_path)
if code_paths:
for code_path in code_paths:
src_dir_path = code_path.parent
src_file_name = code_path.name
dest_dir_path = Path(path) / default_subpath / src_dir_path
dest_file_path = dest_dir_path / src_file_name
dest_dir_path.mkdir(parents=True, exist_ok=True)
shutil.copyfile(code_path, dest_file_path)
return default_subpath
return None
def _validate_infer_and_copy_code_paths(
code_paths, path, infer_code_paths, flavor, default_subpath="code"
):
if infer_code_paths:
if code_paths:
raise MlflowException(
"If 'infer_code_path' is set to True, 'code_paths' param cannot be set."
)
return _infer_and_copy_code_paths(flavor, path, default_subpath)
else:
return _validate_and_copy_code_paths(code_paths, path, default_subpath)
def _validate_path_exists(path, name):
if path and not os.path.exists(path):
raise MlflowException(
message=(
f"Failed to copy the specified {name} path '{path}' into the model "
f"artifacts. The specified {name}path does not exist. Please specify a valid "
f"{name} path and try again."
),
error_code=INVALID_PARAMETER_VALUE,
)
def _validate_and_copy_file_to_directory(file_path: str, dir_path: str, name: str):
"""Copies the file at file_path to the directory at dir_path.
Args:
file_path: A file that should be logged as an artifact.
dir_path: The path of the directory to save the file to.
name: The name for the kind of file being copied.
"""
_validate_path_exists(file_path, name)
try:
_copy_file_or_tree(src=file_path, dst=dir_path)
except OSError as e:
# A common error is code-paths includes Databricks Notebook. We include it in error
# message when running in Databricks, but not in other envs tp avoid confusion.
example = ", such as Databricks Notebooks" if is_in_databricks_runtime() else ""
raise MlflowException(
message=(
f"Failed to copy the specified code path '{file_path}' into the model "
"artifacts. It appears that your code path includes file(s) that cannot "
f"be copied{example}. Please specify a code path that does not include "
"such files and try again.",
),
error_code=INVALID_PARAMETER_VALUE,
) from e
def _add_code_to_system_path(code_path):
sys.path = [code_path] + sys.path
def _validate_and_prepare_target_save_path(path):
if os.path.exists(path) and any(os.scandir(path)):
raise MlflowException(
message=f"Path '{path}' already exists and is not empty",
error_code=RESOURCE_ALREADY_EXISTS,
)
os.makedirs(path, exist_ok=True)
def _add_code_from_conf_to_system_path(local_path, conf, code_key=FLAVOR_CONFIG_CODE):
"""Checks if any code_paths were logged with the model in the flavor conf and prepends
the directory to the system path.
Args:
local_path: The local path containing model artifacts.
conf: The flavor-specific conf that should contain the FLAVOR_CONFIG_CODE
key, which specifies the directory containing custom code logged as artifacts.
code_key: The key used by the flavor to indicate custom code artifacts.
By default this is FLAVOR_CONFIG_CODE.
"""
assert isinstance(conf, dict), "`conf` argument must be a dict."
if code_key in conf and conf[code_key]:
code_path = os.path.join(local_path, conf[code_key])
_add_code_to_system_path(code_path)
def _validate_onnx_session_options(onnx_session_options):
"""Validates that the specified onnx_session_options dict is valid.
Args:
onnx_session_options: The onnx_session_options dict to validate.
"""
import onnxruntime as ort
if onnx_session_options is not None:
if not isinstance(onnx_session_options, dict):
raise TypeError(
f"Argument onnx_session_options should be a dict, not {type(onnx_session_options)}"
)
for key, value in onnx_session_options.items():
if key != "extra_session_config" and not hasattr(ort.SessionOptions, key):
raise ValueError(
f"Key {key} in onnx_session_options is not a valid "
"ONNX Runtime session options key"
)
elif key == "extra_session_config" and not isinstance(value, dict):
raise TypeError(
f"Value for key {key} in onnx_session_options should be a dict, "
"not {type(value)}"
)
elif key == "execution_mode" and value.upper() not in [
"PARALLEL",
"SEQUENTIAL",
]:
raise ValueError(
f"Value for key {key} in onnx_session_options should be "
f"'parallel' or 'sequential', not {value}"
)
elif key == "graph_optimization_level" and value not in [0, 1, 2, 99]:
raise ValueError(
f"Value for key {key} in onnx_session_options should be 0, 1, 2, or 99, "
f"not {value}"
)
elif key in ["intra_op_num_threads", "intra_op_num_threads"] and value < 0:
raise ValueError(
f"Value for key {key} in onnx_session_options should be >= 0, not {value}"
)
def _get_overridden_pyfunc_model_config(
pyfunc_config: dict[str, Any], load_config: dict[str, Any], logger
) -> dict[str, Any]:
"""
Updates the inference configuration according to the model's configuration and the overrides.
Only arguments already present in the inference configuration can be updated. The environment
variable ``MLFLOW_PYFUNC_INFERENCE_CONFIG`` can also be used to provide additional inference
configuration.
"""
overrides = {}
if env_overrides := os.environ.get("MLFLOW_PYFUNC_INFERENCE_CONFIG"):
logger.debug(
"Inference configuration is being loaded from ``MLFLOW_PYFUNC_INFERENCE_CONFIG``"
" environ."
)
overrides.update(dict(json.loads(env_overrides)))
if load_config:
overrides.update(load_config)
if not overrides:
return pyfunc_config
if not pyfunc_config:
logger.warning(
f"Argument(s) {', '.join(overrides.keys())} were ignored since the model's ``pyfunc``"
" flavor doesn't accept model configuration. Use ``model_config`` when logging"
" the model to allow it."
)
return None
valid_keys = set(pyfunc_config.keys()) & set(overrides.keys())
ignored_keys = set(overrides.keys()) - valid_keys
allowed_config = {key: overrides[key] for key in valid_keys}
if ignored_keys:
logger.warning(
f"Argument(s) {', '.join(ignored_keys)} were ignored since they are not valid keys in"
" the corresponding section of the ``pyfunc`` flavor. Use ``model_config`` when"
" logging the model to include the keys you plan to indicate. Current allowed"
f" configuration includes {', '.join(pyfunc_config.keys())}"
)
pyfunc_config.update(allowed_config)
return pyfunc_config
def _validate_and_get_model_config_from_file(model_config):
model_config = os.path.abspath(model_config)
if os.path.exists(model_config):
with open(model_config) as file:
try:
return yaml.safe_load(file)
except yaml.YAMLError as e:
raise MlflowException(
f"The provided `model_config` file '{model_config}' is not a valid YAML "
f"file: {e}",
error_code=INVALID_PARAMETER_VALUE,
)
else:
raise MlflowException(
"An invalid `model_config` file was passed. The provided `model_config` "
f"file '{model_config}'is not a valid file path.",
error_code=INVALID_PARAMETER_VALUE,
)
def _validate_pyfunc_model_config(model_config):
"""
Validates the values passes in the model_config section. There are no typing
restrictions but we require them being JSON-serializable.
"""
if not model_config:
return
if isinstance(model_config, Path):
_validate_and_get_model_config_from_file(os.fspath(model_config))
elif isinstance(model_config, str):
_validate_and_get_model_config_from_file(model_config)
elif isinstance(model_config, dict) and all(isinstance(key, str) for key in model_config):
try:
json.dumps(model_config)
except (TypeError, OverflowError):
raise MlflowException(
"Values in the provided ``model_config`` are of an unsupported type. Only "
"JSON-serializable data types can be provided as values.",
error_code=INVALID_PARAMETER_VALUE,
)
else:
raise MlflowException(
"An invalid ``model_config`` structure was passed. ``model_config`` must be a "
"valid file path or of type ``dict`` with string keys.",
error_code=INVALID_PARAMETER_VALUE,
)
RECORD_ENV_VAR_ALLOWLIST = {
# api key related
"API_KEY",
"API_TOKEN",
# databricks auth related
"DATABRICKS_HOST",
"DATABRICKS_USERNAME",
"DATABRICKS_PASSWORD",
"DATABRICKS_TOKEN",
"DATABRICKS_INSECURE",
"DATABRICKS_CLIENT_ID",
"DATABRICKS_CLIENT_SECRET",
"_DATABRICKS_WORKSPACE_HOST",
"_DATABRICKS_WORKSPACE_ID",
}
@contextlib.contextmanager
def env_var_tracker():
"""
Context manager for temporarily tracking environment variables accessed.
It tracks environment variables accessed during the context manager's lifetime.
"""
from mlflow.environment_variables import MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING
tracked_env_names = set()
if MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING.get():
original_getitem = os._Environ.__getitem__
original_get = os._Environ.get
def updated_get_item(self, key):
result = original_getitem(self, key)
tracked_env_names.add(key)
return result
def updated_get(self, key, *args, **kwargs):
if key in self:
tracked_env_names.add(key)
return original_get(self, key, *args, **kwargs)
try:
os._Environ.__getitem__ = updated_get_item
os._Environ.get = updated_get
yield tracked_env_names
finally:
os._Environ.__getitem__ = original_getitem
os._Environ.get = original_get
else:
yield tracked_env_names