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

216 lines
7.8 KiB
Python

import shutil
import subprocess
import sys
import tarfile
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Generator, Literal
import yaml
from mlflow.artifacts import download_artifacts
from mlflow.exceptions import MlflowException
from mlflow.models.model import MLMODEL_FILE_NAME
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
from mlflow.utils.databricks_utils import DatabricksRuntimeVersion, get_databricks_runtime_version
from mlflow.utils.environment import _REQUIREMENTS_FILE_NAME
from mlflow.utils.logging_utils import eprint
EnvPackType = Literal["databricks_model_serving"]
@dataclass(kw_only=True)
class EnvPackConfig:
name: EnvPackType
install_dependencies: bool = True
_ARTIFACT_PATH = "_databricks"
_MODEL_VERSION_TAR = "model_version.tar"
_MODEL_ENVIRONMENT_TAR = "model_environment.tar"
def _validate_env_pack(env_pack):
"""Checks if env_pack is a supported value
Supported values are:
- the string "databricks_model_serving"
- an ``EnvPackConfig`` with ``name == 'databricks_model_serving'`` and a boolean
``install_dependencies`` field.
- None
"""
if env_pack is None:
return None
if isinstance(env_pack, str):
if env_pack == "databricks_model_serving":
return EnvPackConfig(name="databricks_model_serving", install_dependencies=True)
raise MlflowException.invalid_parameter_value(
f"Invalid env_pack value: {env_pack!r}. Expected: 'databricks_model_serving'."
)
if isinstance(env_pack, EnvPackConfig):
if env_pack.name != "databricks_model_serving":
raise MlflowException.invalid_parameter_value(
f"Invalid EnvPackConfig.name: {env_pack.name!r}. "
"Expected 'databricks_model_serving'."
)
if not isinstance(env_pack.install_dependencies, bool):
raise MlflowException.invalid_parameter_value(
"EnvPackConfig.install_dependencies must be a bool."
)
return env_pack
# Anything else is invalid
raise MlflowException.invalid_parameter_value(
"env_pack must be either None, the string 'databricks_model_serving', or an EnvPackConfig "
"with a boolean 'install_dependencies' field."
)
def _tar(root_path: Path, tar_path: Path) -> tarfile.TarFile:
"""
Package all files under root_path into a tar at tar_path, excluding __pycache__, *.pyc, and
wheels_info.json.
"""
def exclude(tarinfo: tarfile.TarInfo):
name = tarinfo.name
base = Path(name).name
if "__pycache__" in name or base.endswith(".pyc") or base == "wheels_info.json":
return None
return tarinfo
# Pull in symlinks
with tarfile.open(tar_path, "w", dereference=True) as tar:
tar.add(root_path, arcname=".", filter=exclude)
return tar
@contextmanager
def _get_source_artifacts(
model_uri: str, local_model_path: str | None = None
) -> Generator[Path, None, None]:
"""
Get source artifacts and handle cleanup of downloads.
Does not mutate local_model_path contents if provided.
Args:
model_uri: The URI of the model to package.
local_model_path: Optional local path to model artifacts.
Yields:
Path: The path to the source artifacts directory.
"""
source_dir = Path(local_model_path or download_artifacts(artifact_uri=model_uri))
yield source_dir
if not local_model_path:
shutil.rmtree(source_dir)
# TODO: Check pip requirements using uv instead.
@contextmanager
def pack_env_for_databricks_model_serving(
model_uri: str,
*,
enforce_pip_requirements: bool = False,
local_model_path: str | None = None,
) -> Generator[str, None, None]:
"""
Generate Databricks artifacts for fast deployment.
Args:
model_uri: The URI of the model to package.
enforce_pip_requirements: Whether to enforce pip requirements installation.
local_model_path: Optional local path to model artifacts. If provided, pack
the local artifacts instead of downloading.
Yields:
str: The path to the local artifacts directory containing the model artifacts and
environment.
Example:
>>> with pack_env_for_databricks_model_serving("models:/my-model/1") as artifacts_dir:
... # Use artifacts_dir here
... pass
"""
dbr_version = DatabricksRuntimeVersion.parse()
if not dbr_version.is_client_image:
raise ValueError(
f"Serverless environment is required when packing environment for Databricks Model "
f"Serving. Current version: {dbr_version}"
)
with _get_source_artifacts(model_uri, local_model_path) as source_artifacts_dir:
# Check runtime version consistency
# We read the MLmodel file directly instead of using Model.to_dict() because to_dict() adds
# the current runtime version via get_databricks_runtime_version(), which would prevent us
# from detecting runtime version mismatches.
mlmodel_path = source_artifacts_dir / MLMODEL_FILE_NAME
with open(mlmodel_path) as f:
model_dict = yaml.safe_load(f)
if "databricks_runtime" not in model_dict:
raise ValueError(
"Model must have been created in a Databricks runtime environment. "
"Missing 'databricks_runtime' field in MLmodel file."
)
current_runtime = DatabricksRuntimeVersion.parse()
model_runtime = DatabricksRuntimeVersion.parse(model_dict["databricks_runtime"])
if current_runtime.major != model_runtime.major:
raise ValueError(
f"Runtime version mismatch. Model was created with runtime "
f"{model_dict['databricks_runtime']} (major version {model_runtime.major}), "
f"but current runtime is {get_databricks_runtime_version()} "
f"(major version {current_runtime.major})"
)
# Check that _databricks directory does not exist in source
if (source_artifacts_dir / _ARTIFACT_PATH).exists():
raise MlflowException(
f"Source artifacts contain a '{_ARTIFACT_PATH}' directory and is not "
"eligible for use with env_pack.",
error_code=INVALID_PARAMETER_VALUE,
)
if enforce_pip_requirements:
eprint("Installing model requirements...")
try:
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"-r",
str(source_artifacts_dir / _REQUIREMENTS_FILE_NAME),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
except subprocess.CalledProcessError as e:
eprint("Error installing requirements:")
eprint(e.stdout)
raise
with tempfile.TemporaryDirectory() as temp_dir:
# Copy source artifacts to packaged_model_dir
packaged_model_dir = Path(temp_dir) / "model"
shutil.copytree(
source_artifacts_dir, packaged_model_dir, dirs_exist_ok=False, symlinks=False
)
# Package model artifacts and env into packaged_model_dir/_databricks
packaged_artifacts_dir = packaged_model_dir / _ARTIFACT_PATH
packaged_artifacts_dir.mkdir(exist_ok=False)
_tar(source_artifacts_dir, packaged_artifacts_dir / _MODEL_VERSION_TAR)
_tar(Path(sys.prefix), packaged_artifacts_dir / _MODEL_ENVIRONMENT_TAR)
yield str(packaged_model_dir)