|
14 | 14 |
|
15 | 15 | import base64 |
16 | 16 | import pathlib |
| 17 | +import unittest.mock |
17 | 18 |
|
18 | 19 | import pytest |
19 | 20 |
|
20 | 21 | import mlrun |
| 22 | +import mlrun.common.constants |
21 | 23 | import mlrun.common.schemas |
22 | 24 | import mlrun.runtimes |
23 | 25 | import mlrun.utils |
@@ -395,17 +397,6 @@ def test_deploy_reverse_proxy_image(rundb_mock, igz_version_mock): |
395 | 397 | assert mlrun.runtimes.ApplicationRuntime.reverse_proxy_image |
396 | 398 |
|
397 | 399 |
|
398 | | -def test_application_from_local_file_validation(): |
399 | | - project = mlrun.get_or_create_project("test-application", allow_cross_project=True) |
400 | | - func_path = assets_path / "sample_function.py" |
401 | | - with pytest.raises( |
402 | | - mlrun.errors.MLRunInvalidArgumentError, |
403 | | - match="Embedding a code file is not supported for application runtime. " |
404 | | - "Code files should be specified via project/function source.", |
405 | | - ): |
406 | | - project.set_function(func=str(func_path), name="my-app", kind="application") |
407 | | - |
408 | | - |
409 | 400 | def _assert_function_code(fn, file_path=None): |
410 | 401 | file_path = ( |
411 | 402 | file_path or mlrun.runtimes.ApplicationRuntime.get_filename_and_handler()[0] |
@@ -967,3 +958,153 @@ def test_enrich_sidecar_probe_ports_no_probes(): |
967 | 958 | assert ProbeType.READINESS.key not in sidecar |
968 | 959 | assert ProbeType.LIVENESS.key not in sidecar |
969 | 960 | assert ProbeType.STARTUP.key not in sidecar |
| 961 | + |
| 962 | + |
| 963 | +@pytest.mark.parametrize( |
| 964 | + "source,setup_file,expected", |
| 965 | + [ |
| 966 | + ("local_file.py", True, True), |
| 967 | + ("directory_path", False, False), |
| 968 | + ("", False, False), |
| 969 | + ("store://artifacts/project/file", False, False), |
| 970 | + ("https://example.com/file.py", False, False), |
| 971 | + ("git://github.com/repo.git", False, False), |
| 972 | + ("/non/existent/path.py", False, False), |
| 973 | + ], |
| 974 | +) |
| 975 | +def test_is_single_local_file(tmp_path, source, setup_file, expected): |
| 976 | + # Test _is_single_local_file identifies local files vs remote/invalid sources. |
| 977 | + func_name = "application-test" |
| 978 | + fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function( |
| 979 | + func_name, |
| 980 | + kind="application", |
| 981 | + image="mlrun/mlrun", |
| 982 | + ) |
| 983 | + |
| 984 | + if setup_file: |
| 985 | + # Create a temporary file for local file case |
| 986 | + file_path = tmp_path / source |
| 987 | + file_path.write_text("def handler(): pass") |
| 988 | + test_source = str(file_path) |
| 989 | + elif source == "directory_path": |
| 990 | + # Use tmp_path as directory |
| 991 | + test_source = str(tmp_path) |
| 992 | + else: |
| 993 | + test_source = source |
| 994 | + |
| 995 | + assert fn._is_single_local_file(test_source) is expected |
| 996 | + |
| 997 | + |
| 998 | +def test_upload_source_as_artifact(tmp_path): |
| 999 | + # Test that local single file is uploaded as artifact |
| 1000 | + func_name = "application-test" |
| 1001 | + # Create a temporary source file |
| 1002 | + source_file = tmp_path / "handler.py" |
| 1003 | + source_file.write_text("def handler(): pass") |
| 1004 | + |
| 1005 | + fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function( |
| 1006 | + func_name, |
| 1007 | + kind="application", |
| 1008 | + image="mlrun/mlrun", |
| 1009 | + project="test-project", |
| 1010 | + ) |
| 1011 | + fn.spec.build.source = str(source_file) |
| 1012 | + |
| 1013 | + mock_artifact = unittest.mock.MagicMock() |
| 1014 | + mock_artifact.uri = "store://artifacts/test-project/application-test-source" |
| 1015 | + |
| 1016 | + mock_project = unittest.mock.MagicMock() |
| 1017 | + mock_project.log_artifact.return_value = mock_artifact |
| 1018 | + |
| 1019 | + with unittest.mock.patch( |
| 1020 | + "mlrun.get_or_create_project", return_value=mock_project |
| 1021 | + ) as mock_get_project: |
| 1022 | + fn._upload_source_as_artifact() |
| 1023 | + |
| 1024 | + # Verify project was retrieved |
| 1025 | + mock_get_project.assert_called_once_with("test-project") |
| 1026 | + |
| 1027 | + # Verify artifact was logged with correct parameters |
| 1028 | + mock_project.log_artifact.assert_called_once_with( |
| 1029 | + item="application-test-source", |
| 1030 | + local_path=str(source_file), |
| 1031 | + artifact_path=mlrun.common.constants.MLRUN_INTERNAL_ARTIFACT_PATH, |
| 1032 | + upload=True, |
| 1033 | + labels={ |
| 1034 | + mlrun.common.constants.MLRunInternalLabels.function_name: func_name, |
| 1035 | + mlrun.common.constants.MLRunInternalLabels.system_generated: "true", |
| 1036 | + }, |
| 1037 | + ) |
| 1038 | + |
| 1039 | + # Verify source was updated to the artifact URI |
| 1040 | + assert ( |
| 1041 | + fn.spec.build.source == "store://artifacts/test-project/application-test-source" |
| 1042 | + ) |
| 1043 | + |
| 1044 | + |
| 1045 | +@pytest.mark.parametrize( |
| 1046 | + "source", |
| 1047 | + [ |
| 1048 | + "store://artifacts/test-project/existing-artifact", |
| 1049 | + "https://github.com/org/repo.git", |
| 1050 | + "s3://bucket/path/file.py", |
| 1051 | + "", |
| 1052 | + ], |
| 1053 | +) |
| 1054 | +def test_upload_source_as_artifact_skip_non_local(source): |
| 1055 | + # Test that non-local sources (store URIs, remote URLs) and empty path are not uploaded |
| 1056 | + func_name = "application-test" |
| 1057 | + fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function( |
| 1058 | + func_name, |
| 1059 | + kind="application", |
| 1060 | + image="mlrun/mlrun", |
| 1061 | + project="test-project", |
| 1062 | + ) |
| 1063 | + fn.spec.build.source = source |
| 1064 | + |
| 1065 | + with unittest.mock.patch("mlrun.get_or_create_project") as mock_get_project: |
| 1066 | + fn._upload_source_as_artifact() |
| 1067 | + |
| 1068 | + # Verify project was not called (upload skipped) |
| 1069 | + mock_get_project.assert_not_called() |
| 1070 | + |
| 1071 | + # Verify source remains unchanged |
| 1072 | + assert fn.spec.build.source == source |
| 1073 | + |
| 1074 | + |
| 1075 | +def test_upload_source_as_artifact_no_project_error(): |
| 1076 | + # Test that missing project raises an error |
| 1077 | + func_name = "application-test" |
| 1078 | + # Create a temporary source file |
| 1079 | + with unittest.mock.patch("os.path.isfile", return_value=True): |
| 1080 | + fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function( |
| 1081 | + func_name, |
| 1082 | + kind="application", |
| 1083 | + image="mlrun/mlrun", |
| 1084 | + ) |
| 1085 | + fn.metadata.project = None |
| 1086 | + fn.spec.build.source = "/path/to/handler.py" |
| 1087 | + |
| 1088 | + with pytest.raises( |
| 1089 | + mlrun.errors.MLRunMissingProjectError, |
| 1090 | + match="Project is required to upload source as artifact", |
| 1091 | + ): |
| 1092 | + fn._upload_source_as_artifact() |
| 1093 | + |
| 1094 | + |
| 1095 | +def test_set_function_single_file_application(tmp_path): |
| 1096 | + # Test that set_function with single .py file works for application runtime |
| 1097 | + source_file = tmp_path / "handler.py" |
| 1098 | + source_file.write_text("def handler(): pass") |
| 1099 | + |
| 1100 | + project = mlrun.get_or_create_project("test-proj", allow_cross_project=True) |
| 1101 | + fn = project.set_function( |
| 1102 | + str(source_file), |
| 1103 | + name="my-app", |
| 1104 | + kind="application", |
| 1105 | + image="mlrun/mlrun", |
| 1106 | + ) |
| 1107 | + |
| 1108 | + assert fn.kind == "application" |
| 1109 | + assert fn.metadata.project == "test-proj" |
| 1110 | + assert fn.spec.build.source == str(source_file) |
0 commit comments