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

470 lines
15 KiB
Python

"""
Utilities for uv package manager integration.
This module provides functions for detecting uv projects and exporting dependencies
via ``uv export`` for automatic dependency inference during model logging.
"""
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import NamedTuple
from packaging.version import Version
from mlflow.environment_variables import MLFLOW_LOG_UV_FILES
_logger = logging.getLogger(__name__)
# Minimum uv version required for ``uv export`` functionality
_MIN_UV_VERSION = Version("0.6.10")
# File names used for uv project detection and artifacts
_UV_LOCK_FILE = "uv.lock"
_PYPROJECT_FILE = "pyproject.toml"
_PYTHON_VERSION_FILE = ".python-version"
def get_uv_version() -> Version | None:
"""
Get the installed uv version.
Returns:
The uv version as a packaging.version.Version object, or None if uv is not installed
or version cannot be determined.
"""
uv_bin = shutil.which("uv")
if uv_bin is None:
return None
try:
result = subprocess.run(
[uv_bin, "--version"],
capture_output=True,
text=True,
check=True,
)
# Output format: "uv 0.5.0 (abc123 2024-01-01)"
version_str = result.stdout.strip().split()[1]
return Version(version_str)
except (subprocess.CalledProcessError, IndexError, ValueError) as e:
_logger.debug(f"Failed to determine uv version: {e}")
return None
def _get_uv_binary() -> str | None:
"""Return the uv binary path if installed and meeting version requirements, else None."""
uv_bin = shutil.which("uv")
if uv_bin is None:
return None
version = get_uv_version()
if version is None:
return None
if version < _MIN_UV_VERSION:
_logger.debug(f"uv version {version} is below minimum required version {_MIN_UV_VERSION}")
return None
return uv_bin
def is_uv_available() -> bool:
"""
Check if uv is installed and meets the minimum version requirement.
Returns:
True if uv is installed and version >= 0.6.10, False otherwise.
"""
return _get_uv_binary() is not None
class UVProjectInfo(NamedTuple):
"""Paths for a detected uv project."""
uv_lock: Path
pyproject: Path
def detect_uv_project(directory: str | Path | None = None) -> UVProjectInfo | None:
"""
Detect if the given directory is a uv project.
A uv project is identified by the presence of BOTH ``uv.lock`` and ``pyproject.toml``
in the specified directory.
Args:
directory: The directory to check. Defaults to the current working directory.
Returns:
A UVProjectInfo with paths to uv.lock and pyproject.toml,
or None if the directory is not a uv project.
"""
directory = Path.cwd() if directory is None else Path(directory)
uv_lock_path = directory / _UV_LOCK_FILE
pyproject_path = directory / _PYPROJECT_FILE
if uv_lock_path.exists() and pyproject_path.exists():
_logger.info(
f"Detected uv project: found {_UV_LOCK_FILE} and {_PYPROJECT_FILE} in {directory}"
)
return UVProjectInfo(uv_lock=uv_lock_path, pyproject=pyproject_path)
return None
def export_uv_requirements(
directory: str | Path | None = None,
no_dev: bool = True,
no_hashes: bool = True,
frozen: bool = True,
groups: list[str] | None = None,
extras: list[str] | None = None,
) -> list[str] | None:
"""
Export dependencies from a uv project to pip-compatible requirements.
Runs ``uv export`` to generate a list of pinned dependencies from the uv lock file.
Environment markers (e.g. ``; python_version < '3.12'``) are preserved in the output
so that pip can evaluate them at install time.
Args:
directory: The uv project directory. Defaults to the current working directory.
no_dev: Exclude development dependencies. Defaults to True.
no_hashes: Omit hashes from output. Defaults to True.
frozen: Use frozen lockfile without updating. Defaults to True.
groups: Optional list of dependency groups to include (additive with project deps).
Maps to ``uv export --group <name>``.
extras: Optional list of optional dependency extras to include.
Maps to ``uv export --extra <name>``.
Returns:
A list of requirement strings (e.g., ``["requests==2.28.0", "numpy==1.24.0"]``),
or None if export fails.
"""
uv_bin = _get_uv_binary()
if uv_bin is None:
_logger.warning(
"uv is not available or version is below minimum required. "
"Falling back to pip-based inference."
)
return None
directory = Path.cwd() if directory is None else Path(directory)
cmd = [uv_bin, "export"]
if no_dev:
cmd.append("--no-dev")
if no_hashes:
cmd.append("--no-hashes")
if frozen:
cmd.append("--frozen")
if groups:
for group in groups:
cmd.extend(["--group", group])
if extras:
for extra in extras:
cmd.extend(["--extra", extra])
# Additional flags for cleaner output
cmd.extend([
"--no-header", # Omit the "autogenerated by uv" comment
"--no-emit-project", # Don't emit the project itself
"--no-annotate", # Omit "# via ..." comment annotations
])
try:
_logger.debug(f"Running uv export: {' '.join(str(c) for c in cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
cwd=directory,
)
requirements = [
line.strip()
for line in result.stdout.strip().splitlines()
if line.strip() and not line.strip().startswith("#")
]
_logger.info(f"Exported {len(requirements)} dependencies via uv")
return requirements
except subprocess.CalledProcessError as e:
_logger.warning(
f"uv export failed with exit code {e.returncode}. "
f"stderr: {e.stderr}. Falling back to pip-based inference."
)
return None
except Exception as e:
_logger.warning(f"uv export failed: {e}. Falling back to pip-based inference.")
return None
def copy_uv_project_files(
dest_dir: str | Path,
source_dir: str | Path,
) -> bool:
"""
Copy uv project files to the model artifact directory.
Copies uv.lock, pyproject.toml, and .python-version (if present) to preserve
uv project configuration as model artifacts for reproducibility.
Can be disabled by setting MLFLOW_LOG_UV_FILES=false environment variable
for large projects where uv.lock size is a concern.
Args:
dest_dir: The destination directory (model artifact directory).
source_dir: The source directory containing uv project files.
Returns:
True if uv files were copied, False otherwise.
"""
if not MLFLOW_LOG_UV_FILES.get():
_logger.info("uv file logging disabled via MLFLOW_LOG_UV_FILES environment variable")
return False
dest_dir = Path(dest_dir)
source_dir = Path(source_dir)
uv_project = detect_uv_project(source_dir)
if uv_project is None:
return False
try:
shutil.copy2(uv_project.uv_lock, dest_dir / _UV_LOCK_FILE)
_logger.info(f"Copied {_UV_LOCK_FILE} to model artifacts")
shutil.copy2(uv_project.pyproject, dest_dir / _PYPROJECT_FILE)
_logger.info(f"Copied {_PYPROJECT_FILE} to model artifacts")
python_version_src = source_dir / _PYTHON_VERSION_FILE
if python_version_src.exists():
shutil.copy2(python_version_src, dest_dir / _PYTHON_VERSION_FILE)
_logger.info(f"Copied {_PYTHON_VERSION_FILE} to model artifacts")
return True
except Exception as e:
_logger.warning(f"Failed to copy uv project files: {e}")
return False
def extract_index_urls_from_uv_lock(uv_lock_path: str | Path) -> list[str]:
"""
Extract private index URLs from a uv.lock file.
Parses the uv.lock TOML file to find package sources that use non-PyPI registries.
These URLs are returned as `--extra-index-url` compatible strings.
Note: Credentials are NEVER stored in uv.lock. Users must provide credentials
at restore time via UV_INDEX_* environment variables or .netrc file.
Args:
uv_lock_path: Path to the uv.lock file.
Returns:
A list of unique index URLs (excluding PyPI). Empty list if none found
or if parsing fails.
"""
uv_lock_path = Path(uv_lock_path)
if not uv_lock_path.exists():
return []
try:
content = uv_lock_path.read_text()
# Extract registry URLs from uv.lock using regex
# Format in uv.lock: source = { registry = "https://..." }
registry_pattern = r'source\s*=\s*\{\s*registry\s*=\s*["\']([^"\']+)["\']'
urls = set(re.findall(registry_pattern, content))
# Filter out default PyPI URLs
pypi_urls = {
"https://pypi.org/simple",
"https://pypi.org/simple/",
"https://pypi.python.org/simple",
"https://pypi.python.org/simple/",
}
private_urls = [url for url in urls if url.lower() not in pypi_urls]
if private_urls:
_logger.info(f"Extracted {len(private_urls)} private index URL(s) from uv.lock")
_logger.warning(
"Private package indexes detected in uv lockfile. "
"Ensure credentials are available at model load time via "
"UV_INDEX_* environment variables or .netrc file."
)
return sorted(private_urls)
except Exception as e:
_logger.debug(f"Failed to extract index URLs from uv.lock: {e}")
return []
def create_uv_sync_pyproject(
dest_dir: str | Path,
python_version: str,
project_name: str = "mlflow-model-env",
) -> Path:
"""
Create a minimal pyproject.toml for uv sync.
This is required for `uv sync --frozen` to work when restoring a model
environment from a uv.lock artifact.
Args:
dest_dir: Directory where pyproject.toml will be created.
python_version: Python version requirement (e.g., "3.11" or "3.11.5").
project_name: Name for the temporary project. Defaults to "mlflow-model-env".
Returns:
Path to the created pyproject.toml file.
"""
dest_dir = Path(dest_dir)
# Pin the exact Python version including micro (e.g. "==3.11.5") so uv
# restores the environment with the same interpreter that was used at
# log time. "dependencies" is left empty because all deps come from
# uv.lock.
pyproject_content = f"""[project]
name = "{project_name}"
version = "0.0.0"
requires-python = "=={python_version}"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"""
pyproject_path = dest_dir / _PYPROJECT_FILE
pyproject_path.write_text(pyproject_content)
_logger.debug(f"Created minimal pyproject.toml for uv sync at {pyproject_path}")
return pyproject_path
def setup_uv_sync_environment(
env_dir: str | Path,
model_path: str | Path,
python_version: str,
) -> bool:
"""
Set up a uv project structure for environment restoration via ``uv sync --frozen``.
Copies uv.lock from model artifacts and creates a minimal pyproject.toml
to enable exact environment restoration using uv.
Args:
env_dir: The environment directory where the uv project will be set up.
model_path: Path to the model artifacts containing uv.lock.
python_version: Python version for the environment.
Returns:
True if setup succeeded (uv.lock exists in model artifacts), False otherwise.
"""
env_dir = Path(env_dir)
model_path = Path(model_path)
uv_lock_artifact = model_path / _UV_LOCK_FILE
if not uv_lock_artifact.exists():
_logger.debug(f"No uv.lock found in model artifacts at {model_path}")
return False
env_dir.mkdir(parents=True, exist_ok=True)
# Copy uv.lock to environment directory
shutil.copy2(uv_lock_artifact, env_dir / _UV_LOCK_FILE)
_logger.debug(f"Copied uv.lock to {env_dir}")
# Copy pyproject.toml from model if available, otherwise create minimal one
pyproject_artifact = model_path / _PYPROJECT_FILE
if pyproject_artifact.exists():
shutil.copy2(pyproject_artifact, env_dir / _PYPROJECT_FILE)
_logger.debug(f"Copied pyproject.toml from model artifacts to {env_dir}")
else:
create_uv_sync_pyproject(env_dir, python_version)
# Copy .python-version if it exists in model artifacts
python_version_artifact = model_path / _PYTHON_VERSION_FILE
if python_version_artifact.exists():
shutil.copy2(python_version_artifact, env_dir / _PYTHON_VERSION_FILE)
_logger.debug(f"Copied .python-version to {env_dir}")
_logger.info(f"Set up uv sync environment at {env_dir}")
return True
def run_uv_sync(
project_dir: str | Path,
frozen: bool = True,
no_dev: bool = True,
capture_output: bool = False,
) -> bool:
"""
Run `uv sync` to install dependencies from a uv.lock file.
This provides exact cross-platform environment restoration, significantly
faster than pip-based installation.
Args:
project_dir: Directory containing pyproject.toml and uv.lock.
frozen: Use frozen lockfile without updating. Defaults to True.
no_dev: Exclude development dependencies. Defaults to True.
capture_output: Whether to capture stdout/stderr. Defaults to False.
Returns:
True if sync succeeded, False otherwise.
"""
uv_bin = _get_uv_binary()
if uv_bin is None:
_logger.warning("uv is not available for environment sync")
return False
project_dir = Path(project_dir)
cmd = [uv_bin, "sync"]
if frozen:
cmd.append("--frozen")
if no_dev:
cmd.append("--no-dev")
try:
_logger.info(f"Running uv sync in {project_dir}")
_logger.debug(f"uv sync command: {' '.join(str(c) for c in cmd)}")
subprocess.run(
cmd,
cwd=project_dir,
capture_output=capture_output,
check=True,
text=True,
# Set UV_PROJECT_ENVIRONMENT to install packages into python environment at
# `project_dir` path instead of the default `{cwd}/.venv` path.
env={**os.environ, "UV_PROJECT_ENVIRONMENT": str(project_dir)},
)
_logger.info("uv sync completed successfully")
return True
except subprocess.CalledProcessError as e:
_logger.warning(f"uv sync failed with exit code {e.returncode}: {e.stderr}")
return False
except Exception as e:
_logger.warning(f"uv sync failed: {e}")
return False
def has_uv_lock_artifact(model_path: str | Path) -> bool:
"""Check if a model has a uv.lock artifact."""
return (Path(model_path) / _UV_LOCK_FILE).exists()