868 lines
28 KiB
Python
868 lines
28 KiB
Python
import subprocess
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from packaging.version import Version
|
|
|
|
from mlflow.environment_variables import MLFLOW_UV_AUTO_DETECT
|
|
from mlflow.utils.environment import infer_pip_requirements
|
|
from mlflow.utils.uv_utils import (
|
|
_PYPROJECT_FILE,
|
|
_UV_LOCK_FILE,
|
|
copy_uv_project_files,
|
|
create_uv_sync_pyproject,
|
|
detect_uv_project,
|
|
export_uv_requirements,
|
|
extract_index_urls_from_uv_lock,
|
|
get_uv_version,
|
|
has_uv_lock_artifact,
|
|
is_uv_available,
|
|
run_uv_sync,
|
|
setup_uv_sync_environment,
|
|
)
|
|
|
|
# --- get_uv_version tests ---
|
|
|
|
|
|
def test_get_uv_version_returns_none_when_uv_not_installed():
|
|
with mock.patch("mlflow.utils.uv_utils.shutil.which", return_value=None):
|
|
assert get_uv_version() is None
|
|
|
|
|
|
def test_get_uv_version_returns_version_when_uv_installed():
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "uv 0.6.10 (abc123 2024-01-01)"
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils.shutil.which", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
version = get_uv_version()
|
|
assert version == Version("0.6.10")
|
|
mock_run.assert_called_once()
|
|
|
|
|
|
def test_get_uv_version_returns_none_on_subprocess_error():
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils.shutil.which", return_value="/usr/bin/uv"),
|
|
mock.patch(
|
|
"mlflow.utils.uv_utils.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, "uv"),
|
|
),
|
|
):
|
|
assert get_uv_version() is None
|
|
|
|
|
|
def test_get_uv_version_returns_none_on_parse_error():
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "invalid output"
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils.shutil.which", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
assert get_uv_version() is None
|
|
|
|
|
|
# --- is_uv_available tests ---
|
|
|
|
|
|
def test_is_uv_available_returns_false_when_uv_not_installed():
|
|
with mock.patch("mlflow.utils.uv_utils.shutil.which", return_value=None):
|
|
assert is_uv_available() is False
|
|
|
|
|
|
def test_is_uv_available_returns_false_when_version_below_minimum():
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "uv 0.4.0 (abc123 2024-01-01)"
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils.shutil.which", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
assert is_uv_available() is False
|
|
|
|
|
|
@pytest.mark.parametrize("version_str", ["0.6.10", "1.0.0"])
|
|
def test_is_uv_available_returns_true_when_version_meets_or_exceeds_minimum(version_str):
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = f"uv {version_str} (abc123 2024-01-01)"
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils.shutil.which", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
assert is_uv_available() is True
|
|
|
|
|
|
# --- detect_uv_project tests ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("create_uv_lock", "create_pyproject"),
|
|
[(False, True), (True, False)],
|
|
ids=["missing_uv_lock", "missing_pyproject"],
|
|
)
|
|
def test_detect_uv_project_returns_none_when_file_missing(
|
|
tmp_path, create_uv_lock, create_pyproject
|
|
):
|
|
if create_uv_lock:
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
if create_pyproject:
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
assert detect_uv_project(tmp_path) is None
|
|
|
|
|
|
def test_detect_uv_project_returns_paths_when_both_files_exist(tmp_path):
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
result = detect_uv_project(tmp_path)
|
|
assert result is not None
|
|
assert result.uv_lock == tmp_path / _UV_LOCK_FILE
|
|
assert result.pyproject == tmp_path / _PYPROJECT_FILE
|
|
|
|
|
|
def test_detect_uv_project_uses_cwd_when_directory_not_specified(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
result = detect_uv_project()
|
|
assert result is not None
|
|
assert result.uv_lock == tmp_path / _UV_LOCK_FILE
|
|
|
|
|
|
# --- export_uv_requirements tests ---
|
|
|
|
|
|
def test_export_uv_requirements_returns_none_when_uv_not_available():
|
|
with mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value=None):
|
|
assert export_uv_requirements() is None
|
|
|
|
|
|
def test_export_uv_requirements_returns_requirements_list(tmp_path):
|
|
uv_output = """requests==2.28.0
|
|
numpy==1.24.0
|
|
pandas==2.0.0
|
|
"""
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
result = export_uv_requirements(tmp_path)
|
|
|
|
assert result == ["requests==2.28.0", "numpy==1.24.0", "pandas==2.0.0"]
|
|
mock_run.assert_called_once()
|
|
|
|
|
|
def test_export_uv_requirements_preserves_environment_markers(tmp_path):
|
|
uv_output = """requests==2.28.0
|
|
pywin32==306 ; sys_platform == 'win32'
|
|
numpy==1.24.0
|
|
"""
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = export_uv_requirements(tmp_path)
|
|
|
|
assert result is not None
|
|
assert len(result) == 3
|
|
assert "pywin32==306 ; sys_platform == 'win32'" in result
|
|
|
|
|
|
def test_export_uv_requirements_keeps_all_marker_variants(tmp_path):
|
|
uv_output = """numpy==2.2.6 ; python_version < '3.11'
|
|
numpy==2.4.1 ; python_version >= '3.11'
|
|
"""
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = export_uv_requirements(tmp_path)
|
|
|
|
assert result is not None
|
|
numpy_entries = [r for r in result if r.startswith("numpy")]
|
|
assert len(numpy_entries) == 2
|
|
|
|
|
|
def test_export_uv_requirements_returns_none_on_subprocess_error(tmp_path):
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch(
|
|
"mlflow.utils.uv_utils.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, "uv"),
|
|
),
|
|
):
|
|
assert export_uv_requirements(tmp_path) is None
|
|
|
|
|
|
def test_export_uv_requirements_with_explicit_directory(tmp_path):
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
|
|
uv_output = """requests==2.28.0
|
|
numpy==1.24.0
|
|
"""
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
result = export_uv_requirements(directory=tmp_path)
|
|
|
|
assert result is not None
|
|
assert "requests==2.28.0" in result
|
|
assert "numpy==1.24.0" in result
|
|
mock_run.assert_called_once()
|
|
assert mock_run.call_args.kwargs["cwd"] == tmp_path
|
|
|
|
|
|
# --- export_uv_requirements groups/extras tests ---
|
|
|
|
|
|
def test_export_uv_requirements_passes_groups_to_command(tmp_path):
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "requests==2.28.0\n"
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
export_uv_requirements(tmp_path, groups=["serving", "ml"])
|
|
|
|
call_args = mock_run.call_args[0][0]
|
|
assert "--group" in call_args
|
|
serving_idx = call_args.index("--group")
|
|
assert call_args[serving_idx + 1] == "serving"
|
|
second_group_idx = call_args.index("--group", serving_idx + 1)
|
|
assert call_args[second_group_idx + 1] == "ml"
|
|
|
|
|
|
def test_export_uv_requirements_passes_extras_to_command(tmp_path):
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "uvicorn==0.29.0\n"
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
export_uv_requirements(tmp_path, extras=["api", "gpu"])
|
|
|
|
call_args = mock_run.call_args[0][0]
|
|
assert "--extra" in call_args
|
|
api_idx = call_args.index("--extra")
|
|
assert call_args[api_idx + 1] == "api"
|
|
second_extra_idx = call_args.index("--extra", api_idx + 1)
|
|
assert call_args[second_extra_idx + 1] == "gpu"
|
|
|
|
|
|
# --- copy_uv_project_files tests ---
|
|
|
|
|
|
def test_copy_uv_project_files_returns_false_when_not_uv_project(tmp_path):
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
assert result is False
|
|
|
|
|
|
def test_copy_uv_project_files_copies_files_when_uv_project(tmp_path):
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
(source_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
(source_dir / _PYPROJECT_FILE).write_text("pyproject content")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
|
|
assert result is True
|
|
assert (dest_dir / _UV_LOCK_FILE).read_text() == "lock content"
|
|
assert (dest_dir / _PYPROJECT_FILE).read_text() == "pyproject content"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("has_python_version", "expected_exists"),
|
|
[(True, True), (False, False)],
|
|
ids=["with_python_version", "without_python_version"],
|
|
)
|
|
def test_copy_uv_project_files_python_version_handling(
|
|
tmp_path, has_python_version, expected_exists
|
|
):
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
(source_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
(source_dir / _PYPROJECT_FILE).write_text("pyproject content")
|
|
if has_python_version:
|
|
(source_dir / ".python-version").write_text("3.11.5")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
|
|
assert result is True
|
|
assert (dest_dir / ".python-version").exists() == expected_exists
|
|
if expected_exists:
|
|
assert (dest_dir / ".python-version").read_text() == "3.11.5"
|
|
|
|
|
|
def test_copy_uv_project_files_respects_mlflow_log_uv_files_env_false(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("MLFLOW_LOG_UV_FILES", "false")
|
|
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
(source_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
(source_dir / _PYPROJECT_FILE).write_text("pyproject content")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
|
|
assert result is False
|
|
assert not (dest_dir / _UV_LOCK_FILE).exists()
|
|
assert not (dest_dir / _PYPROJECT_FILE).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("env_value", ["0", "false", "FALSE", "False"])
|
|
def test_copy_uv_project_files_env_var_false_variants(tmp_path, monkeypatch, env_value):
|
|
monkeypatch.setenv("MLFLOW_LOG_UV_FILES", env_value)
|
|
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
(source_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
(source_dir / _PYPROJECT_FILE).write_text("pyproject content")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.parametrize("env_value", ["true", "1", "TRUE", "True"])
|
|
def test_copy_uv_project_files_env_var_true_variants(tmp_path, monkeypatch, env_value):
|
|
monkeypatch.setenv("MLFLOW_LOG_UV_FILES", env_value)
|
|
|
|
source_dir = tmp_path / "source"
|
|
source_dir.mkdir()
|
|
(source_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
(source_dir / _PYPROJECT_FILE).write_text("pyproject content")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir)
|
|
assert result is True
|
|
|
|
|
|
def test_copy_uv_project_files_with_monorepo_layout(tmp_path):
|
|
project_dir = tmp_path / "monorepo" / "subproject"
|
|
project_dir.mkdir(parents=True)
|
|
(project_dir / _UV_LOCK_FILE).write_text("lock content from monorepo")
|
|
(project_dir / _PYPROJECT_FILE).write_text("pyproject from monorepo")
|
|
(project_dir / ".python-version").write_text("3.12.0")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir=project_dir)
|
|
|
|
assert result is True
|
|
assert (dest_dir / _UV_LOCK_FILE).read_text() == "lock content from monorepo"
|
|
assert (dest_dir / ".python-version").read_text() == "3.12.0"
|
|
|
|
|
|
def test_copy_uv_project_files_with_nonexistent_source(tmp_path):
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
nonexistent_dir = tmp_path / "nonexistent"
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir=nonexistent_dir)
|
|
assert result is False
|
|
|
|
|
|
def test_copy_uv_project_files_with_missing_pyproject(tmp_path):
|
|
project_dir = tmp_path / "incomplete_project"
|
|
project_dir.mkdir()
|
|
(project_dir / _UV_LOCK_FILE).write_text("lock content")
|
|
|
|
dest_dir = tmp_path / "dest"
|
|
dest_dir.mkdir()
|
|
|
|
result = copy_uv_project_files(dest_dir, source_dir=project_dir)
|
|
assert result is False
|
|
|
|
|
|
# --- Integration tests for infer_pip_requirements uv path ---
|
|
|
|
|
|
def test_infer_pip_requirements_uses_uv_when_project_detected(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
uv_output = "requests==2.28.0\nnumpy==1.24.0\n"
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = infer_pip_requirements("runs:/fake/model", "sklearn")
|
|
|
|
assert "requests==2.28.0" in result
|
|
assert "numpy==1.24.0" in result
|
|
|
|
|
|
def test_export_uv_requirements_returns_none_when_uv_binary_missing(tmp_path):
|
|
with mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value=None):
|
|
result = export_uv_requirements(tmp_path)
|
|
assert result is None
|
|
|
|
|
|
def test_infer_pip_requirements_passes_groups_and_extras_to_uv_export(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
(tmp_path / _UV_LOCK_FILE).touch()
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
uv_output = "fastapi==0.100.0\nuvicorn==0.29.0\n"
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result) as mock_run,
|
|
):
|
|
result = infer_pip_requirements(
|
|
str(tmp_path),
|
|
"sklearn",
|
|
uv_groups=["serving"],
|
|
uv_extras=["api"],
|
|
)
|
|
|
|
assert "fastapi==0.100.0" in result
|
|
assert "uvicorn==0.29.0" in result
|
|
|
|
call_args = mock_run.call_args[0][0]
|
|
assert "--group" in call_args
|
|
group_idx = call_args.index("--group")
|
|
assert call_args[group_idx + 1] == "serving"
|
|
assert "--extra" in call_args
|
|
extra_idx = call_args.index("--extra")
|
|
assert call_args[extra_idx + 1] == "api"
|
|
|
|
|
|
def test_detect_uv_project_not_detected_when_files_missing(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
assert detect_uv_project() is None
|
|
|
|
|
|
# --- MLFLOW_UV_AUTO_DETECT Environment Variable Tests ---
|
|
|
|
|
|
def test_mlflow_uv_auto_detect_returns_true_by_default(monkeypatch):
|
|
monkeypatch.delenv("MLFLOW_UV_AUTO_DETECT", raising=False)
|
|
assert MLFLOW_UV_AUTO_DETECT.get() is True
|
|
|
|
|
|
@pytest.mark.parametrize("env_value", ["false", "0", "FALSE", "False"])
|
|
def test_mlflow_uv_auto_detect_returns_false_when_disabled(monkeypatch, env_value):
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", env_value)
|
|
assert MLFLOW_UV_AUTO_DETECT.get() is False
|
|
|
|
|
|
@pytest.mark.parametrize("env_value", ["true", "1", "TRUE", "True"])
|
|
def test_mlflow_uv_auto_detect_returns_true_when_enabled(monkeypatch, env_value):
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", env_value)
|
|
assert MLFLOW_UV_AUTO_DETECT.get() is True
|
|
|
|
|
|
def test_infer_pip_requirements_skips_uv_when_auto_detect_disabled(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "uv.lock").touch()
|
|
(tmp_path / "pyproject.toml").touch()
|
|
|
|
assert detect_uv_project() is not None
|
|
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "false")
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.environment.detect_uv_project") as mock_detect,
|
|
mock.patch("mlflow.utils.environment.export_uv_requirements") as mock_export,
|
|
mock.patch(
|
|
"mlflow.utils.environment._infer_requirements",
|
|
return_value=["scikit-learn==1.0"],
|
|
),
|
|
):
|
|
result = infer_pip_requirements(str(tmp_path), "sklearn")
|
|
|
|
mock_detect.assert_not_called()
|
|
mock_export.assert_not_called()
|
|
assert "scikit-learn==1.0" in result
|
|
|
|
|
|
def test_infer_pip_requirements_uses_explicit_uv_project_dir(tmp_path, monkeypatch):
|
|
work_dir = tmp_path / "work"
|
|
work_dir.mkdir()
|
|
monkeypatch.chdir(work_dir)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
|
|
uv_project = tmp_path / "my_project"
|
|
uv_project.mkdir()
|
|
(uv_project / _UV_LOCK_FILE).touch()
|
|
(uv_project / _PYPROJECT_FILE).touch()
|
|
|
|
uv_output = "requests==2.28.0\n"
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = infer_pip_requirements(str(tmp_path), "sklearn", uv_project_dir=uv_project)
|
|
|
|
assert "requests==2.28.0" in result
|
|
|
|
|
|
def test_infer_pip_requirements_explicit_uv_project_dir_overrides_disabled_auto_detect(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "false")
|
|
|
|
uv_project = tmp_path / "my_project"
|
|
uv_project.mkdir()
|
|
(uv_project / _UV_LOCK_FILE).touch()
|
|
(uv_project / _PYPROJECT_FILE).touch()
|
|
|
|
uv_output = "numpy==1.24.0\n"
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = infer_pip_requirements(str(tmp_path), "sklearn", uv_project_dir=uv_project)
|
|
|
|
assert "numpy==1.24.0" in result
|
|
|
|
|
|
def test_infer_pip_requirements_prepends_extra_index_urls_for_private_indexes(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
|
|
uv_lock_content = """version = 1
|
|
requires-python = ">=3.10"
|
|
|
|
[[package]]
|
|
name = "my-internal-utils"
|
|
version = "0.2.7"
|
|
source = { registry = "https://pypi.internal.example.com/simple" }
|
|
|
|
[[package]]
|
|
name = "scikit-learn"
|
|
version = "1.3.0"
|
|
source = { registry = "https://pypi.org/simple" }
|
|
"""
|
|
(tmp_path / _UV_LOCK_FILE).write_text(uv_lock_content)
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "my-internal-utils==0.2.7\nscikit-learn==1.3.0\n"
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = infer_pip_requirements("runs:/fake/model", "sklearn")
|
|
|
|
assert "--extra-index-url https://pypi.internal.example.com/simple" in result
|
|
assert "my-internal-utils==0.2.7" in result
|
|
assert "scikit-learn==1.3.0" in result
|
|
|
|
|
|
def test_infer_pip_requirements_no_extra_index_urls_when_no_private_indexes(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
|
|
uv_lock_content = """version = 1
|
|
requires-python = ">=3.10"
|
|
|
|
[[package]]
|
|
name = "scikit-learn"
|
|
version = "1.3.0"
|
|
source = { registry = "https://pypi.org/simple" }
|
|
"""
|
|
(tmp_path / _UV_LOCK_FILE).write_text(uv_lock_content)
|
|
(tmp_path / _PYPROJECT_FILE).touch()
|
|
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = "scikit-learn==1.3.0\n"
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = infer_pip_requirements("runs:/fake/model", "sklearn")
|
|
|
|
assert not any(req.startswith("--extra-index-url") for req in result)
|
|
assert "scikit-learn==1.3.0" in result
|
|
|
|
|
|
def test_export_uv_requirements_strips_comment_lines(tmp_path):
|
|
uv_output = """requests==2.28.0
|
|
# via
|
|
# some-package
|
|
urllib3==1.26.0
|
|
# via requests
|
|
certifi==2023.7.22
|
|
"""
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = uv_output
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = export_uv_requirements(tmp_path)
|
|
|
|
assert result == ["requests==2.28.0", "urllib3==1.26.0", "certifi==2023.7.22"]
|
|
|
|
|
|
def test_export_uv_requirements_returns_empty_list_on_empty_output(tmp_path):
|
|
mock_result = mock.Mock()
|
|
mock_result.stdout = ""
|
|
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run", return_value=mock_result),
|
|
):
|
|
result = export_uv_requirements(tmp_path)
|
|
|
|
assert result == []
|
|
|
|
|
|
# --- Private Index URL Extraction Tests ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("uv_lock_content", "expected_urls"),
|
|
[
|
|
(
|
|
"""
|
|
version = 1
|
|
requires-python = ">=3.11"
|
|
|
|
[[package]]
|
|
name = "my-private-pkg"
|
|
version = "1.0.0"
|
|
source = { registry = "https://internal.company.com/simple" }
|
|
|
|
[[package]]
|
|
name = "numpy"
|
|
version = "1.24.0"
|
|
source = { registry = "https://pypi.org/simple" }
|
|
""",
|
|
["https://internal.company.com/simple"],
|
|
),
|
|
(
|
|
"""
|
|
version = 1
|
|
|
|
[[package]]
|
|
name = "pkg1"
|
|
source = { registry = "https://private1.com/simple" }
|
|
|
|
[[package]]
|
|
name = "pkg2"
|
|
source = { registry = "https://private2.com/simple" }
|
|
|
|
[[package]]
|
|
name = "pkg3"
|
|
source = { registry = "https://private1.com/simple" }
|
|
""",
|
|
["https://private1.com/simple", "https://private2.com/simple"],
|
|
),
|
|
(
|
|
"""
|
|
version = 1
|
|
|
|
[[package]]
|
|
name = "numpy"
|
|
source = { registry = "https://pypi.org/simple" }
|
|
""",
|
|
[],
|
|
),
|
|
],
|
|
ids=["single_private", "multiple_private_deduped", "no_private"],
|
|
)
|
|
def test_extract_index_urls_from_uv_lock(tmp_path, uv_lock_content, expected_urls):
|
|
uv_lock_path = tmp_path / "uv.lock"
|
|
uv_lock_path.write_text(uv_lock_content)
|
|
|
|
result = extract_index_urls_from_uv_lock(uv_lock_path)
|
|
assert result == expected_urls
|
|
|
|
|
|
def test_extract_index_urls_from_uv_lock_file_not_exists(tmp_path):
|
|
result = extract_index_urls_from_uv_lock(tmp_path / "nonexistent.lock")
|
|
assert result == []
|
|
|
|
|
|
# --- uv Sync Environment Setup Tests ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("python_version", "project_name", "expected_name", "expected_python"),
|
|
[
|
|
("3.11.5", "mlflow-model-env", "mlflow-model-env", "==3.11.5"),
|
|
("3.10.14", "my-custom-env", "my-custom-env", "==3.10.14"),
|
|
],
|
|
ids=["default_name", "custom_name"],
|
|
)
|
|
def test_create_uv_sync_pyproject(
|
|
tmp_path, python_version, project_name, expected_name, expected_python
|
|
):
|
|
result_path = create_uv_sync_pyproject(tmp_path, python_version, project_name=project_name)
|
|
|
|
assert result_path.exists()
|
|
content = result_path.read_text()
|
|
assert f'name = "{expected_name}"' in content
|
|
assert f'requires-python = "{expected_python}"' in content
|
|
|
|
|
|
def test_setup_uv_sync_environment(tmp_path):
|
|
model_path = tmp_path / "model"
|
|
model_path.mkdir()
|
|
(model_path / "uv.lock").write_text('version = 1\nrequires-python = ">=3.11"')
|
|
(model_path / ".python-version").write_text("3.11.5")
|
|
|
|
env_dir = tmp_path / "env"
|
|
|
|
result = setup_uv_sync_environment(env_dir, model_path, "3.11.5")
|
|
|
|
assert result is True
|
|
assert (env_dir / "uv.lock").exists()
|
|
assert (env_dir / "pyproject.toml").exists()
|
|
assert (env_dir / ".python-version").exists()
|
|
|
|
|
|
def test_setup_uv_sync_environment_copies_existing_pyproject(tmp_path):
|
|
model_path = tmp_path / "model"
|
|
model_path.mkdir()
|
|
original_pyproject = '[project]\nname = "my-model"\nversion = "1.0.0"\n'
|
|
(model_path / "uv.lock").write_text('version = 1\nrequires-python = ">=3.11"')
|
|
(model_path / "pyproject.toml").write_text(original_pyproject)
|
|
|
|
env_dir = tmp_path / "env"
|
|
|
|
result = setup_uv_sync_environment(env_dir, model_path, "3.11.5")
|
|
|
|
assert result is True
|
|
# Copied from model, not generated (should have "my-model" not "mlflow-model-env")
|
|
pyproject_content = (env_dir / "pyproject.toml").read_text()
|
|
assert 'name = "my-model"' in pyproject_content
|
|
assert "mlflow-model-env" not in pyproject_content
|
|
|
|
|
|
def test_setup_uv_sync_environment_no_uv_lock(tmp_path):
|
|
model_path = tmp_path / "model"
|
|
model_path.mkdir()
|
|
|
|
env_dir = tmp_path / "env"
|
|
|
|
result = setup_uv_sync_environment(env_dir, model_path, "3.11")
|
|
|
|
assert result is False
|
|
assert not env_dir.exists()
|
|
|
|
|
|
def test_has_uv_lock_artifact(tmp_path):
|
|
model_path = tmp_path / "model"
|
|
model_path.mkdir()
|
|
|
|
assert has_uv_lock_artifact(model_path) is False
|
|
|
|
(model_path / "uv.lock").write_text("version = 1")
|
|
assert has_uv_lock_artifact(model_path) is True
|
|
|
|
|
|
def test_run_uv_sync_returns_false_when_uv_not_available(tmp_path):
|
|
with mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value=None):
|
|
result = run_uv_sync(tmp_path)
|
|
assert result is False
|
|
|
|
|
|
def test_run_uv_sync_builds_correct_command(tmp_path):
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch("mlflow.utils.uv_utils.subprocess.run") as mock_run,
|
|
):
|
|
run_uv_sync(tmp_path, frozen=True, no_dev=True)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args[0][0]
|
|
assert call_args[0] == "/usr/bin/uv"
|
|
assert call_args[1] == "sync"
|
|
assert "--frozen" in call_args
|
|
assert "--no-dev" in call_args
|
|
|
|
|
|
def test_run_uv_sync_returns_false_on_failure(tmp_path):
|
|
with (
|
|
mock.patch("mlflow.utils.uv_utils._get_uv_binary", return_value="/usr/bin/uv"),
|
|
mock.patch(
|
|
"mlflow.utils.uv_utils.subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(1, "uv sync"),
|
|
),
|
|
):
|
|
result = run_uv_sync(tmp_path)
|
|
assert result is False
|
|
|
|
|
|
def test_infer_pip_requirements_warns_when_groups_set_but_no_uv_project(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "true")
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.utils.environment._infer_requirements",
|
|
return_value=["scikit-learn==1.0"],
|
|
),
|
|
mock.patch("mlflow.utils.environment._logger") as mock_logger,
|
|
):
|
|
result = infer_pip_requirements(
|
|
str(tmp_path),
|
|
"sklearn",
|
|
uv_groups=["serving"],
|
|
uv_extras=["api"],
|
|
)
|
|
|
|
assert "scikit-learn==1.0" in result
|
|
mock_logger.warning.assert_any_call(
|
|
"uv_groups and/or uv_extras were specified but no uv project was detected. "
|
|
"These parameters will be ignored. Falling back to package capture based inference."
|
|
)
|