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

263 lines
10 KiB
Python

import os
from shlex import quote
from unittest import mock
import pytest
from mlflow.exceptions import ExecutionException
from mlflow.projects._project_spec import EntryPoint
from mlflow.utils.file_utils import TempDir, path_to_local_file_uri
from tests.projects.utils import TEST_PROJECT_DIR, load_project
def test_entry_point_compute_params():
"""
Tests that EntryPoint correctly computes a final set of parameters to use when running a project
"""
project = load_project()
entry_point = project.get_entry_point("greeter")
# Pass extra "excitement" param, use default value for `greeting` param
with TempDir() as storage_dir:
params, extra_params = entry_point.compute_parameters(
{"name": "friend", "excitement": 10}, storage_dir
)
assert params == {"name": "friend", "greeting": "hi"}
assert extra_params == {quote("excitement"): quote("10")}
# Don't pass extra "excitement" param, pass value for `greeting`
params, extra_params = entry_point.compute_parameters(
{"name": "friend", "greeting": "hello"}, storage_dir
)
assert params == {"name": "friend", "greeting": "hello"}
assert extra_params == {}
# Raise exception on missing required parameter
with pytest.raises(
ExecutionException, match="No value given for missing parameters: 'name'"
):
entry_point.compute_parameters({}, storage_dir)
def test_entry_point_compute_command():
"""
Tests that EntryPoint correctly computes the command to execute in order to run the entry point.
"""
project = load_project()
entry_point = project.get_entry_point("greeter")
with TempDir() as tmp:
storage_dir = tmp.path()
command = entry_point.compute_command({"name": "friend", "excitement": 10}, storage_dir)
assert command == "python greeter.py hi friend --excitement 10"
with pytest.raises(
ExecutionException, match="No value given for missing parameters: 'name'"
):
entry_point.compute_command({}, storage_dir)
# Test shell escaping
name_value = "friend; echo 'hi'"
command = entry_point.compute_command({"name": name_value}, storage_dir)
assert command == "python greeter.py {} {}".format(quote("hi"), quote(name_value))
@pytest.mark.parametrize(
"malicious_key",
["x;touch /tmp/pwned", "a&b", "a|b", "a`b`", "a$(b)", "a>b"],
)
def test_compute_command_shell_escapes_extra_param_keys(malicious_key):
entry_point = load_project().get_entry_point("greeter")
with TempDir() as tmp:
command = entry_point.compute_command({"name": "friend", malicious_key: "1"}, tmp.path())
# Raw command must contain the shell-quoted key so bash parses it as a
# single (nonsensical) flag token rather than executing the payload.
assert f"--{quote(malicious_key)}" in command
def test_path_parameter():
"""
Tests that MLflow file-download APIs get called when necessary for arguments of type `path`.
"""
project = load_project()
entry_point = project.get_entry_point("line_count")
with mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri", return_value=0
) as download_uri_mock:
# Verify that we don't attempt to call download_uri when passing a local file to a
# parameter of type "path"
with TempDir() as tmp:
dst_dir = tmp.path()
local_path = os.path.join(TEST_PROJECT_DIR, "MLproject")
params, _ = entry_point.compute_parameters(
user_parameters={"path": local_path}, storage_dir=dst_dir
)
assert params["path"] == os.path.abspath(local_path)
assert download_uri_mock.call_count == 0
params, _ = entry_point.compute_parameters(
user_parameters={"path": path_to_local_file_uri(local_path)}, storage_dir=dst_dir
)
assert params["path"] == os.path.abspath(local_path)
assert download_uri_mock.call_count == 0
# Verify that we raise an exception when passing a non-existent local file to a
# parameter of type "path"
with TempDir() as tmp:
dst_dir = tmp.path()
with pytest.raises(ExecutionException, match="no such file or directory"):
entry_point.compute_parameters(
user_parameters={"path": os.path.join(dst_dir, "some/nonexistent/file")},
storage_dir=dst_dir,
)
# Verify that we do call `download_uri` when passing a URI to a parameter of type "path"
for i, prefix in enumerate(["dbfs:/", "s3://", "gs://"]):
with TempDir() as tmp:
dst_dir = tmp.path()
file_to_download = "images.tgz"
download_path = f"{dst_dir}/{file_to_download}"
download_uri_mock.return_value = download_path
params, _ = entry_point.compute_parameters(
user_parameters={"path": os.path.join(prefix, file_to_download)},
storage_dir=dst_dir,
)
assert params["path"] == download_path
assert download_uri_mock.call_count == i + 1
def test_uri_parameter():
project = load_project()
entry_point = project.get_entry_point("download_uri")
with (
mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri"
) as download_uri_mock,
TempDir() as tmp,
):
dst_dir = tmp.path()
# Test that we don't attempt to locally download parameters of type URI
entry_point.compute_command(
user_parameters={"uri": f"file://{dst_dir}"}, storage_dir=dst_dir
)
assert download_uri_mock.call_count == 0
# Test that we raise an exception if a local path is passed to a parameter of type URI
with pytest.raises(ExecutionException, match="Expected URI for parameter uri"):
entry_point.compute_command(user_parameters={"uri": dst_dir}, storage_dir=dst_dir)
def test_params():
defaults = {
"alpha": "float",
"l1_ratio": {"type": "float", "default": 0.1},
"l2_ratio": {"type": "float", "default": 0.0003},
"random_str": {"type": "string", "default": "hello"},
}
entry_point = EntryPoint("entry_point_name", defaults, "command_name script.py")
user1 = {}
with pytest.raises(ExecutionException, match="No value given for missing parameters"):
entry_point._validate_parameters(user1)
user_2 = {"beta": 0.004}
with pytest.raises(ExecutionException, match="No value given for missing parameters"):
entry_point._validate_parameters(user_2)
user_3 = {"alpha": 0.004, "gamma": 0.89}
expected_final_3 = {
"alpha": "0.004",
"l1_ratio": "0.1",
"l2_ratio": "0.0003",
"random_str": "hello",
}
expected_extra_3 = {quote("gamma"): quote("0.89")}
final_3, extra_3 = entry_point.compute_parameters(user_3, None)
assert expected_extra_3 == extra_3
assert expected_final_3 == final_3
user_4 = {"alpha": 0.004, "l1_ratio": 0.0008, "random_str_2": "hello"}
expected_final_4 = {
"alpha": "0.004",
"l1_ratio": "0.0008",
"l2_ratio": "0.0003",
"random_str": "hello",
}
expected_extra_4 = {quote("random_str_2"): quote("hello")}
final_4, extra_4 = entry_point.compute_parameters(user_4, None)
assert expected_extra_4 == extra_4
assert expected_final_4 == final_4
user_5 = {"alpha": -0.99, "random_str": "hi"}
expected_final_5 = {
"alpha": "-0.99",
"l1_ratio": "0.1",
"l2_ratio": "0.0003",
"random_str": "hi",
}
expected_extra_5 = {}
final_5, extra_5 = entry_point.compute_parameters(user_5, None)
assert expected_final_5 == final_5
assert expected_extra_5 == extra_5
user_6 = {"alpha": 0.77, "ALPHA": 0.89}
expected_final_6 = {
"alpha": "0.77",
"l1_ratio": "0.1",
"l2_ratio": "0.0003",
"random_str": "hello",
}
expected_extra_6 = {quote("ALPHA"): quote("0.89")}
final_6, extra_6 = entry_point.compute_parameters(user_6, None)
assert expected_extra_6 == extra_6
assert expected_final_6 == final_6
def test_path_params():
data_file = "s3://path.test/resources/data_file.csv"
defaults = {
"constants": {"type": "uri", "default": "s3://path.test/b1"},
"data": {"type": "path", "default": data_file},
}
entry_point = EntryPoint("entry_point_name", defaults, "command_name script.py")
with mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri", return_value=None
) as download_uri_mock:
final_1, extra_1 = entry_point.compute_parameters({}, None)
assert final_1 == {"constants": "s3://path.test/b1", "data": data_file}
assert extra_1 == {}
assert download_uri_mock.call_count == 0
with mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri"
) as download_uri_mock:
user_2 = {"alpha": 0.001, "constants": "s3://path.test/b_two"}
final_2, extra_2 = entry_point.compute_parameters(user_2, None)
assert final_2 == {"constants": "s3://path.test/b_two", "data": data_file}
assert extra_2 == {"alpha": "0.001"}
assert download_uri_mock.call_count == 0
with (
mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri"
) as download_uri_mock,
TempDir() as tmp,
):
dest_path = tmp.path()
download_path = f"{dest_path}/data_file.csv"
download_uri_mock.return_value = download_path
user_3 = {"alpha": 0.001}
final_3, extra_3 = entry_point.compute_parameters(user_3, dest_path)
assert final_3 == {"constants": "s3://path.test/b1", "data": download_path}
assert extra_3 == {"alpha": "0.001"}
assert download_uri_mock.call_count == 1
with (
mock.patch(
"mlflow.tracking.artifact_utils._download_artifact_from_uri"
) as download_uri_mock,
TempDir() as tmp,
):
dest_path = tmp.path()
download_path = f"{dest_path}/images.tgz"
download_uri_mock.return_value = download_path
user_4 = {"data": "s3://another.example.test/data_stash/images.tgz"}
final_4, extra_4 = entry_point.compute_parameters(user_4, dest_path)
assert final_4 == {"constants": "s3://path.test/b1", "data": download_path}
assert extra_4 == {}
assert download_uri_mock.call_count == 1