""" 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 ``. extras: Optional list of optional dependency extras to include. Maps to ``uv export --extra ``. 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()