Skip to content

Commit f914a25

Browse files
Add py-files support for function python dependency management (#77)
* Function Support for pyfiles * Complete function pyfiles support with pip install and error handling * Code refactor * Fix py-files directory handling - add existence check before rmtree * fixing dependencies
1 parent 9134460 commit f914a25

File tree

5 files changed

+216
-24
lines changed

5 files changed

+216
-24
lines changed

src/datacustomcode/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,12 @@ def auth(profile: str):
134134
@click.option("--network", default="default")
135135
def zip(path: str, network: str):
136136
from datacustomcode.deploy import zip
137+
from datacustomcode.scan import find_base_directory, get_package_type
137138

138139
logger.debug("Zipping project")
139-
zip(path, network)
140+
base_directory = find_base_directory(path)
141+
package_type = get_package_type(base_directory)
142+
zip(path, network, package_type)
140143

141144

142145
@cli.command()

src/datacustomcode/deploy.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,13 @@ def create_deployment(
279279
DEPENDENCIES_ARCHIVE_PATH = os.path.join(
280280
"payload", "archives", DEPENDENCIES_ARCHIVE_FULL_NAME
281281
)
282+
PY_FILES_PATH = os.path.join("payload", "py-files")
282283
ZIP_FILE_NAME = "deployment.zip"
283284

284285

285-
def prepare_dependency_archive(directory: str, docker_network: str) -> None:
286+
def prepare_dependency_archive(
287+
directory: str, docker_network: str, package_type: str
288+
) -> None:
286289
cmd = f"docker images -q {DOCKER_IMAGE_NAME}"
287290
image_exists = cmd_output(cmd)
288291

@@ -305,11 +308,28 @@ def prepare_dependency_archive(directory: str, docker_network: str) -> None:
305308
shutil.copy("build_native_dependencies.sh", temp_dir)
306309
cmd = docker_run_cmd(docker_network, temp_dir)
307310
cmd_output(cmd, env=docker_env)
308-
archives_temp_path = os.path.join(temp_dir, DEPENDENCIES_ARCHIVE_FULL_NAME)
309-
os.makedirs(os.path.dirname(DEPENDENCIES_ARCHIVE_PATH), exist_ok=True)
310-
shutil.copy(archives_temp_path, DEPENDENCIES_ARCHIVE_PATH)
311-
312-
logger.info(f"Dependencies archived to {DEPENDENCIES_ARCHIVE_PATH}")
311+
if package_type == "function":
312+
source_py_files = os.path.join(temp_dir, "py-files")
313+
if os.path.exists(source_py_files):
314+
logger.info(
315+
f"py-files directory found at {source_py_files}. "
316+
"Copying to payload directory..."
317+
)
318+
os.makedirs(os.path.dirname(PY_FILES_PATH), exist_ok=True)
319+
if os.path.exists(PY_FILES_PATH):
320+
shutil.rmtree(PY_FILES_PATH)
321+
shutil.copytree(source_py_files, PY_FILES_PATH)
322+
logger.info(f"py-files copied to {PY_FILES_PATH}")
323+
else:
324+
logger.info(
325+
f"No py-files directory found at {source_py_files}. "
326+
"Skipping py-files copy."
327+
)
328+
else:
329+
archives_temp_path = os.path.join(temp_dir, DEPENDENCIES_ARCHIVE_FULL_NAME)
330+
os.makedirs(os.path.dirname(DEPENDENCIES_ARCHIVE_PATH), exist_ok=True)
331+
shutil.copy(archives_temp_path, DEPENDENCIES_ARCHIVE_PATH)
332+
logger.info(f"Dependencies archived to {DEPENDENCIES_ARCHIVE_PATH}")
313333

314334

315335
def docker_build_cmd(network: str) -> str:
@@ -516,13 +536,14 @@ def upload_zip(file_upload_url: str) -> None:
516536
def zip(
517537
directory: str,
518538
docker_network: str,
539+
package_type: str,
519540
):
520541
# Create a zip file excluding .DS_Store files
521542
import zipfile
522543

523544
# prepare payload only if requirements.txt is non-empty
524545
if has_nonempty_requirements_file(directory):
525-
prepare_dependency_archive(directory, docker_network)
546+
prepare_dependency_archive(directory, docker_network, package_type)
526547
else:
527548
logger.info(
528549
f"Skipping dependency archive: requirements.txt is missing or empty "
@@ -561,7 +582,7 @@ def deploy_full(
561582

562583
# create deployment and upload payload
563584
deployment = create_deployment(access_token, metadata)
564-
zip(directory, docker_network)
585+
zip(directory, docker_network, metadata.codeType)
565586
upload_zip(deployment.fileUploadUrl)
566587
wait_for_deployment(access_token, metadata, callback)
567588

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
#!/bin/bash
22
set -e
33

4-
# Description: build native dependencies
4+
# Description: build native dependencies for function (unpacked pip install to py-files)
55

66
python3.11 -m venv --copies .venv
77
source .venv/bin/activate
8-
pip install -r requirements.txt
9-
venv-pack -o native_dependencies.tar.gz -f
8+
pip install --target ./py-files -r requirements.txt

tests/test_deploy.py

Lines changed: 179 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def test_prepare_dependency_archive_image_exists(
9393
mock_docker_build_cmd.return_value = "mock build command"
9494
mock_docker_run_cmd.return_value = "mock run command"
9595

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

9898
# Verify docker images command was called
9999
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -153,7 +153,7 @@ def test_prepare_dependency_archive_build_image(
153153
mock_docker_build_cmd.return_value = "mock build command"
154154
mock_docker_run_cmd.return_value = "mock run command"
155155

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

158158
# Verify docker images command was called
159159
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -214,7 +214,7 @@ def test_prepare_dependency_archive_docker_build_failure(
214214
]
215215

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

219219
# Verify docker images command was called
220220
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -257,7 +257,7 @@ def test_prepare_dependency_archive_docker_run_failure(
257257
]
258258

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

262262
# Verify docker images command was called
263263
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
@@ -300,14 +300,150 @@ def test_prepare_dependency_archive_file_copy_failure(
300300
mock_copy.side_effect = FileNotFoundError("File not found")
301301

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

305305
# Verify docker images command was called
306306
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
307307

308308
# Verify files were attempted to be copied
309309
mock_copy.assert_any_call("requirements.txt", "/tmp/test_dir")
310310

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", env=ANY)
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+
"""
413+
Test prepare_dependency_archive with function type when py-files is missing.
414+
Should log and continue without error.
415+
"""
416+
# Mock the temporary directory context manager
417+
mock_temp_dir_instance = MagicMock()
418+
mock_temp_dir_instance.__enter__.return_value = "/tmp/test_dir"
419+
mock_temp_dir_instance.__exit__.return_value = None
420+
mock_temp_dir.return_value = mock_temp_dir_instance
421+
422+
# Mock cmd_output to return image ID (indicating image exists)
423+
mock_cmd_output.return_value = "abc123"
424+
425+
# Mock os.path.join for py-files path
426+
def join_side_effect(*args):
427+
if args == ("/tmp/test_dir", "py-files"):
428+
return "/tmp/test_dir/py-files"
429+
return "/".join(args)
430+
431+
mock_join.side_effect = join_side_effect
432+
433+
# Mock os.path.exists to return False for py-files (doesn't exist)
434+
mock_exists.return_value = False
435+
436+
# Mock the docker command functions
437+
mock_docker_build_cmd.return_value = "mock build command"
438+
mock_docker_run_cmd.return_value = "mock run command"
439+
440+
# Should complete successfully without raising an error
441+
prepare_dependency_archive("/test/dir", "default", "function")
442+
443+
# Verify docker commands were called
444+
mock_cmd_output.assert_any_call(self.EXPECTED_DOCKER_IMAGES_CMD)
445+
mock_docker_run_cmd.assert_called_once_with("default", "/tmp/test_dir")
446+
311447

312448
class TestHasNonemptyRequirementsFile:
313449
@patch("datacustomcode.deploy.os.path.dirname")
@@ -609,10 +745,10 @@ def test_zip_with_requirements(
609745
("/test/dir/subdir", [], ["file3.py"]),
610746
]
611747

612-
zip("/test/dir", "default")
748+
zip("/test/dir", "default", "script")
613749

614750
mock_has_requirements.assert_called_once_with("/test/dir")
615-
mock_prepare.assert_called_once_with("/test/dir", "default")
751+
mock_prepare.assert_called_once_with("/test/dir", "default", "script")
616752
mock_zipfile.assert_called_once_with(
617753
"deployment.zip", "w", zipfile.ZIP_DEFLATED
618754
)
@@ -637,7 +773,7 @@ def test_zip_without_requirements(
637773
("/test/dir/subdir", [], ["file3.py"]),
638774
]
639775

640-
zip("/test/dir", "default")
776+
zip("/test/dir", "default", "script")
641777

642778
mock_has_requirements.assert_called_once_with("/test/dir")
643779
mock_prepare.assert_not_called()
@@ -646,6 +782,38 @@ def test_zip_without_requirements(
646782
)
647783
assert mock_zipfile_instance.write.call_count == 3 # One call per file
648784

785+
@patch("datacustomcode.deploy.has_nonempty_requirements_file")
786+
@patch("datacustomcode.deploy.prepare_dependency_archive")
787+
@patch("zipfile.ZipFile")
788+
@patch("os.walk")
789+
def test_zip_with_function_package_type(
790+
self,
791+
mock_walk,
792+
mock_zipfile,
793+
mock_prepare,
794+
mock_has_requirements,
795+
):
796+
"""Test zipping a directory with function package type."""
797+
mock_has_requirements.return_value = True
798+
mock_zipfile_instance = MagicMock()
799+
mock_zipfile.return_value.__enter__.return_value = mock_zipfile_instance
800+
mock_zipfile_instance.write = MagicMock()
801+
802+
# Mock os.walk to return some test files
803+
mock_walk.return_value = [
804+
("/test/dir", ["subdir"], ["file1.py", "file2.py"]),
805+
("/test/dir/subdir", [], ["file3.py"]),
806+
]
807+
808+
zip("/test/dir", "default", "function")
809+
810+
mock_has_requirements.assert_called_once_with("/test/dir")
811+
mock_prepare.assert_called_once_with("/test/dir", "default", "function")
812+
mock_zipfile.assert_called_once_with(
813+
"deployment.zip", "w", zipfile.ZIP_DEFLATED
814+
)
815+
assert mock_zipfile_instance.write.call_count == 3 # One call per file
816+
649817

650818
class TestUploadZip:
651819
@patch("datacustomcode.deploy.requests.put")
@@ -934,7 +1102,7 @@ def test_deploy_full(
9341102
mock_retrieve_token.assert_called_once_with(credentials)
9351103
mock_get_config.assert_called_once_with("/test/dir")
9361104
mock_create_deployment.assert_called_once_with(access_token, metadata)
937-
mock_zip.assert_called_once_with("/test/dir", "default")
1105+
mock_zip.assert_called_once_with("/test/dir", "default", "script")
9381106
mock_upload_zip.assert_called_once_with("https://upload.example.com")
9391107
mock_wait.assert_called_once_with(access_token, metadata, callback)
9401108
mock_create_transform.assert_called_once_with(
@@ -998,7 +1166,7 @@ def test_deploy_full_client_credentials(
9981166
mock_retrieve_token.assert_called_once_with(credentials)
9991167
mock_get_config.assert_called_once_with("/test/dir")
10001168
mock_create_deployment.assert_called_once_with(access_token, metadata)
1001-
mock_zip.assert_called_once_with("/test/dir", "default")
1169+
mock_zip.assert_called_once_with("/test/dir", "default", "script")
10021170
mock_upload_zip.assert_called_once_with("https://upload.example.com")
10031171
mock_wait.assert_called_once_with(access_token, metadata, callback)
10041172
mock_create_transform.assert_called_once_with(
@@ -1104,7 +1272,7 @@ def test_deploy_full_happy_path(
11041272
mock_retrieve_token.assert_called_once_with(credentials)
11051273
mock_get_config.assert_called_once_with("/test/dir")
11061274
mock_create_deployment.assert_called_once_with(access_token, metadata)
1107-
mock_zip.assert_called_once_with("/test/dir", "default")
1275+
mock_zip.assert_called_once_with("/test/dir", "default", "script")
11081276
mock_upload_zip.assert_called_once_with("https://upload.example.com")
11091277
mock_wait.assert_called_once_with(access_token, metadata, callback)
11101278
mock_create_transform.assert_called_once_with(

tests/test_scan.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from datacustomcode.scan import (
1111
SDK_CONFIG_DIR,
12+
SDK_CONFIG_FILE,
1213
DataAccessLayerCalls,
1314
dc_config_json_from_file,
1415
scan_file,
@@ -40,7 +41,7 @@ def create_sdk_config(base_directory: str, package_type: str = "script") -> str:
4041
"""
4142
sdk_config = {"type": package_type}
4243
write_sdk_config(base_directory, sdk_config)
43-
return os.path.join(base_directory, SDK_CONFIG_DIR, "config.json")
44+
return os.path.join(base_directory, SDK_CONFIG_DIR, SDK_CONFIG_FILE)
4445

4546

4647
class TestClientMethodVisitor:

0 commit comments

Comments
 (0)