Skip to content

Commit 30b58f3

Browse files
committed
feat: add python option for creating parent packages during copy
1 parent 3e531b3 commit 30b58f3

File tree

8 files changed

+220
-21
lines changed

8 files changed

+220
-21
lines changed

aws_lambda_builders/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def build(
105105
106106
:type options: dict
107107
:param options:
108-
Optional dictionary of options ot pass to build action. **Not supported**.
108+
Optional dictionary of options to pass to build action.
109109
110110
:type executable_search_paths: list
111111
:param executable_search_paths:

aws_lambda_builders/workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def __init__(
227227
optimizations : dict, optional
228228
dictionary of optimization flags to pass to the build action. **Not supported**, by default None
229229
options : dict, optional
230-
dictionary of options ot pass to build action. **Not supported**., by default None
230+
dictionary of options to pass to build action. By default None
231231
mode : str, optional
232232
Mode the build should produce, by default BuildMode.RELEASE
233233
download_dependencies: bool, optional

aws_lambda_builders/workflows/python_pip/DESIGN.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,47 @@ bundle has an `__init__.py` and is on the `PYTHONPATH`.
147147
The dependencies should now be succesfully installed in the target directory.
148148
All the temporary/intermediate files can now be deleting including all the
149149
wheel files and sdists.
150+
151+
### Configuring the builder
152+
153+
The Lambda builder supports the following optional sub-properties of the `aws_sam` configuration property.
154+
155+
#### `parent_python_packages`
156+
157+
`parent_python_packages` must be a string, corresponding to a dot-separated list of parent packages to create in the destination directory. This is useful when the source code has a package structure that needs to be preserved in the built artifacts.
158+
159+
For example, if your source code is structured like this:
160+
161+
```
162+
├── app
163+
| ├── main.py
164+
| └── utils
165+
| └── logger.py
166+
└── tests
167+
└── unit/test_handler.py
168+
```
169+
170+
Then the SAM build output will look like this:
171+
172+
```
173+
└── .aws-sam
174+
└── build
175+
└── AppLogicalId
176+
├── main.py
177+
└── utils
178+
└── logger.py
179+
```
180+
181+
The tests would break because `main.py` is importing `logger` as `from .utils import logger`, but the built artifacts would not have the `app` package.
182+
183+
To fix this, you can set the `parent_python_packages` property to `app` and the SAM build output will look like this:
184+
185+
```
186+
└── .aws-sam
187+
└── build
188+
└── AppLogicalId
189+
└── app
190+
├── main.py
191+
└── utils
192+
└── logger.py
193+
```

aws_lambda_builders/workflows/python_pip/actions.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import logging
6+
from pathlib import Path
67
from typing import Optional, Tuple
78

89
from aws_lambda_builders.actions import ActionFailedError, BaseAction, Purpose
@@ -21,6 +22,8 @@
2122

2223
LOG = logging.getLogger(__name__)
2324

25+
PARENT_PYTHON_PKGS_KEY = "parent_python_packages"
26+
2427

2528
class PythonPipBuildAction(BaseAction):
2629
NAME = "ResolveDependencies"
@@ -115,3 +118,49 @@ def _find_runtime_with_pip(self) -> Tuple[SubprocessPip, str]:
115118
LOG.debug(f"Python runtime path '{python_path}' does not contain pip")
116119

117120
raise ActionFailedError("Failed to find a Python runtime containing pip on the PATH.")
121+
122+
123+
class PythonCreateParentPackagesAction(BaseAction):
124+
NAME = "CreateParentPackages"
125+
DESCRIPTION = "Creating parent Python packages"
126+
PURPOSE = Purpose.COPY_SOURCE
127+
LANGUAGE = "python"
128+
129+
def __init__(self, source_dir, dest_dir, options=None):
130+
self.source_dir = source_dir
131+
self.dest_dir = dest_dir
132+
self.options = options or {}
133+
134+
def execute(self):
135+
pkg_parts = self._get_parent_python_packages()
136+
if not pkg_parts:
137+
return
138+
139+
source_path = Path(self.source_dir)
140+
dest_path = Path(self.dest_dir)
141+
142+
target_pkg_path = dest_path.joinpath(*pkg_parts)
143+
try:
144+
target_pkg_path.mkdir(parents=True, exist_ok=False)
145+
except FileExistsError:
146+
LOG.warning("Skipping creating package %s as it would overwrite existing folder", target_pkg_path)
147+
return
148+
149+
for item in source_path.glob(pattern="*"):
150+
if (dest_path / item.name).exists():
151+
(dest_path / item.name).rename(target_pkg_path / item.name)
152+
else:
153+
LOG.debug(f"{item} does not exist in the build path, skipping.")
154+
155+
def _get_parent_python_packages(self) -> Optional[list]:
156+
"""
157+
Returns the parent Python packages to be created.
158+
"""
159+
if not isinstance(self.options, dict):
160+
return None
161+
162+
parent_python_pkgs = self.options.get(PARENT_PYTHON_PKGS_KEY)
163+
if isinstance(parent_python_pkgs, str):
164+
return parent_python_pkgs.split(".")
165+
166+
return None

aws_lambda_builders/workflows/python_pip/workflow.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from aws_lambda_builders.workflow import BaseWorkflow, BuildDirectory, BuildInSourceSupport, Capability
1010
from aws_lambda_builders.workflows.python_pip.validator import PythonRuntimeValidator
1111

12-
from .actions import PythonPipBuildAction
12+
from .actions import PARENT_PYTHON_PKGS_KEY, PythonCreateParentPackagesAction, PythonPipBuildAction
1313
from .utils import OSUtils, is_experimental_build_improvements_enabled
1414

1515
LOG = logging.getLogger(__name__)
@@ -87,15 +87,15 @@ def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtim
8787
self.actions = []
8888
if not osutils.file_exists(manifest_path):
8989
LOG.warning("requirements.txt file not found. Continuing the build without dependencies.")
90-
self.actions.append(CopySourceAction(source_dir, artifacts_dir, excludes=self.EXCLUDED_FILES))
90+
self._actions.append(CopySourceAction(source_dir, artifacts_dir, excludes=self.EXCLUDED_FILES))
9191
return
9292

9393
# If a requirements.txt exists, run pip builder before copy action.
9494
if self.download_dependencies:
9595
if self.dependencies_dir:
9696
# clean up the dependencies folder before installing
97-
self.actions.append(CleanUpAction(self.dependencies_dir))
98-
self.actions.append(
97+
self._actions.append(CleanUpAction(self.dependencies_dir))
98+
self._actions.append(
9999
PythonPipBuildAction(
100100
artifacts_dir,
101101
scratch_dir,
@@ -113,11 +113,21 @@ def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtim
113113
# when copying downloaded dependencies back to artifacts folder, don't exclude anything
114114
# symlinking python dependencies is disabled for now since it is breaking sam local commands
115115
if False and is_experimental_build_improvements_enabled(self.experimental_flags):
116-
self.actions.append(LinkSourceAction(self.dependencies_dir, artifacts_dir))
116+
self._actions.append(LinkSourceAction(self.dependencies_dir, artifacts_dir))
117117
else:
118-
self.actions.append(CopySourceAction(self.dependencies_dir, artifacts_dir))
118+
self._actions.append(CopySourceAction(self.dependencies_dir, artifacts_dir))
119119

120-
self.actions.append(CopySourceAction(source_dir, artifacts_dir, excludes=self.EXCLUDED_FILES))
120+
self._actions.append(CopySourceAction(source_dir, artifacts_dir, excludes=self.EXCLUDED_FILES))
121+
122+
@property
123+
def actions(self):
124+
if self._should_create_parent_packages():
125+
return self._actions + [PythonCreateParentPackagesAction(self.source_dir, self.artifacts_dir, self.options)]
126+
return self._actions
127+
128+
@actions.setter
129+
def actions(self, value):
130+
self._actions = value
121131

122132
def get_resolvers(self):
123133
"""
@@ -138,5 +148,13 @@ def _get_additional_binaries(self):
138148
major, _ = self.runtime.replace(self.CAPABILITY.language, "").split(".")
139149
return [f"{self.CAPABILITY.language}{major}"] if major == self.PYTHON_VERSION_THREE else None
140150

151+
def _should_create_parent_packages(self):
152+
"""
153+
Determines if the parent packages should be created based on the options provided.
154+
"""
155+
if isinstance(self.options, dict):
156+
return PARENT_PYTHON_PKGS_KEY in self.options
157+
return False
158+
141159
def get_validators(self):
142160
return [PythonRuntimeValidator(runtime=self.runtime, architecture=self.architecture)]

tests/integration/workflows/python_pip/test_python_pip.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1+
import logging
12
import os
23
import pathlib
4+
import platform
35
import shutil
46
import sys
5-
import platform
67
import tempfile
7-
from unittest import TestCase, skipIf, mock
8+
from unittest import TestCase, mock, skipIf
89

910
from parameterized import parameterized_class
1011

1112
from aws_lambda_builders.builder import LambdaBuilder
1213
from aws_lambda_builders.exceptions import WorkflowFailedError
13-
import logging
14-
1514
from aws_lambda_builders.utils import which
1615
from aws_lambda_builders.workflows.python_pip.utils import EXPERIMENTAL_FLAG_BUILD_PERFORMANCE
1716

@@ -432,3 +431,48 @@ def test_without_combine_dependencies(self):
432431
dependencies_files = set(os.listdir(self.dependencies_dir))
433432
for f in expected_dependencies_files:
434433
self.assertIn(f, dependencies_files)
434+
435+
def test_must_copy_with_parent_packages_without_manifest(self):
436+
parent_package = "testdata"
437+
self.builder.build(
438+
self.source_dir,
439+
self.artifacts_dir,
440+
self.scratch_dir,
441+
os.path.join("non", "existent", "manifest"),
442+
runtime=self.runtime,
443+
experimental_flags=self.experimental_flags,
444+
options={"parent_python_packages": parent_package},
445+
)
446+
self.assertEqual(set([parent_package]), set(os.listdir(self.artifacts_dir)))
447+
expected_files = self.test_data_files
448+
output_files = set(os.listdir(pathlib.Path(self.artifacts_dir) / parent_package))
449+
self.assertEqual(expected_files, output_files)
450+
451+
def test_must_copy_with_parent_packages_with_manifest(self):
452+
parent_package = "testdata"
453+
self.builder.build(
454+
self.source_dir,
455+
self.artifacts_dir,
456+
self.scratch_dir,
457+
self.manifest_path_valid,
458+
runtime=self.runtime,
459+
experimental_flags=self.experimental_flags,
460+
options={"parent_python_packages": parent_package},
461+
)
462+
463+
if self.runtime in ("python3.12", "python3.13"):
464+
self.check_architecture_in("numpy-2.1.2.dist-info", ["manylinux2014_x86_64", "manylinux1_x86_64"])
465+
expected_dependencies = {"numpy", "numpy-2.1.2.dist-info", "numpy.libs"}
466+
elif self.runtime in ("python3.10", "python3.11"):
467+
self.check_architecture_in("numpy-1.23.5.dist-info", ["manylinux2014_x86_64", "manylinux1_x86_64"])
468+
expected_dependencies = {"numpy", "numpy-1.23.5.dist-info", "numpy.libs"}
469+
else:
470+
self.check_architecture_in("numpy-1.20.3.dist-info", ["manylinux2010_x86_64", "manylinux1_x86_64"])
471+
expected_dependencies = {"numpy", "numpy-1.20.3.dist-info", "numpy.libs"}
472+
473+
output_files: set[str] = set(os.listdir(self.artifacts_dir))
474+
self.assertEqual(set([parent_package, *expected_dependencies]), output_files)
475+
476+
expected_files = self.test_data_files
477+
output_files = set(os.listdir(pathlib.Path(self.artifacts_dir) / parent_package))
478+
self.assertEqual(expected_files, output_files)

tests/unit/test_builder.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import itertools
22
from unittest import TestCase
3-
from unittest.mock import patch, call, Mock
3+
from unittest.mock import Mock, call, patch
44

55
from parameterized import parameterized
66

77
from aws_lambda_builders.builder import LambdaBuilder
8-
from aws_lambda_builders.workflow import BuildDirectory, BuildInSourceSupport, Capability, BaseWorkflow
98
from aws_lambda_builders.registry import DEFAULT_REGISTRY
9+
from aws_lambda_builders.workflow import BaseWorkflow, BuildDirectory, BuildInSourceSupport, Capability
1010

1111

1212
class TesetLambdaBuilder_init(TestCase):
@@ -193,6 +193,6 @@ def test_with_mocks(
193193
workflow_instance.run.assert_called_once()
194194
os_mock.path.exists.assert_called_once_with("scratch_dir")
195195
if scratch_dir_exists:
196-
os_mock.makedirs.not_called()
196+
os_mock.makedirs.assert_not_called()
197197
else:
198198
os_mock.makedirs.assert_called_once_with("scratch_dir")

tests/unit/workflows/python_pip/test_actions.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import sys
2-
32
from unittest import TestCase
4-
from unittest.mock import patch, Mock, ANY
3+
from unittest.mock import ANY, MagicMock, Mock, patch
54

65
from aws_lambda_builders.actions import ActionFailedError
76
from aws_lambda_builders.architecture import ARM64, X86_64
87
from aws_lambda_builders.binary_path import BinaryPath
9-
10-
from aws_lambda_builders.workflows.python_pip.actions import PythonPipBuildAction
8+
from aws_lambda_builders.workflows.python_pip.actions import (
9+
PARENT_PYTHON_PKGS_KEY,
10+
PythonCreateParentPackagesAction,
11+
PythonPipBuildAction,
12+
)
1113
from aws_lambda_builders.workflows.python_pip.exceptions import MissingPipError
12-
from aws_lambda_builders.workflows.python_pip.packager import PackagerError, SubprocessPip
14+
from aws_lambda_builders.workflows.python_pip.packager import PackagerError
1315

1416

1517
class TestPythonPipBuildAction(TestCase):
@@ -185,3 +187,45 @@ def test_find_runtime_no_python_matches(self):
185187
PythonPipBuildAction(Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries)._find_runtime_with_pip()
186188

187189
self.assertEqual(str(ex.exception), "Failed to find a Python runtime containing pip on the PATH.")
190+
191+
192+
class TestPythonCreateParentPackagesAction(TestCase):
193+
def setUp(self):
194+
self.source = "source"
195+
self.dest = "dest"
196+
197+
self.mock_source_dir = MagicMock(name="source_dir")
198+
self.mock_source_file = MagicMock(name="source_file")
199+
self.mock_dest_dir = MagicMock(name="dest_dir")
200+
self.mock_dest_file = MagicMock(name="dest_file")
201+
self.target_dir = MagicMock(name="target_dir")
202+
203+
self.mock_dest_dir.joinpath.return_value = self.target_dir
204+
self.mock_source_dir.glob.return_value = [self.mock_source_file]
205+
self.mock_dest_dir.__truediv__.return_value = self.mock_dest_file
206+
207+
def mock_source_dest(self, mock_path):
208+
mock_path.side_effect = lambda x: self.mock_source_dir if x == self.source else self.mock_dest_dir
209+
210+
@patch("aws_lambda_builders.workflows.python_pip.actions.Path")
211+
def test_skips_bad_config(self, mock_path):
212+
self.mock_source_dest(mock_path)
213+
action = PythonCreateParentPackagesAction(self.source, self.dest, options="not_a_dict")
214+
215+
action.execute()
216+
217+
self.mock_dest_file.rename.assert_not_called()
218+
219+
@patch("aws_lambda_builders.workflows.python_pip.actions.Path")
220+
def test_creates_parent_packages(self, mock_path):
221+
self.mock_source_dest(mock_path)
222+
223+
self.mock_dest_file.exists.return_value = True
224+
225+
action = PythonCreateParentPackagesAction(
226+
self.source, self.dest, options={PARENT_PYTHON_PKGS_KEY: "foo.bar.baz"}
227+
)
228+
229+
action.execute()
230+
231+
self.mock_dest_file.rename.assert_called_once_with(self.target_dir / "baz")

0 commit comments

Comments
 (0)