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

260 lines
10 KiB
Python

"""
This script should be executed in a fresh python interpreter process using `subprocess`.
"""
import argparse
import builtins
import functools
import importlib
import json
import os
import sys
import mlflow
from mlflow.models.model import MLMODEL_FILE_NAME, Model
from mlflow.pyfunc import MAIN
from mlflow.utils._spark_utils import _prepare_subprocess_environ_for_creating_local_spark_session
from mlflow.utils.exception_utils import get_stacktrace
from mlflow.utils.file_utils import write_to
from mlflow.utils.requirements_utils import (
DATABRICKS_MODULES_TO_PACKAGES,
MLFLOW_MODULES_TO_PACKAGES,
)
def _get_top_level_module(full_module_name):
return full_module_name.split(".")[0]
def _get_second_level_module(full_module_name):
return ".".join(full_module_name.split(".")[:2])
class _CaptureImportedModules:
"""
A context manager to capture imported modules by temporarily applying a patch to
`builtins.__import__` and `importlib.import_module`.
If `record_full_module` is set to `False`, it only captures top level modules
for inferring python package purpose.
If `record_full_module` is set to `True`, it captures full module name for all
imported modules and sub-modules. This is used in automatic model code path inference.
"""
def __init__(self, record_full_module=False):
self.imported_modules = set()
self.original_import = None
self.original_import_module = None
self.record_full_module = record_full_module
def _wrap_import(self, original):
@functools.wraps(original)
def wrapper(name, globals=None, locals=None, fromlist=(), level=0):
is_absolute_import = level == 0
if not self.record_full_module and is_absolute_import:
self._record_imported_module(name)
result = original(name, globals, locals, fromlist, level)
if self.record_full_module:
if is_absolute_import:
parent_modules = name.split(".")
else:
parent_modules = globals["__name__"].split(".")
if level > 1:
parent_modules = parent_modules[: -(level - 1)]
if fromlist:
for from_name in fromlist:
full_modules = parent_modules + [from_name]
full_module_name = ".".join(full_modules)
if full_module_name in sys.modules:
self._record_imported_module(full_module_name)
else:
# in the case that `from_name` is a function or a class
# then record the parent_module
self._record_imported_module(".".join(parent_modules))
else:
full_module_name = ".".join(parent_modules)
self._record_imported_module(full_module_name)
return result
return wrapper
def _wrap_import_module(self, original):
@functools.wraps(original)
def wrapper(name, *args, **kwargs):
self._record_imported_module(name)
return original(name, *args, **kwargs)
return wrapper
def _record_imported_module(self, full_module_name):
if self.record_full_module:
self.imported_modules.add(full_module_name)
return
# If the module is an internal module (prefixed by "_") or is the "databricks"
# module, which is populated by many different packages, don't record it (specific
# module imports within the databricks namespace are still recorded and mapped to
# their corresponding packages)
if full_module_name.startswith("_") or full_module_name == "databricks":
return
top_level_module = _get_top_level_module(full_module_name)
second_level_module = _get_second_level_module(full_module_name)
if top_level_module == "databricks":
# Multiple packages populate the `databricks` module namespace on Databricks;
# to avoid bundling extraneous Databricks packages into model dependencies, we
# scope each module to its relevant package
if second_level_module in DATABRICKS_MODULES_TO_PACKAGES:
self.imported_modules.add(second_level_module)
return
for databricks_module in DATABRICKS_MODULES_TO_PACKAGES:
if full_module_name.startswith(databricks_module):
self.imported_modules.add(databricks_module)
return
# special casing for mlflow extras since they may not be required by default
if top_level_module == "mlflow":
if second_level_module in MLFLOW_MODULES_TO_PACKAGES:
self.imported_modules.add(second_level_module)
return
self.imported_modules.add(top_level_module)
def __enter__(self):
# Patch `builtins.__import__` and `importlib.import_module`
self.original_import = builtins.__import__
self.original_import_module = importlib.import_module
builtins.__import__ = self._wrap_import(self.original_import)
importlib.import_module = self._wrap_import_module(self.original_import_module)
return self
def __exit__(self, *_, **__):
# Revert the patches
builtins.__import__ = self.original_import
importlib.import_module = self.original_import_module
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--model-path", required=True)
parser.add_argument("--flavor", required=True)
parser.add_argument("--output-file", required=True)
parser.add_argument("--sys-path", required=True)
parser.add_argument("--module-to-throw", required=False)
parser.add_argument("--error-file", required=False)
parser.add_argument("--record-full-module", default=False, action="store_true")
return parser.parse_args()
def store_imported_modules(
cap_cm, model_path, flavor, output_file, error_file=None, record_full_module=False
):
# If `model_path` refers to an MLflow model directory, load the model using
# `mlflow.pyfunc.load_model`
if os.path.isdir(model_path) and MLMODEL_FILE_NAME in os.listdir(model_path):
mlflow_model = Model.load(model_path)
pyfunc_conf = mlflow_model.flavors.get(mlflow.pyfunc.FLAVOR_NAME)
input_example = mlflow_model.load_input_example(model_path)
params = mlflow_model.load_input_example_params(model_path)
def load_model_and_predict(original_load_fn, *args, **kwargs):
model = original_load_fn(*args, **kwargs)
if input_example is not None:
try:
model.predict(input_example, params=params)
except Exception as e:
if error_file:
stack_trace = get_stacktrace(e)
write_to(
error_file,
"Failed to run predict on input_example, dependencies "
"introduced in predict are not captured.\n" + stack_trace,
)
else:
raise e
return model
if record_full_module:
# Note: if we want to record all imported modules
# (for inferring code_paths purpose),
# The `importlib.import_module(pyfunc_conf[MAIN])` invocation
# must be wrapped with `cap_cm` context manager,
# because `pyfunc_conf[MAIN]` might also be a module loaded from
# code_paths.
with cap_cm:
# `mlflow.pyfunc.load_model` internally invokes
# `importlib.import_module(pyfunc_conf[MAIN])`
mlflow.pyfunc.load_model(model_path)
else:
loader_module = importlib.import_module(pyfunc_conf[MAIN])
original = loader_module._load_pyfunc
@functools.wraps(original)
def _load_pyfunc_patch(*args, **kwargs):
with cap_cm:
return load_model_and_predict(original, *args, **kwargs)
loader_module._load_pyfunc = _load_pyfunc_patch
try:
mlflow.pyfunc.load_model(model_path)
finally:
loader_module._load_pyfunc = original
# Otherwise, load the model using `mlflow.<flavor>._load_pyfunc`.
# For models that don't contain pyfunc flavor (e.g. scikit-learn estimator
# that doesn't implement a `predict` method),
# we need to directly pass a model data path to this script.
else:
with cap_cm:
importlib.import_module(f"mlflow.{flavor}")._load_pyfunc(model_path)
# Store the imported modules in `output_file`
write_to(output_file, "\n".join(cap_cm.imported_modules))
def main():
args = parse_args()
model_path = args.model_path
flavor = args.flavor
output_file = args.output_file
error_file = args.error_file
# Mirror `sys.path` of the parent process
sys.path = json.loads(args.sys_path)
if flavor == mlflow.spark.FLAVOR_NAME:
# Create a local spark environment within the subprocess
from mlflow.utils._spark_utils import _create_local_spark_session_for_loading_spark_model
_prepare_subprocess_environ_for_creating_local_spark_session()
_create_local_spark_session_for_loading_spark_model()
cap_cm = _CaptureImportedModules(record_full_module=args.record_full_module)
store_imported_modules(
cap_cm,
model_path,
flavor,
output_file,
error_file,
record_full_module=args.record_full_module,
)
# Clean up a spark session created by `mlflow.spark._load_pyfunc`
if flavor == mlflow.spark.FLAVOR_NAME:
from mlflow.utils._spark_utils import _get_active_spark_session
if spark := _get_active_spark_session():
try:
spark.stop()
except Exception:
# Swallow unexpected exceptions
pass
if __name__ == "__main__":
main()