549 lines
19 KiB
Python
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)
|