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