270 lines
9.9 KiB
Python
270 lines
9.9 KiB
Python
import subprocess
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import mlflow
|
|
from mlflow.genai import disable_git_model_versioning, enable_git_model_versioning
|
|
from mlflow.genai.git_versioning import _get_active_git_context
|
|
from mlflow.utils.mlflow_tags import MLFLOW_GIT_DIFF
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_active_context():
|
|
yield
|
|
disable_git_model_versioning()
|
|
|
|
|
|
TEST_FILENAME = "test.txt"
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_git_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
path = tmp_path / "test_repo"
|
|
path.mkdir()
|
|
subprocess.check_call(["git", "init"], cwd=path)
|
|
subprocess.check_call(["git", "config", "user.name", "test"], cwd=path)
|
|
subprocess.check_call(["git", "config", "user.email", "test@example.com"], cwd=path)
|
|
(path / TEST_FILENAME).touch()
|
|
subprocess.check_call(["git", "add", "."], cwd=path)
|
|
subprocess.check_call(["git", "commit", "-m", "init"], cwd=path)
|
|
monkeypatch.chdir(path)
|
|
return path
|
|
|
|
|
|
def test_enable_git_model_versioning(monkeypatch: pytest.MonkeyPatch, tmp_git_repo: Path):
|
|
context = enable_git_model_versioning()
|
|
assert context.info.commit is not None
|
|
assert context.info.branch is not None
|
|
assert context.info.dirty is False
|
|
assert context.info.diff is None # Clean repo has no diff
|
|
|
|
# Create a dummy file to make the repo dirty
|
|
Path(tmp_git_repo / "dummy.txt").touch()
|
|
context = enable_git_model_versioning()
|
|
# Untracked files should not be considered dirty
|
|
assert context.info.dirty is False
|
|
assert context.info.diff is None # No diff for untracked files
|
|
|
|
# Checkout a new branch
|
|
subprocess.check_call(["git", "checkout", "-b", "new-branch"], cwd=tmp_git_repo)
|
|
context = enable_git_model_versioning()
|
|
assert context.info.branch == "new-branch"
|
|
|
|
|
|
def test_disable_git_model_versioning_in_non_git_repo(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
with mock.patch("mlflow.genai.git_versioning._logger.warning") as mock_warning:
|
|
context = enable_git_model_versioning()
|
|
|
|
mock_warning.assert_called_once()
|
|
warning_message = mock_warning.call_args[0][0]
|
|
assert "Encountered an error while retrieving git information" in warning_message
|
|
assert "Git model versioning is disabled" in warning_message
|
|
assert context.info is None
|
|
assert context.active_model is None
|
|
|
|
|
|
def test_enable_git_model_versioning_context_manager(tmp_git_repo: Path):
|
|
assert _get_active_git_context() is None
|
|
|
|
with enable_git_model_versioning() as context:
|
|
assert _get_active_git_context() is context
|
|
|
|
assert _get_active_git_context() is None
|
|
|
|
|
|
def test_disable_git_model_versioning_resets_context(tmp_git_repo: Path):
|
|
with enable_git_model_versioning() as context:
|
|
assert _get_active_git_context() is context
|
|
disable_git_model_versioning()
|
|
assert _get_active_git_context() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_sets_active_context(tmp_git_repo: Path):
|
|
assert _get_active_git_context() is None
|
|
|
|
context = enable_git_model_versioning()
|
|
assert _get_active_git_context() is context
|
|
|
|
disable_git_model_versioning()
|
|
assert _get_active_git_context() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_creates_initial_logged_model(tmp_git_repo: Path):
|
|
with enable_git_model_versioning() as context:
|
|
assert mlflow.get_active_model_id() == context.active_model.model_id
|
|
models = mlflow.search_logged_models(output_format="list")
|
|
assert len(models) == 1
|
|
assert models[0].model_id == context.active_model.model_id
|
|
assert models[0].tags.items() >= context.info.to_mlflow_tags().items()
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_reuses_model_when_no_changes(tmp_git_repo: Path):
|
|
# Create initial model
|
|
with enable_git_model_versioning() as context:
|
|
initial_model_id = context.active_model.model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
# No git state changes, should reuse the same model
|
|
with enable_git_model_versioning() as context:
|
|
assert mlflow.get_active_model_id() == initial_model_id
|
|
models = mlflow.search_logged_models(output_format="list")
|
|
assert len(models) == 1
|
|
assert models[0].model_id == initial_model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_creates_new_model_on_commit(tmp_git_repo: Path):
|
|
# Create initial model
|
|
with enable_git_model_versioning() as context:
|
|
initial_model_id = context.active_model.model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
# Make a new commit
|
|
subprocess.check_call(["git", "commit", "--allow-empty", "-m", "commit"], cwd=tmp_git_repo)
|
|
|
|
# Should create a new logged model
|
|
with enable_git_model_versioning() as context:
|
|
assert mlflow.get_active_model_id() != initial_model_id
|
|
assert mlflow.get_active_model_id() == context.active_model.model_id
|
|
models = mlflow.search_logged_models(output_format="list")
|
|
assert len(models) == 2
|
|
assert models[0].model_id == context.active_model.model_id
|
|
assert models[0].tags.items() >= context.info.to_mlflow_tags().items()
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_creates_new_model_on_dirty_repo(tmp_git_repo: Path):
|
|
# Create initial model
|
|
with enable_git_model_versioning() as context:
|
|
initial_model_id = context.active_model.model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
# Modify a tracked file to make the repo dirty
|
|
(tmp_git_repo / TEST_FILENAME).write_text("Updated content")
|
|
|
|
# Should create a new logged model
|
|
with enable_git_model_versioning() as context:
|
|
assert mlflow.get_active_model_id() != initial_model_id
|
|
assert mlflow.get_active_model_id() == context.active_model.model_id
|
|
models = mlflow.search_logged_models(output_format="list")
|
|
assert len(models) == 2
|
|
assert models[0].model_id == context.active_model.model_id
|
|
assert models[0].tags.items() >= context.info.to_mlflow_tags().items()
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_ignores_untracked_files(tmp_git_repo: Path):
|
|
# Create initial model
|
|
with enable_git_model_versioning() as context:
|
|
initial_model_id = context.active_model.model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
# Create an untracked file
|
|
(tmp_git_repo / "untracked.txt").touch()
|
|
|
|
# Should NOT create a new logged model
|
|
with enable_git_model_versioning() as context:
|
|
assert mlflow.get_active_model_id() == initial_model_id
|
|
models = mlflow.search_logged_models(output_format="list")
|
|
assert len(models) == 1
|
|
assert models[0].model_id == initial_model_id
|
|
assert mlflow.get_active_model_id() is None
|
|
|
|
|
|
def test_enable_git_model_versioning_default_remote_name(tmp_git_repo: Path):
|
|
subprocess.check_call(
|
|
["git", "remote", "add", "origin", "https://github.com/test/repo.git"], cwd=tmp_git_repo
|
|
)
|
|
context = enable_git_model_versioning()
|
|
assert context.info.repo_url == "https://github.com/test/repo.git"
|
|
|
|
|
|
def test_enable_git_model_versioning_custom_remote_name(tmp_git_repo: Path):
|
|
# Add multiple remotes
|
|
subprocess.check_call(
|
|
["git", "remote", "add", "origin", "https://github.com/test/repo.git"],
|
|
cwd=tmp_git_repo,
|
|
)
|
|
subprocess.check_call(
|
|
["git", "remote", "add", "upstream", "https://github.com/upstream/repo.git"],
|
|
cwd=tmp_git_repo,
|
|
)
|
|
context = enable_git_model_versioning(remote_name="upstream")
|
|
assert context.info.repo_url == "https://github.com/upstream/repo.git"
|
|
|
|
|
|
def test_enable_git_model_versioning_no_remote(tmp_git_repo: Path):
|
|
# No remote - repo_url should be None
|
|
context = enable_git_model_versioning()
|
|
assert context.info.repo_url is None
|
|
|
|
|
|
def test_git_diff_collected_when_dirty(tmp_git_repo: Path):
|
|
# Initially clean repo
|
|
context = enable_git_model_versioning()
|
|
assert context.info.dirty is False
|
|
assert context.info.diff is None
|
|
disable_git_model_versioning()
|
|
|
|
# Modify a tracked file
|
|
test_file = tmp_git_repo / TEST_FILENAME
|
|
test_file.write_text("Modified content")
|
|
|
|
# Should have diff now
|
|
context = enable_git_model_versioning()
|
|
assert context.info.dirty is True
|
|
assert context.info.diff is not None
|
|
assert "Modified content" in context.info.diff
|
|
assert MLFLOW_GIT_DIFF in context.info.to_mlflow_tags()
|
|
|
|
# Make another change
|
|
with open(test_file, "a") as f:
|
|
f.write("\nAnother change")
|
|
|
|
# Both changes should be in the diff
|
|
context = enable_git_model_versioning()
|
|
model = mlflow.get_logged_model(context.active_model.model_id)
|
|
assert "Modified content" in model.tags[MLFLOW_GIT_DIFF]
|
|
assert "Another change" in model.tags[MLFLOW_GIT_DIFF]
|
|
|
|
|
|
def test_git_diff_includes_staged_changes(tmp_git_repo: Path):
|
|
# Create two files
|
|
file1 = tmp_git_repo / "file1.txt"
|
|
file2 = tmp_git_repo / "file2.txt"
|
|
file1.write_text("file1 content")
|
|
file2.write_text("file2 content")
|
|
|
|
# Stage file1
|
|
subprocess.check_call(["git", "add", "file1.txt"], cwd=tmp_git_repo)
|
|
|
|
# file2 remains unstaged (but untracked files don't show in diff)
|
|
# So let's modify an existing tracked file instead
|
|
(tmp_git_repo / TEST_FILENAME).write_text("modified content")
|
|
|
|
context = enable_git_model_versioning()
|
|
assert context.info.dirty is True
|
|
assert context.info.diff is not None
|
|
assert "file1 content" in context.info.diff # Staged changes
|
|
assert "modified content" in context.info.diff # Unstaged changes
|
|
|
|
|
|
def test_enable_git_model_versioning_from_subdirectory(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_git_repo: Path
|
|
):
|
|
subdir = tmp_git_repo / "subdir"
|
|
subdir.mkdir()
|
|
monkeypatch.chdir(subdir)
|
|
|
|
context = enable_git_model_versioning()
|
|
assert context.info is not None
|
|
assert context.info.commit is not None
|
|
assert context.info.branch is not None
|
|
assert context.info.dirty is False
|