216 lines
7.8 KiB
Python
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)
|