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

549 lines
19 KiB
Python

import json
import os
import shutil
import subprocess
import uuid
from unittest import mock
import git
import pytest
import yaml
import mlflow
from mlflow import MlflowClient
from mlflow.entities import RunStatus, SourceType, ViewType
from mlflow.environment_variables import MLFLOW_CONDA_CREATE_ENV_CMD, MLFLOW_CONDA_HOME
from mlflow.exceptions import ExecutionException, MlflowException
from mlflow.projects import _parse_kubernetes_config, _resolve_experiment_id
from mlflow.store.tracking.file_store import FileStore
from mlflow.utils import PYTHON_VERSION
from mlflow.utils.conda import CONDA_EXE, get_or_create_conda_env
from mlflow.utils.mlflow_tags import (
MLFLOW_GIT_BRANCH,
MLFLOW_GIT_REPO_URL,
MLFLOW_PARENT_RUN_ID,
MLFLOW_PROJECT_BACKEND,
MLFLOW_PROJECT_ENTRY_POINT,
MLFLOW_PROJECT_ENV,
MLFLOW_SOURCE_NAME,
MLFLOW_SOURCE_TYPE,
MLFLOW_USER,
)
from mlflow.utils.process import ShellCommandException
from tests.projects.utils import TEST_PROJECT_DIR, TEST_PROJECT_NAME, validate_exit_status
MOCK_USER = "janebloggs"
@pytest.fixture
def patch_user():
with mock.patch("mlflow.projects.utils._get_user", return_value=MOCK_USER):
yield
def _get_version_local_git_repo(local_git_repo):
repo = git.Repo(local_git_repo, search_parent_directories=True)
return repo.git.rev_parse("HEAD")
@pytest.fixture(scope="module", autouse=True)
def clean_mlruns_dir():
yield
dir_path = os.path.join(TEST_PROJECT_DIR, "mlruns")
if os.path.exists(dir_path):
shutil.rmtree(dir_path)
@pytest.mark.parametrize(
("experiment_name", "experiment_id", "expected"),
[
("Default", None, "0"),
("add an experiment", None, "1"),
(None, 2, "2"),
(None, "2", "2"),
(None, None, "0"),
],
)
def test_resolve_experiment_id(experiment_name, experiment_id, expected):
assert expected == _resolve_experiment_id(
experiment_name=experiment_name, experiment_id=experiment_id
)
def test_resolve_experiment_id_should_not_allow_both_name_and_id_in_use():
with pytest.raises(
MlflowException, match="Specify only one of 'experiment_name' or 'experiment_id'."
):
_resolve_experiment_id(experiment_name="experiment_named", experiment_id="44")
def test_invalid_run_mode():
with pytest.raises(
ExecutionException, match="Got unsupported execution mode some unsupported mode"
):
mlflow.projects.run(uri=TEST_PROJECT_DIR, backend="some unsupported mode")
def test_expected_tags_logged_when_using_conda():
with mock.patch.object(MlflowClient, "set_tag") as tag_mock:
try:
mlflow.projects.run(TEST_PROJECT_DIR, env_manager="conda")
finally:
tag_mock.assert_has_calls(
[
mock.call(mock.ANY, MLFLOW_PROJECT_BACKEND, "local"),
mock.call(mock.ANY, MLFLOW_PROJECT_ENV, "conda"),
],
any_order=True,
)
@pytest.mark.usefixtures("patch_user")
@pytest.mark.parametrize("use_start_run", map(str, [0, 1]))
@pytest.mark.parametrize("version", [None, "master", "git-commit"])
def test_run_local_git_repo(
local_git_repo, local_git_repo_uri, use_start_run, version, monkeypatch
):
monkeypatch.setenv("DATABRICKS_HOST", "my-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "my-token")
if version is not None:
uri = local_git_repo_uri + "#" + TEST_PROJECT_NAME
else:
uri = os.path.join(f"{local_git_repo}/", TEST_PROJECT_NAME)
if version == "git-commit":
version = _get_version_local_git_repo(local_git_repo)
submitted_run = mlflow.projects.run(
uri,
entry_point="test_tracking",
version=version,
parameters={"use_start_run": use_start_run},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
)
# Blocking runs should be finished when they return
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
# Test that we can call wait() on a synchronous run & that the run has the correct
# status after calling wait().
submitted_run.wait()
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
# Validate run contents in the FileStore
run_id = submitted_run.run_id
mlflow_service = MlflowClient()
runs = mlflow_service.search_runs(
[FileStore.DEFAULT_EXPERIMENT_ID], run_view_type=ViewType.ACTIVE_ONLY
)
assert len(runs) == 1
store_run_id = runs[0].info.run_id
assert run_id == store_run_id
run = mlflow_service.get_run(run_id)
assert run.info.status == RunStatus.to_string(RunStatus.FINISHED)
assert run.data.params == {
"use_start_run": use_start_run,
}
assert run.data.metrics == {"some_key": 3}
tags = run.data.tags
assert tags[MLFLOW_USER] == MOCK_USER
assert "file:" in tags[MLFLOW_SOURCE_NAME]
assert tags[MLFLOW_SOURCE_TYPE] == SourceType.to_string(SourceType.PROJECT)
assert tags[MLFLOW_PROJECT_ENTRY_POINT] == "test_tracking"
assert tags[MLFLOW_PROJECT_BACKEND] == "local"
if version == "master":
assert tags[MLFLOW_GIT_BRANCH] == "master"
assert tags[MLFLOW_GIT_REPO_URL] == local_git_repo_uri
def test_invalid_version_local_git_repo(local_git_repo_uri):
# Run project with invalid commit hash
with pytest.raises(ExecutionException, match=r"Unable to checkout version \'badc0de\'"):
mlflow.projects.run(
local_git_repo_uri + "#" + TEST_PROJECT_NAME,
entry_point="test_tracking",
version="badc0de",
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
)
@pytest.mark.parametrize("use_start_run", map(str, [0, 1]))
@pytest.mark.usefixtures("patch_user")
def test_run(use_start_run):
submitted_run = mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="test_tracking",
parameters={"use_start_run": use_start_run},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
)
assert submitted_run.run_id is not None
# Blocking runs should be finished when they return
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
# Test that we can call wait() on a synchronous run & that the run has the correct
# status after calling wait().
submitted_run.wait()
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
# Validate run contents in the FileStore
run_id = submitted_run.run_id
mlflow_service = MlflowClient()
runs = mlflow_service.search_runs(
[FileStore.DEFAULT_EXPERIMENT_ID], run_view_type=ViewType.ACTIVE_ONLY
)
assert len(runs) == 1
store_run_id = runs[0].info.run_id
assert run_id == store_run_id
run = mlflow_service.get_run(run_id)
assert run.info.status == RunStatus.to_string(RunStatus.FINISHED)
assert run.data.params == {
"use_start_run": use_start_run,
}
assert run.data.metrics == {"some_key": 3}
tags = run.data.tags
assert tags[MLFLOW_USER] == MOCK_USER
assert "file:" in tags[MLFLOW_SOURCE_NAME]
assert tags[MLFLOW_SOURCE_TYPE] == SourceType.to_string(SourceType.PROJECT)
assert tags[MLFLOW_PROJECT_ENTRY_POINT] == "test_tracking"
def test_run_with_parent():
with mlflow.start_run():
parent_run_id = mlflow.active_run().info.run_id
submitted_run = mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="test_tracking",
parameters={"use_start_run": "1"},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
)
assert submitted_run.run_id is not None
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
run_id = submitted_run.run_id
run = MlflowClient().get_run(run_id)
assert run.data.tags[MLFLOW_PARENT_RUN_ID] == parent_run_id
def test_run_with_artifact_path(tmp_path):
artifact_file = tmp_path.joinpath("model.pkl")
artifact_file.write_text("Hello world")
with mlflow.start_run() as run:
mlflow.log_artifact(artifact_file)
submitted_run = mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="test_artifact_path",
parameters={"model": f"runs:/{run.info.run_id}/model.pkl"},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
)
validate_exit_status(submitted_run.get_status(), RunStatus.FINISHED)
def test_run_async():
submitted_run0 = mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="sleep",
parameters={"duration": 2},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
synchronous=False,
)
validate_exit_status(submitted_run0.get_status(), RunStatus.RUNNING)
submitted_run0.wait()
validate_exit_status(submitted_run0.get_status(), RunStatus.FINISHED)
submitted_run1 = mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="sleep",
parameters={"duration": -1, "invalid-param": 30},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
synchronous=False,
)
submitted_run1.wait()
validate_exit_status(submitted_run1.get_status(), RunStatus.FAILED)
@pytest.mark.parametrize(
("mock_env", "expected_conda", "expected_activate"),
[
(
{CONDA_EXE: "/abc/conda"},
"/abc/conda",
"/abc/activate",
),
(
{MLFLOW_CONDA_HOME.name: "/some/dir/"},
"/some/dir/bin/conda",
"/some/dir/bin/activate",
),
],
)
def test_conda_path(mock_env, expected_conda, expected_activate, monkeypatch):
for name in [CONDA_EXE, MLFLOW_CONDA_HOME.name]:
monkeypatch.delenv(name, raising=False)
for name, value in mock_env.items():
monkeypatch.setenv(name, value)
assert mlflow.utils.conda.get_conda_bin_executable("conda") == expected_conda
assert mlflow.utils.conda.get_conda_bin_executable("activate") == expected_activate
@pytest.mark.parametrize(
("mock_env", "expected_conda_env_create_path"),
[
(
{CONDA_EXE: "/abc/conda"},
"/abc/conda",
),
(
{CONDA_EXE: "/abc/conda", MLFLOW_CONDA_CREATE_ENV_CMD.name: "mamba"},
"/abc/mamba",
),
(
{MLFLOW_CONDA_HOME.name: "/some/dir/"},
"/some/dir/bin/conda",
),
(
{MLFLOW_CONDA_HOME.name: "/some/dir/", MLFLOW_CONDA_CREATE_ENV_CMD.name: "mamba"},
"/some/dir/bin/mamba",
),
],
)
def test_find_conda_executables(mock_env, expected_conda_env_create_path, monkeypatch):
"""
Verify that we correctly determine the path to executables to be used to
create environments (for example, it could be mamba instead of conda)
"""
monkeypatch.delenv(CONDA_EXE, raising=False)
monkeypatch.delenv(MLFLOW_CONDA_HOME.name, raising=False)
monkeypatch.delenv(MLFLOW_CONDA_CREATE_ENV_CMD.name, raising=False)
for name, value in mock_env.items():
monkeypatch.setenv(name, value)
conda_env_create_path = mlflow.utils.conda._get_conda_executable_for_create_env()
assert conda_env_create_path == expected_conda_env_create_path
def test_create_env_with_mamba(monkeypatch):
"""
Test that mamba is called when set, and that we fail when mamba is not available or is
not working. We mock the calls so we do not actually execute mamba (which is not
installed in the test environment anyway)
"""
def exec_cmd_mock(cmd, *args, **kwargs):
if cmd[-1] == "--json":
# We are supposed to list environments in JSON format
return subprocess.CompletedProcess(
cmd, 0, json.dumps({"envs": ["mlflow-mock-environment"]}), None
)
else:
# Here we are creating the environment, no need to return
# anything
return subprocess.CompletedProcess(cmd, 0)
def exec_cmd_mock_raise(cmd, *args, **kwargs):
if os.path.basename(cmd[0]) == "mamba":
raise OSError()
conda_env_path = os.path.join(TEST_PROJECT_DIR, "conda.yaml")
monkeypatch.setenv(MLFLOW_CONDA_CREATE_ENV_CMD.name, "mamba")
# Simulate success
with mock.patch("mlflow.utils.process._exec_cmd", side_effect=exec_cmd_mock):
mlflow.utils.conda.get_or_create_conda_env(conda_env_path)
# Simulate a non-working or non-existent mamba
with mock.patch("mlflow.utils.process._exec_cmd", side_effect=exec_cmd_mock_raise):
with pytest.raises(
ExecutionException,
match="You have set the env variable MLFLOW_CONDA_CREATE_ENV_CMD",
):
mlflow.utils.conda.get_or_create_conda_env(conda_env_path)
def test_conda_environment_cleaned_up_when_pip_fails(tmp_path):
conda_yaml = tmp_path / "conda.yaml"
content = f"""
name: {uuid.uuid4().hex}
channels:
- conda-forge
dependencies:
- python={PYTHON_VERSION}
- pip
- pip:
- mlflow==999.999.999
"""
conda_yaml.write_text(content)
envs_before = mlflow.utils.conda._list_conda_environments()
# `conda create` should fail because mlflow 999.999.999 doesn't exist
with pytest.raises(ShellCommandException, match=r"No matching distribution found"):
mlflow.utils.conda.get_or_create_conda_env(conda_yaml, capture_output=True)
# Ensure the environment is cleaned up
envs_after = mlflow.utils.conda._list_conda_environments()
assert envs_before == envs_after
def test_cancel_run():
submitted_run0, submitted_run1 = (
mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="sleep",
parameters={"duration": 2},
env_manager="local",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
synchronous=False,
)
for _ in range(2)
)
submitted_run0.cancel()
validate_exit_status(submitted_run0.get_status(), RunStatus.FAILED)
# Sanity check: cancelling one run has no effect on the other
assert submitted_run1.wait()
validate_exit_status(submitted_run1.get_status(), RunStatus.FINISHED)
# Try cancelling after calling wait()
submitted_run1.cancel()
validate_exit_status(submitted_run1.get_status(), RunStatus.FINISHED)
def test_parse_kubernetes_config():
work_dir = "./examples/docker"
kubernetes_config = {
"kube-context": "docker-for-desktop",
"kube-job-template-path": os.path.join(work_dir, "kubernetes_job_template.yaml"),
"repository-uri": "dockerhub_account/mlflow-kubernetes-example",
}
yaml_obj = None
with open(kubernetes_config["kube-job-template-path"]) as job_template:
yaml_obj = yaml.safe_load(job_template.read())
kube_config = _parse_kubernetes_config(kubernetes_config)
assert kube_config["kube-context"] == kubernetes_config["kube-context"]
assert kube_config["kube-job-template-path"] == kubernetes_config["kube-job-template-path"]
assert kube_config["repository-uri"] == kubernetes_config["repository-uri"]
assert kube_config["kube-job-template"] == yaml_obj
@pytest.fixture
def mock_kubernetes_job_template(tmp_path):
k8s_yaml = tmp_path.joinpath("kubernetes_job_template.yaml")
k8s_yaml.write_text(
"""
apiVersion: batch/v1
kind: Job
metadata:
name: "{replaced with MLflow Project name}"
namespace: mlflow
spec:
ttlSecondsAfterFinished: 100
backoffLimit: 0
template:
spec:
containers:
- name: "{replaced with MLflow Project name}"
image: "{replaced with URI of Docker image created during Project execution}"
command: ["{replaced with MLflow Project entry point command}"]
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
restartPolicy: Never
""".lstrip()
)
return str(k8s_yaml)
class StartsWithMatcher:
def __init__(self, prefix):
self.prefix = prefix
def __eq__(self, other):
return isinstance(other, str) and other.startswith(self.prefix)
def test_parse_kubernetes_config_without_context(mock_kubernetes_job_template):
with mock.patch("mlflow.projects._logger.debug") as mock_debug:
kubernetes_config = {
"repository-uri": "dockerhub_account/mlflow-kubernetes-example",
"kube-job-template-path": mock_kubernetes_job_template,
}
_parse_kubernetes_config(kubernetes_config)
mock_debug.assert_called_once_with(
StartsWithMatcher("Could not find kube-context in backend_config")
)
def test_parse_kubernetes_config_without_image_uri(mock_kubernetes_job_template):
kubernetes_config = {
"kube-context": "docker-for-desktop",
"kube-job-template-path": mock_kubernetes_job_template,
}
with pytest.raises(ExecutionException, match="Could not find 'repository-uri'"):
_parse_kubernetes_config(kubernetes_config)
def test_parse_kubernetes_config_invalid_template_job_file():
kubernetes_config = {
"kube-context": "docker-for-desktop",
"repository-uri": "username/mlflow-kubernetes-example",
"kube-job-template-path": "file_not_found.yaml",
}
with pytest.raises(ExecutionException, match="Could not find 'kube-job-template-path'"):
_parse_kubernetes_config(kubernetes_config)
@pytest.mark.parametrize("synchronous", [True, False])
def test_credential_propagation(synchronous, monkeypatch):
class DummyProcess:
def wait(self):
return 0
def poll(self):
return 0
def communicate(self, _):
return "", ""
monkeypatch.setenv("DATABRICKS_HOST", "host")
monkeypatch.setenv("DATABRICKS_TOKEN", "mytoken")
with (
mock.patch("subprocess.Popen", return_value=DummyProcess()) as popen_mock,
mock.patch("mlflow.utils.uri.is_databricks_uri", return_value=True),
):
mlflow.projects.run(
TEST_PROJECT_DIR,
entry_point="sleep",
experiment_id=FileStore.DEFAULT_EXPERIMENT_ID,
parameters={"duration": 2},
env_manager="local",
synchronous=synchronous,
)
_, kwargs = popen_mock.call_args
env = kwargs["env"]
assert env["DATABRICKS_HOST"] == "host"
assert env["DATABRICKS_TOKEN"] == "mytoken"
def test_get_or_create_conda_env_capture_output_mode(tmp_path):
conda_yaml_file = tmp_path / "conda.yaml"
conda_yaml_file.write_text(
"""
channels:
- conda-forge
dependencies:
- pip:
- scikit-learn==99.99.99
"""
)
with pytest.raises(
ShellCommandException,
match="Could not find a version that satisfies the requirement scikit-learn==99.99.99",
):
get_or_create_conda_env(str(conda_yaml_file), capture_output=True)