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

463 lines
18 KiB
Python

import logging
import os
import re
import shutil
import tempfile
import uuid
from pathlib import Path
from typing import Literal
from packaging.version import Version
import mlflow
from mlflow.environment_variables import _MLFLOW_TESTING, MLFLOW_ENV_ROOT
from mlflow.exceptions import MlflowException
from mlflow.models.model import MLMODEL_FILE_NAME, Model
from mlflow.utils import env_manager as em
from mlflow.utils.conda import _PIP_CACHE_DIR
from mlflow.utils.databricks_utils import is_in_databricks_runtime
from mlflow.utils.environment import (
_CONDA_ENV_FILE_NAME,
_PYTHON_ENV_FILE_NAME,
_REQUIREMENTS_FILE_NAME,
_get_mlflow_env_name,
_PythonEnv,
)
from mlflow.utils.file_utils import remove_on_error
from mlflow.utils.os import is_windows
from mlflow.utils.process import _exec_cmd, _join_commands
from mlflow.utils.requirements_utils import _parse_requirements
from mlflow.utils.uv_utils import has_uv_lock_artifact, run_uv_sync, setup_uv_sync_environment
_logger = logging.getLogger(__name__)
def _get_mlflow_virtualenv_root():
"""
Returns the root directory to store virtualenv environments created by MLflow.
"""
return MLFLOW_ENV_ROOT.get()
_DATABRICKS_PYENV_BIN_PATH = "/databricks/.pyenv/bin/pyenv"
def _is_pyenv_available():
"""
Returns True if pyenv is available, otherwise False.
"""
return _get_pyenv_bin_path() is not None
def _validate_pyenv_is_available():
"""
Validates pyenv is available. If not, throws an `MlflowException` with a brief instruction on
how to install pyenv.
"""
url = (
"https://github.com/pyenv/pyenv#installation"
if not is_windows()
else "https://github.com/pyenv-win/pyenv-win#installation"
)
if not _is_pyenv_available():
raise MlflowException(
f"Could not find the pyenv binary. See {url} for installation instructions."
)
_SEMANTIC_VERSION_REGEX = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$")
def _get_pyenv_bin_path():
if os.path.exists(_DATABRICKS_PYENV_BIN_PATH):
return _DATABRICKS_PYENV_BIN_PATH
return shutil.which("pyenv")
def _find_latest_installable_python_version(version_prefix):
"""
Find the latest installable python version that matches the given version prefix
from the output of `pyenv install --list`. For example, `version_prefix("3.8")` returns '3.8.x'
where 'x' represents the latest micro version in 3.8.
"""
lines = _exec_cmd(
[_get_pyenv_bin_path(), "install", "--list"],
capture_output=True,
shell=is_windows(),
).stdout.splitlines()
semantic_versions = filter(_SEMANTIC_VERSION_REGEX.match, map(str.strip, lines))
matched = [v for v in semantic_versions if v.startswith(version_prefix)]
if not matched:
raise MlflowException(f"Could not find python version that matches {version_prefix}")
return max(matched, key=Version)
def _install_python(version, pyenv_root=None, capture_output=False):
"""Installs a specified version of python with pyenv and returns a path to the installed python
binary.
Args:
version: Python version to install.
pyenv_root: The value of the "PYENV_ROOT" environment variable used when running
`pyenv install` which installs python in `{PYENV_ROOT}/versions/{version}`.
capture_output: Set the `capture_output` argument when calling `_exec_cmd`.
Returns:
Path to the installed python binary.
"""
version = (
version
if _SEMANTIC_VERSION_REGEX.match(version)
else _find_latest_installable_python_version(version)
)
_logger.info("Installing python %s if it does not exist", version)
# pyenv-win doesn't support `--skip-existing` but its behavior is enabled by default
# https://github.com/pyenv-win/pyenv-win/pull/314
pyenv_install_options = ("--skip-existing",) if not is_windows() else ()
extra_env = {"PYENV_ROOT": pyenv_root} if pyenv_root else None
pyenv_bin_path = _get_pyenv_bin_path()
_exec_cmd(
[pyenv_bin_path, "install", *pyenv_install_options, version],
capture_output=capture_output,
# Windows fails to find pyenv and throws `FileNotFoundError` without `shell=True`
shell=is_windows(),
extra_env=extra_env,
)
if not is_windows():
if pyenv_root is None:
pyenv_root = _exec_cmd([pyenv_bin_path, "root"], capture_output=True).stdout.strip()
path_to_bin = ("bin", "python")
else:
# pyenv-win doesn't provide the `pyenv root` command
pyenv_root = os.environ.get("PYENV_ROOT")
if pyenv_root is None:
raise MlflowException("Environment variable 'PYENV_ROOT' must be set")
path_to_bin = ("python.exe",)
return Path(pyenv_root).joinpath("versions", version, *path_to_bin)
def _get_conda_env_file(model_config):
from mlflow.pyfunc import _extract_conda_env
for flavor, config in model_config.flavors.items():
if flavor == mlflow.pyfunc.FLAVOR_NAME:
if env := config.get(mlflow.pyfunc.ENV):
return _extract_conda_env(env)
return _CONDA_ENV_FILE_NAME
def _get_python_env_file(model_config):
from mlflow.pyfunc import EnvType
for flavor, config in model_config.flavors.items():
if flavor == mlflow.pyfunc.FLAVOR_NAME:
env = config.get(mlflow.pyfunc.ENV)
if isinstance(env, dict):
# Models saved in MLflow >= 2.0 use a dictionary for the pyfunc flavor
# `env` config, where the keys are different environment managers (e.g.
# conda, virtualenv) and the values are corresponding environment paths
return env[EnvType.VIRTUALENV]
return _PYTHON_ENV_FILE_NAME
def _get_python_env(local_model_path):
"""Constructs `_PythonEnv` from the model artifacts stored in `local_model_path`. If
`python_env.yaml` is available, use it, otherwise extract model dependencies from `conda.yaml`.
If `conda.yaml` contains conda dependencies except `python`, `pip`, `setuptools`, and, `wheel`,
an `MlflowException` is thrown because conda dependencies cannot be installed in a virtualenv
environment.
Args:
local_model_path: Local directory containing the model artifacts.
Returns:
`_PythonEnv` instance.
"""
model_config = Model.load(local_model_path / MLMODEL_FILE_NAME)
python_env_file = local_model_path / _get_python_env_file(model_config)
conda_env_file = local_model_path / _get_conda_env_file(model_config)
requirements_file = local_model_path / _REQUIREMENTS_FILE_NAME
if python_env_file.exists():
return _PythonEnv.from_yaml(python_env_file)
else:
_logger.info(
"This model is missing %s, which is because it was logged in an older version"
"of MLflow (< 1.26.0) that does not support restoring a model environment with "
"virtualenv. Attempting to extract model dependencies from %s and %s instead.",
_PYTHON_ENV_FILE_NAME,
_REQUIREMENTS_FILE_NAME,
_CONDA_ENV_FILE_NAME,
)
if requirements_file.exists():
deps = _PythonEnv.get_dependencies_from_conda_yaml(conda_env_file)
return _PythonEnv(
python=deps["python"],
build_dependencies=deps["build_dependencies"],
dependencies=[f"-r {_REQUIREMENTS_FILE_NAME}"],
)
else:
return _PythonEnv.from_conda_yaml(conda_env_file)
def _get_virtualenv_name(python_env, work_dir_path, env_id=None):
requirements = _parse_requirements(
python_env.dependencies,
is_constraint=False,
base_dir=work_dir_path,
)
return _get_mlflow_env_name(
str(python_env) + "".join(map(str, sorted(requirements))) + (env_id or "")
)
def _get_virtualenv_activate_cmd(env_dir: Path) -> str:
# Created a command to activate the environment
paths = ("bin", "activate") if not is_windows() else ("Scripts", "activate.bat")
activate_cmd = env_dir.joinpath(*paths)
return f"source {activate_cmd}" if not is_windows() else str(activate_cmd)
def _get_uv_env_creation_command(env_dir: str | Path, python_version: str) -> str:
return ["uv", "venv", str(env_dir), f"--python={python_version}"]
def _create_virtualenv(
local_model_path: Path,
python_env: _PythonEnv,
env_dir: Path,
python_install_dir: str | None = None,
env_manager: Literal["virtualenv", "uv"] = em.UV,
extra_env: dict[str, str] | None = None,
capture_output: bool = False,
pip_requirements_override: list[str] | None = None,
):
if env_manager not in {em.VIRTUALENV, em.UV}:
raise MlflowException.invalid_parameter_value(
f"Invalid value for `env_manager`: {env_manager}. "
f"Must be one of `{em.VIRTUALENV}, {em.UV}`"
)
activate_cmd = _get_virtualenv_activate_cmd(env_dir)
if env_dir.exists():
_logger.info(f"Environment {env_dir} already exists")
return activate_cmd
env_creation_extra_env = {}
if env_manager == em.VIRTUALENV:
python_bin_path = _install_python(
python_env.python, pyenv_root=python_install_dir, capture_output=capture_output
)
_logger.info(f"Creating a new environment in {env_dir} with {python_bin_path}")
env_creation_cmd = [python_bin_path, "-m", "venv", env_dir]
install_deps_cmd_prefix = "python -m pip install"
elif env_manager == em.UV:
_logger.info(
f"Creating a new environment in {env_dir} with python "
f"version {python_env.python} using uv"
)
env_creation_cmd = _get_uv_env_creation_command(env_dir, python_env.python)
install_deps_cmd_prefix = "uv pip install"
if python_install_dir:
# Setting `UV_PYTHON_INSTALL_DIR` to make `uv env` install python into
# the directory it points to.
env_creation_extra_env["UV_PYTHON_INSTALL_DIR"] = python_install_dir
if _MLFLOW_TESTING.get():
os.environ["RUST_LOG"] = "uv=debug"
with remove_on_error(
env_dir,
onerror=lambda e: _logger.warning(
"Encountered an unexpected error: %s while creating a virtualenv environment in %s, "
"removing the environment directory...",
repr(e),
env_dir,
),
):
_exec_cmd(
env_creation_cmd,
capture_output=capture_output,
extra_env=env_creation_extra_env,
)
# Use UV sync if model has uv.lock artifact and using UV env manager
if env_manager == em.UV and has_uv_lock_artifact(local_model_path):
_logger.info("Found uv.lock artifact, restoring environment with uv sync")
if not setup_uv_sync_environment(env_dir, local_model_path, python_env.python):
raise MlflowException(
"Failed to set up uv sync environment. Ensure the model's uv.lock "
"and pyproject.toml artifacts are valid."
)
if not run_uv_sync(env_dir, capture_output=capture_output):
raise MlflowException(
"Failed to restore model environment using uv sync. Ensure that uv is "
"installed and the model's uv.lock artifact is valid. To install "
"dependencies with pip instead, set the env_manager parameter to "
"'virtualenv' instead of 'uv'."
)
_logger.info("UV sync completed successfully")
else:
_logger.info("Installing dependencies")
for deps in filter(None, [python_env.build_dependencies, python_env.dependencies]):
with tempfile.TemporaryDirectory() as tmpdir:
# Create a temporary requirements file in the model directory to resolve the
# references in it correctly. To do this, we must first symlink or copy the
# model directory's contents to a temporary location for compatibility with
# deployment tools that store models in a read-only mount
try:
for model_item in os.listdir(local_model_path):
os.symlink(
src=os.path.join(local_model_path, model_item),
dst=os.path.join(tmpdir, model_item),
)
except Exception as e:
_logger.warning(
"Failed to symlink model directory during dependency installation"
" Copying instead. Exception: %s",
e,
)
_copy_model_to_writeable_destination(local_model_path, tmpdir)
tmp_req_file = f"requirements.{uuid.uuid4().hex}.txt"
Path(tmpdir).joinpath(tmp_req_file).write_text("\n".join(deps))
cmd = _join_commands(
activate_cmd, f"{install_deps_cmd_prefix} -r {tmp_req_file}"
)
_exec_cmd(cmd, capture_output=capture_output, cwd=tmpdir, extra_env=extra_env)
if pip_requirements_override:
_logger.info(
"Installing additional dependencies specified by "
f"pip_requirements_override: {pip_requirements_override}"
)
cmd = _join_commands(
activate_cmd,
f"{install_deps_cmd_prefix} --quiet {' '.join(pip_requirements_override)}",
)
_exec_cmd(cmd, capture_output=capture_output, extra_env=extra_env)
return activate_cmd
def _copy_model_to_writeable_destination(model_src, dst):
"""
Copies the specified `model_src` directory, which may be read-only, to the writeable `dst`
directory.
"""
os.makedirs(dst, exist_ok=True)
for model_item in os.listdir(model_src):
# Copy individual files and subdirectories, rather than using `shutil.copytree()`
# because `shutil.copytree()` will apply the permissions from the source directory,
# which may be read-only
copy_fn = shutil.copytree if os.path.isdir(model_item) else shutil.copy2
copy_fn(
src=os.path.join(model_src, model_item),
dst=os.path.join(dst, model_item),
)
def _get_virtualenv_extra_env_vars(env_root_dir=None):
extra_env = {
# PIP_NO_INPUT=1 makes pip run in non-interactive mode,
# otherwise pip might prompt "yes or no" and ask stdin input
"PIP_NO_INPUT": "1",
}
if env_root_dir is not None:
# Note: Both conda pip and virtualenv can use the pip cache directory.
extra_env["PIP_CACHE_DIR"] = os.path.join(env_root_dir, _PIP_CACHE_DIR)
return extra_env
_VIRTUALENV_ENVS_DIR = "virtualenv_envs"
_PYENV_ROOT_DIR = "pyenv_root"
def _get_or_create_virtualenv(
local_model_path,
env_id=None,
env_root_dir=None,
capture_output=False,
pip_requirements_override: list[str] | None = None,
env_manager: Literal["virtualenv", "uv"] = em.UV,
extra_envs: dict[str, str] | None = None,
):
"""Restores an MLflow model's environment in a virtual environment and returns a command
to activate it.
Args:
local_model_path: Local directory containing the model artifacts.
env_id: Optional string that is added to the contents of the yaml file before
calculating the hash. It can be used to distinguish environments that have the
same conda dependencies but are supposed to be different based on the context.
For example, when serving the model we may install additional dependencies to the
environment after the environment has been activated.
pip_requirements_override: If specified, install the specified python dependencies to
the environment (upgrade if already installed).
env_manager: Specifies the environment manager to use to create the environment.
Defaults to "uv".
extra_envs: If specified, a dictionary of extra environment variables will be passed to the
environment creation command.
.. tip::
It is highly recommended to use "uv" as it has significant performance improvements
over "virtualenv".
Returns:
Command to activate the created virtual environment
(e.g. "source /path/to/bin/activate").
"""
if env_manager == em.VIRTUALENV:
_validate_pyenv_is_available()
local_model_path = Path(local_model_path)
python_env = _get_python_env(local_model_path)
if env_root_dir is None:
virtual_envs_root_path = Path(_get_mlflow_virtualenv_root())
python_install_dir = None
else:
virtual_envs_root_path = Path(env_root_dir) / _VIRTUALENV_ENVS_DIR
pyenv_root_path = Path(env_root_dir) / _PYENV_ROOT_DIR
pyenv_root_path.mkdir(parents=True, exist_ok=True)
python_install_dir = str(pyenv_root_path)
virtual_envs_root_path.mkdir(parents=True, exist_ok=True)
env_name = _get_virtualenv_name(python_env, local_model_path, env_id)
env_dir = virtual_envs_root_path / env_name
try:
env_dir.exists()
except PermissionError:
if is_in_databricks_runtime():
# Updating env_name only doesn't work because the cluster may not have
# permission to access the original virtual_envs_root_path
virtual_envs_root_path = (
Path(env_root_dir) / f"{_VIRTUALENV_ENVS_DIR}_{uuid.uuid4().hex[:8]}"
)
virtual_envs_root_path.mkdir(parents=True, exist_ok=True)
env_dir = virtual_envs_root_path / env_name
else:
_logger.warning(
f"Existing virtual environment directory {env_dir} cannot be accessed "
"due to permission error. Check the permissions of the directory and "
"try again. If the issue persists, consider cleaning up the directory manually."
)
raise
extra_envs = extra_envs or {}
extra_envs |= _get_virtualenv_extra_env_vars(env_root_dir)
# Create an environment
return _create_virtualenv(
local_model_path=local_model_path,
python_env=python_env,
env_dir=env_dir,
python_install_dir=python_install_dir,
env_manager=env_manager,
extra_env=extra_envs,
capture_output=capture_output,
pip_requirements_override=pip_requirements_override,
)