Skip to content

Commit a47dfe0

Browse files
Complete function pyfiles support with pip install and error handling
1 parent 3ef526f commit a47dfe0

3 files changed

Lines changed: 253 additions & 10 deletions

File tree

src/datacustomcode/deploy.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def prepare_dependency_archive(directory: str, docker_network: str, package_type
294294

295295
with tempfile.TemporaryDirectory() as temp_dir:
296296
logger.info(
297-
f"Building dependencies archive with docker network: {docker_network}"
297+
f"Building dependencies with docker network: {docker_network}"
298298
)
299299
shutil.copy("requirements.txt", temp_dir)
300300
shutil.copy("build_native_dependencies.sh", temp_dir)
@@ -303,6 +303,11 @@ def prepare_dependency_archive(directory: str, docker_network: str, package_type
303303

304304
if package_type == "function":
305305
source_py_files = os.path.join(temp_dir, "py-files")
306+
if not os.path.exists(source_py_files):
307+
raise FileNotFoundError(
308+
f"Expected py-files directory not found at {source_py_files}. "
309+
"Docker build may have failed."
310+
)
306311
os.makedirs(os.path.dirname(PY_FILES_PATH), exist_ok=True)
307312
if os.path.exists(PY_FILES_PATH):
308313
shutil.rmtree(PY_FILES_PATH)
@@ -525,10 +530,13 @@ def upload_zip(file_upload_url: str) -> None:
525530
def _get_package_type_for_directory(directory: str) -> str:
526531
"""Resolve package type (script/function) for the given payload directory."""
527532
try:
528-
entrypoint_path = os.path.join(os.path.abspath(directory), "entrypoint.py")
529-
base_directory = find_base_directory(entrypoint_path)
533+
base_directory = find_base_directory(os.path.abspath(directory))
530534
return get_package_type(base_directory)
531-
except (ValueError, FileNotFoundError):
535+
except (ValueError, FileNotFoundError) as e:
536+
logger.debug(
537+
f"Could not determine package type for {directory}: {e}. "
538+
"Defaulting to 'script'"
539+
)
532540
return "script"
533541

534542

src/datacustomcode/templates/function/build_native_dependencies.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ set -e
44
# Description: build native dependencies for function (unpacked pip install to py-files)
55

66
python3.11 -m venv --copies .venv
7-
source .venv/bin/activate
7+
source .venv/bin/activate
8+
pip install --target ./py-files -r requirements.txt

tests/test_deploy.py

Lines changed: 239 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
CreateDeploymentResponse,
2727
DataTransformConfig,
2828
DeploymentsResponse,
29+
_get_package_type_for_directory,
2930
_make_api_call,
3031
_retrieve_access_token,
3132
_retrieve_access_token_from_sf_cli,
@@ -92,7 +93,7 @@ def test_prepare_dependency_archive_image_exists(
9293
mock_docker_build_cmd.return_value = "mock build command"
9394
mock_docker_run_cmd.return_value = "mock run command"
9495

95-
prepare_dependency_archive("/test/dir", "default")
96+
prepare_dependency_archive("/test/dir", "default", "script")
9697

9798
# Verify docker images command was called
9899
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -152,7 +153,7 @@ def test_prepare_dependency_archive_build_image(
152153
mock_docker_build_cmd.return_value = "mock build command"
153154
mock_docker_run_cmd.return_value = "mock run command"
154155

155-
prepare_dependency_archive("/test/dir", "default")
156+
prepare_dependency_archive("/test/dir", "default", "script")
156157

157158
# Verify docker images command was called
158159
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -213,7 +214,7 @@ def test_prepare_dependency_archive_docker_build_failure(
213214
]
214215

215216
with pytest.raises(CalledProcessError, match="Build failed"):
216-
prepare_dependency_archive("/test/dir", "default")
217+
prepare_dependency_archive("/test/dir", "default", "script")
217218

218219
# Verify docker images command was called
219220
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -256,7 +257,7 @@ def test_prepare_dependency_archive_docker_run_failure(
256257
]
257258

258259
with pytest.raises(CalledProcessError, match="Run failed"):
259-
prepare_dependency_archive("/test/dir", "default")
260+
prepare_dependency_archive("/test/dir", "default", "script")
260261

261262
# Verify docker images command was called
262263
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -299,14 +300,211 @@ def test_prepare_dependency_archive_file_copy_failure(
299300
mock_copy.side_effect = FileNotFoundError("File not found")
300301

301302
with pytest.raises(FileNotFoundError, match="File not found"):
302-
prepare_dependency_archive("/test/dir", "default")
303+
prepare_dependency_archive("/test/dir", "default", "script")
303304

304305
# Verify docker images command was called
305306
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
306307

307308
# Verify files were attempted to be copied
308309
mock_copy.assert_any_call("requirements.txt", "/tmp/test_dir")
309310

311+
@patch("datacustomcode.deploy.cmd_output")
312+
@patch("datacustomcode.deploy.shutil.copytree")
313+
@patch("datacustomcode.deploy.shutil.rmtree")
314+
@patch("datacustomcode.deploy.shutil.copy")
315+
@patch("datacustomcode.deploy.tempfile.TemporaryDirectory")
316+
@patch("datacustomcode.deploy.os.path.exists")
317+
@patch("datacustomcode.deploy.os.path.join")
318+
@patch("datacustomcode.deploy.os.makedirs")
319+
@patch("datacustomcode.deploy.docker_build_cmd")
320+
@patch("datacustomcode.deploy.docker_run_cmd")
321+
def test_prepare_dependency_archive_function_type(
322+
self,
323+
mock_docker_run_cmd,
324+
mock_docker_build_cmd,
325+
mock_makedirs,
326+
mock_join,
327+
mock_exists,
328+
mock_temp_dir,
329+
mock_copy,
330+
mock_rmtree,
331+
mock_copytree,
332+
mock_cmd_output,
333+
):
334+
"""Test prepare_dependency_archive with function package type."""
335+
# Mock the temporary directory context manager
336+
mock_temp_dir_instance = MagicMock()
337+
mock_temp_dir_instance.__enter__.return_value = "/tmp/test_dir"
338+
mock_temp_dir_instance.__exit__.return_value = None
339+
mock_temp_dir.return_value = mock_temp_dir_instance
340+
341+
# Mock cmd_output to return image ID (indicating image exists)
342+
mock_cmd_output.return_value = "abc123"
343+
344+
# Mock os.path.join for py-files paths
345+
def join_side_effect(*args):
346+
if args == ("/tmp/test_dir", "py-files"):
347+
return "/tmp/test_dir/py-files"
348+
return "/".join(args)
349+
350+
mock_join.side_effect = join_side_effect
351+
352+
# Mock os.path.exists
353+
def exists_side_effect(path):
354+
if path == "/tmp/test_dir/py-files":
355+
return True
356+
if path == "payload/py-files":
357+
return False
358+
return False
359+
360+
mock_exists.side_effect = exists_side_effect
361+
362+
# Mock the docker command functions
363+
mock_docker_build_cmd.return_value = "mock build command"
364+
mock_docker_run_cmd.return_value = "mock run command"
365+
366+
prepare_dependency_archive("/test/dir", "default", "function")
367+
368+
# Verify docker images command was called
369+
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
370+
371+
# Verify docker build command was not called (since image already exists)
372+
mock_docker_build_cmd.assert_not_called()
373+
374+
# Verify files were copied to temp directory
375+
mock_copy.assert_any_call("requirements.txt", "/tmp/test_dir")
376+
mock_copy.assert_any_call("build_native_dependencies.sh", "/tmp/test_dir")
377+
378+
# Verify docker run command was called
379+
mock_docker_run_cmd.assert_called_once_with("default", "/tmp/test_dir")
380+
mock_cmd_output.assert_any_call("mock run command")
381+
382+
# Verify payload directory was created
383+
mock_makedirs.assert_called_once_with("payload", exist_ok=True)
384+
385+
# Verify py-files was NOT removed (doesn't exist yet)
386+
mock_rmtree.assert_not_called()
387+
388+
# Verify py-files directory was copied
389+
mock_copytree.assert_called_once_with(
390+
"/tmp/test_dir/py-files", "payload/py-files"
391+
)
392+
393+
@patch("datacustomcode.deploy.cmd_output")
394+
@patch("datacustomcode.deploy.shutil.copy")
395+
@patch("datacustomcode.deploy.tempfile.TemporaryDirectory")
396+
@patch("datacustomcode.deploy.os.path.exists")
397+
@patch("datacustomcode.deploy.os.path.join")
398+
@patch("datacustomcode.deploy.os.makedirs")
399+
@patch("datacustomcode.deploy.docker_build_cmd")
400+
@patch("datacustomcode.deploy.docker_run_cmd")
401+
def test_prepare_dependency_archive_function_type_missing_pyfiles(
402+
self,
403+
mock_docker_run_cmd,
404+
mock_docker_build_cmd,
405+
mock_makedirs,
406+
mock_join,
407+
mock_exists,
408+
mock_temp_dir,
409+
mock_copy,
410+
mock_cmd_output,
411+
):
412+
"""Test prepare_dependency_archive with function type when py-files is missing."""
413+
# Mock the temporary directory context manager
414+
mock_temp_dir_instance = MagicMock()
415+
mock_temp_dir_instance.__enter__.return_value = "/tmp/test_dir"
416+
mock_temp_dir_instance.__exit__.return_value = None
417+
mock_temp_dir.return_value = mock_temp_dir_instance
418+
419+
# Mock cmd_output to return image ID (indicating image exists)
420+
mock_cmd_output.return_value = "abc123"
421+
422+
# Mock os.path.join for py-files path
423+
def join_side_effect(*args):
424+
if args == ("/tmp/test_dir", "py-files"):
425+
return "/tmp/test_dir/py-files"
426+
return "/".join(args)
427+
428+
mock_join.side_effect = join_side_effect
429+
430+
# Mock os.path.exists to return False for py-files (doesn't exist)
431+
mock_exists.return_value = False
432+
433+
# Mock the docker command functions
434+
mock_docker_build_cmd.return_value = "mock build command"
435+
mock_docker_run_cmd.return_value = "mock run command"
436+
437+
# Should raise FileNotFoundError when py-files doesn't exist
438+
with pytest.raises(
439+
FileNotFoundError,
440+
match="Expected py-files directory not found at /tmp/test_dir/py-files",
441+
):
442+
prepare_dependency_archive("/test/dir", "default", "function")
443+
444+
# Verify docker commands were still called
445+
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
446+
mock_docker_run_cmd.assert_called_once_with("default", "/tmp/test_dir")
447+
448+
449+
class TestGetPackageTypeForDirectory:
450+
@patch("datacustomcode.deploy.get_package_type")
451+
@patch("datacustomcode.deploy.find_base_directory")
452+
def test_get_package_type_function(
453+
self, mock_find_base, mock_get_package_type
454+
):
455+
"""Test _get_package_type_for_directory returns function type."""
456+
mock_find_base.return_value = "/project/root"
457+
mock_get_package_type.return_value = "function"
458+
459+
result = _get_package_type_for_directory("/project/root/payload")
460+
461+
assert result == "function"
462+
mock_find_base.assert_called_once()
463+
mock_get_package_type.assert_called_once_with("/project/root")
464+
465+
@patch("datacustomcode.deploy.get_package_type")
466+
@patch("datacustomcode.deploy.find_base_directory")
467+
def test_get_package_type_script(self, mock_find_base, mock_get_package_type):
468+
"""Test _get_package_type_for_directory returns script type."""
469+
mock_find_base.return_value = "/project/root"
470+
mock_get_package_type.return_value = "script"
471+
472+
result = _get_package_type_for_directory("/project/root/payload")
473+
474+
assert result == "script"
475+
mock_find_base.assert_called_once()
476+
mock_get_package_type.assert_called_once_with("/project/root")
477+
478+
@patch("datacustomcode.deploy.get_package_type")
479+
@patch("datacustomcode.deploy.find_base_directory")
480+
def test_get_package_type_file_not_found(
481+
self, mock_find_base, mock_get_package_type
482+
):
483+
"""Test _get_package_type_for_directory defaults to script when config not found."""
484+
mock_find_base.return_value = "/project/root"
485+
mock_get_package_type.side_effect = FileNotFoundError("Config not found")
486+
487+
result = _get_package_type_for_directory("/project/root/payload")
488+
489+
assert result == "script"
490+
mock_find_base.assert_called_once()
491+
mock_get_package_type.assert_called_once_with("/project/root")
492+
493+
@patch("datacustomcode.deploy.get_package_type")
494+
@patch("datacustomcode.deploy.find_base_directory")
495+
def test_get_package_type_value_error(
496+
self, mock_find_base, mock_get_package_type
497+
):
498+
"""Test _get_package_type_for_directory defaults to script on ValueError."""
499+
mock_find_base.return_value = "/project/root"
500+
mock_get_package_type.side_effect = ValueError("Invalid type")
501+
502+
result = _get_package_type_for_directory("/project/root/payload")
503+
504+
assert result == "script"
505+
mock_find_base.assert_called_once()
506+
mock_get_package_type.assert_called_once_with("/project/root")
507+
310508

311509
class TestHasNonemptyRequirementsFile:
312510
@patch("datacustomcode.deploy.os.path.dirname")
@@ -645,6 +843,42 @@ def test_zip_without_requirements(
645843
)
646844
assert mock_zipfile_instance.write.call_count == 3 # One call per file
647845

846+
@patch("datacustomcode.deploy._get_package_type_for_directory")
847+
@patch("datacustomcode.deploy.has_nonempty_requirements_file")
848+
@patch("datacustomcode.deploy.prepare_dependency_archive")
849+
@patch("zipfile.ZipFile")
850+
@patch("os.walk")
851+
def test_zip_with_function_package_type(
852+
self,
853+
mock_walk,
854+
mock_zipfile,
855+
mock_prepare,
856+
mock_has_requirements,
857+
mock_get_package_type,
858+
):
859+
"""Test zipping a directory with function package type."""
860+
mock_has_requirements.return_value = True
861+
mock_get_package_type.return_value = "function"
862+
mock_zipfile_instance = MagicMock()
863+
mock_zipfile.return_value.__enter__.return_value = mock_zipfile_instance
864+
mock_zipfile_instance.write = MagicMock()
865+
866+
# Mock os.walk to return some test files
867+
mock_walk.return_value = [
868+
("/test/dir", ["subdir"], ["file1.py", "file2.py"]),
869+
("/test/dir/subdir", [], ["file3.py"]),
870+
]
871+
872+
zip("/test/dir", "default")
873+
874+
mock_has_requirements.assert_called_once_with("/test/dir")
875+
mock_get_package_type.assert_called_once_with("/test/dir")
876+
mock_prepare.assert_called_once_with("/test/dir", "default", "function")
877+
mock_zipfile.assert_called_once_with(
878+
"deployment.zip", "w", zipfile.ZIP_DEFLATED
879+
)
880+
assert mock_zipfile_instance.write.call_count == 3 # One call per file
881+
648882

649883
class TestUploadZip:
650884
@patch("datacustomcode.deploy.requests.put")

0 commit comments

Comments
 (0)