Skip to content

Commit e319d46

Browse files
authored
feat: add python option for creating parent packages during copy (aws#8248)
1 parent cd6ec2b commit e319d46

File tree

11 files changed

+277
-30
lines changed

11 files changed

+277
-30
lines changed

samcli/lib/build/app_builder.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,17 @@ def _get_build_options(
829829
if language == "rust" and "Binary" in build_props:
830830
options = options if options else {}
831831
options["artifact_executable_name"] = build_props["Binary"]
832+
833+
if language == "python" and "ParentPackageMode" in build_props:
834+
options = options if options else {}
835+
package_root_mode = build_props["ParentPackageMode"]
836+
if package_root_mode == "auto":
837+
options["parent_python_packages"] = ApplicationBuilder._infer_parent_python_packages(
838+
handler, source_code_path
839+
)
840+
elif package_root_mode == "explicit":
841+
options["parent_python_packages"] = build_props.get("ParentPackages", None)
842+
832843
return options
833844

834845
@staticmethod
@@ -1045,3 +1056,51 @@ def _parse_builder_response(stdout_data: str, image_name: str) -> Dict:
10451056
raise ValueError(msg)
10461057

10471058
return cast(Dict, response)
1059+
1060+
@staticmethod
1061+
def _infer_parent_python_packages(handler: Optional[str], source_code_path: Optional[str]) -> Optional[str]:
1062+
"""
1063+
Infers the parent python packages from the handler and source code path. The parent packages are the
1064+
packages are the union of the handler's parent packages and the source code path's parent packages.
1065+
1066+
Parameters
1067+
----------
1068+
handler: str
1069+
The handler value of the function
1070+
source_code_path: str
1071+
The directory path to the source code of the function
1072+
Returns
1073+
-------
1074+
str
1075+
1076+
"""
1077+
MODULE_PART_COUNT = 2 # file name and function name
1078+
if not handler or not source_code_path:
1079+
LOG.warning(
1080+
"Both function Handler and CodeUri must be provided when using PackageRootMode 'auto'."
1081+
+ "Continuing without parent packages."
1082+
)
1083+
return None
1084+
if handler.count(".") < MODULE_PART_COUNT:
1085+
# Handler does not have any parent packages
1086+
LOG.warning("Handler '%s' does not have any parent python packages", handler)
1087+
return None
1088+
1089+
handler_parts = handler.split(".")[0:-MODULE_PART_COUNT]
1090+
code_path_parts = list(pathlib.Path(source_code_path).parts)
1091+
1092+
# Remove parts from the start of the path until we find the first part of the handler
1093+
while len(code_path_parts) > 0 and code_path_parts[0] != handler_parts[0]:
1094+
code_path_parts.pop(0)
1095+
1096+
if len(code_path_parts) > 0:
1097+
parent_packages = ".".join(code_path_parts[0 : len(handler_parts)])
1098+
LOG.debug("Inferred parent python packages '%s'", parent_packages)
1099+
return parent_packages
1100+
LOG.warning(
1101+
"Could not infer parent python packages from Handler '%s' and CodeUri '%s'."
1102+
+ "Continuing without parent packages.",
1103+
handler,
1104+
source_code_path,
1105+
)
1106+
return None

tests/integration/buildcmd/test_build_cmd_python.py

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
import logging
2+
import pathlib
23
from typing import Set
34
from unittest import skipIf
45
from uuid import uuid4
56

67
import pytest
78
from parameterized import parameterized, parameterized_class
89

10+
from tests.integration.buildcmd.build_integ_base import (
11+
BuildIntegBase,
12+
BuildIntegPythonBase,
13+
)
914
from tests.testing_utils import (
15+
CI_OVERRIDE,
1016
IS_WINDOWS,
17+
RUN_BY_CANARY,
1118
RUNNING_ON_CI,
1219
RUNNING_TEST_FOR_MASTER_ON_CI,
13-
RUN_BY_CANARY,
14-
CI_OVERRIDE,
15-
run_command,
16-
SKIP_DOCKER_TESTS,
1720
SKIP_DOCKER_BUILD,
1821
SKIP_DOCKER_MESSAGE,
22+
SKIP_DOCKER_TESTS,
23+
run_command,
1924
)
20-
from tests.integration.buildcmd.build_integ_base import (
21-
BuildIntegBase,
22-
BuildIntegPythonBase,
23-
)
24-
2525

2626
LOG = logging.getLogger(__name__)
2727

@@ -476,7 +476,6 @@ class TestBuildCommand_PythonFunctions_With_Specified_Architecture(BuildIntegPyt
476476
]
477477
)
478478
def test_with_default_requirements(self, runtime, codeuri, use_container, architecture):
479-
480479
self._test_with_default_requirements(
481480
runtime, codeuri, use_container, self.test_data_path, architecture=architecture
482481
)
@@ -493,7 +492,6 @@ def test_with_default_requirements(self, runtime, codeuri, use_container, archit
493492
)
494493
@pytest.mark.al2023
495494
def test_with_default_requirements_al2023(self, runtime, codeuri, use_container, architecture):
496-
497495
self._test_with_default_requirements(
498496
runtime, codeuri, use_container, self.test_data_path, architecture=architecture
499497
)
@@ -509,6 +507,100 @@ def test_invalid_architecture(self):
509507
self.assertIn("Architecture fake is not supported", str(process_execute.stderr))
510508

511509

510+
class TestBuildCommand_ParentPackages(BuildIntegPythonBase):
511+
template = "template_with_metadata_python.yaml"
512+
runtime = "python3.12"
513+
use_container = False
514+
prop = "CodeUri"
515+
516+
logical_id_one = "FunctionOne"
517+
logical_id_two = "FunctionTwo"
518+
parent_packages_one = "src.fnone"
519+
parent_packages_two = "src.fntwo"
520+
codeuri_one = "PythonParentPackages/src/fnone"
521+
codeuri_two = "PythonParentPackages/src/fntwo"
522+
handler_one = f"{parent_packages_one}.main.handler"
523+
handler_two = f"{parent_packages_two}.main.handler"
524+
525+
overrides = {
526+
"Runtime": runtime,
527+
"CodeUriOne": codeuri_one,
528+
"CodeUriTwo": codeuri_two,
529+
"HandlerOne": handler_one,
530+
"HandlerTwo": handler_two,
531+
}
532+
533+
expected_files_project_manifest = {"numpy", "src"}
534+
expected_source_files = {
535+
"__init__.py",
536+
"main.py",
537+
"requirements.txt",
538+
}
539+
540+
def test_parent_package_mode_explicit(self):
541+
# Arrange
542+
cmdlist = self.get_command_list(
543+
use_container=self.use_container,
544+
parameter_overrides={
545+
**self.overrides,
546+
"ParentPackageMode": "explicit",
547+
"ParentPackagesOne": self.parent_packages_one,
548+
"ParentPackagesTwo": self.parent_packages_two,
549+
},
550+
)
551+
552+
# Act
553+
run_command(cmdlist, cwd=self.working_dir)
554+
555+
# Assert
556+
self._verify_built_artifacts()
557+
558+
def test_parent_package_mode_auto(self):
559+
# Arrange
560+
cmdlist = self.get_command_list(
561+
use_container=self.use_container,
562+
parameter_overrides={**self.overrides, "ParentPackageMode": "auto"},
563+
)
564+
565+
# Act
566+
run_command(cmdlist, cwd=self.working_dir)
567+
568+
# Assert
569+
self._verify_built_artifacts()
570+
571+
def _verify_built_artifacts(self):
572+
[
573+
self._verify_built_artifact(self.default_build_dir, logical_id, self.expected_files_project_manifest)
574+
for logical_id in [self.logical_id_one, self.logical_id_two]
575+
]
576+
[
577+
self._verify_source_files(
578+
self.default_build_dir,
579+
self.expected_source_files,
580+
logical_id,
581+
parent_packages,
582+
)
583+
for (logical_id, parent_packages) in [
584+
(self.logical_id_one, self.parent_packages_one),
585+
(self.logical_id_two, self.parent_packages_two),
586+
]
587+
]
588+
589+
def _verify_source_files(
590+
self, build_dir: pathlib.Path, expected_files: set[str], function_logical_id: str, parent_packages: str
591+
) -> None:
592+
relative_code_uri = parent_packages.replace(".", "/")
593+
function_artifact_dir = build_dir / function_logical_id / relative_code_uri
594+
all_artifacts = set(
595+
map(
596+
lambda artifact: str(object=artifact.relative_to(function_artifact_dir)),
597+
function_artifact_dir.glob("*"),
598+
)
599+
)
600+
actual_files = all_artifacts.intersection(expected_files)
601+
self.assertEqual(actual_files, expected_files)
602+
603+
512604
class TestBuildCommand_ErrorCases(BuildIntegBase):
513605
def test_unsupported_runtime(self):
514606
overrides = {"Runtime": "unsupportedpython", "CodeUri": "Python"}

tests/integration/testdata/buildcmd/PythonParentPackages/src/__init__.py

Whitespace-only changes.

tests/integration/testdata/buildcmd/PythonParentPackages/src/fnone/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import numpy
2+
3+
4+
def handler(event, context):
5+
return {"pi": "{0:.2f}".format(numpy.pi)}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# These are some hard packages to build. Using them here helps us verify that building works on various platforms
2+
3+
# NOTE: Fixing to <1.20.3 as numpy1.20.3 started to use a new wheel naming convention (PEP 600)
4+
numpy<1.20.3; python_version <= '3.9'
5+
numpy==2.1.3; python_version >= '3.10'
6+
# `cryptography` has a dependency on `pycparser` which, for some reason doesn't build inside a Docker container.
7+
# Turning this off until we resolve this issue: https://github.com/awslabs/aws-lambda-builders/issues/29
8+
# cryptography~=2.4

tests/integration/testdata/buildcmd/PythonParentPackages/src/fntwo/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import numpy
2+
3+
4+
def handler(event, context):
5+
return {"pi": "{0:.2f}".format(numpy.pi)}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# These are some hard packages to build. Using them here helps us verify that building works on various platforms
2+
3+
# NOTE: Fixing to <1.20.3 as numpy1.20.3 started to use a new wheel naming convention (PEP 600)
4+
numpy<1.20.3; python_version <= '3.9'
5+
numpy==2.1.3; python_version >= '3.10'
6+
# `cryptography` has a dependency on `pycparser` which, for some reason doesn't build inside a Docker container.
7+
# Turning this off until we resolve this issue: https://github.com/awslabs/aws-lambda-builders/issues/29
8+
# cryptography~=2.4
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
AWSTemplateFormatVersion : '2010-09-09'
2+
Transform: AWS::Serverless-2016-10-31
3+
4+
Parameters:
5+
Runtime:
6+
Type: String
7+
CodeUriOne:
8+
Type: String
9+
HandlerOne:
10+
Type: String
11+
CodeUriTwo:
12+
Type: String
13+
HandlerTwo:
14+
Type: String
15+
ParentPackagesOne:
16+
Type: String
17+
Default: ''
18+
ParentPackagesTwo:
19+
Type: String
20+
Default: ''
21+
ParentPackageMode:
22+
Type: String
23+
24+
Resources:
25+
26+
FunctionOne:
27+
Type: AWS::Serverless::Function
28+
Properties:
29+
CodeUri: !Ref CodeUriOne
30+
Handler: !Ref HandlerOne
31+
Runtime: !Ref Runtime
32+
Timeout: 600
33+
Metadata:
34+
BuildProperties:
35+
ParentPackageMode: !Ref ParentPackageMode
36+
ParentPackages: !Ref ParentPackagesOne
37+
38+
FunctionTwo:
39+
Type: AWS::Serverless::Function
40+
Properties:
41+
CodeUri: !Ref CodeUriTwo
42+
Handler: !Ref HandlerTwo
43+
Runtime: !Ref Runtime
44+
Timeout: 600
45+
Metadata:
46+
BuildProperties:
47+
ParentPackageMode: !Ref ParentPackageMode
48+
ParentPackages: !Ref ParentPackagesTwo

0 commit comments

Comments
 (0)