Skip to content

Commit 23d314c

Browse files
committed
Add PyFunctionAsset and PyFlaskFunctionAsset
They will allow to package the code of a PyFunction and PyFlaskFunction as an asset. This becomes the default in place of always packaging the code in a new zip and uploading it with a time based S3 key
1 parent c2c40bb commit 23d314c

21 files changed

+348
-157
lines changed

src/e3/aws/mock/__init__.py

Whitespace-only changes.

src/e3/aws/mock/troposphere/__init__.py

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
from typing import TYPE_CHECKING
3+
from unittest.mock import patch
4+
from contextlib import contextmanager
5+
6+
if TYPE_CHECKING:
7+
from typing import Any
8+
from collections.abc import Iterator
9+
10+
from e3.aws.troposphere.awslambda import PyFunctionAsset
11+
12+
13+
@contextmanager
14+
def mock_pyfunctionasset() -> Iterator[None]:
15+
"""Mock PyFunctionAsset.
16+
17+
PyFunctionAsset does a pip install and packaging of source files that
18+
may be necessary to disable in some tests. With this mock, the checksum
19+
"dummychecksum" is assigned to assets instead.
20+
"""
21+
22+
def mock_create_assets_dir(self: PyFunctionAsset, *args: Any, **kargs: Any) -> Any:
23+
"""Disable create_assets_dir and assign a dummy checksum."""
24+
self.checksum = "dummychecksum"
25+
26+
with patch(
27+
"e3.aws.troposphere.awslambda.PyFunctionAsset.create_assets_dir",
28+
mock_create_assets_dir,
29+
):
30+
yield

src/e3/aws/troposphere/awslambda/__init__.py

Lines changed: 189 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import os
66
import sys
77
from typing import TYPE_CHECKING
8+
from zipfile import ZipFile
9+
from hashlib import sha256
810

11+
from e3.fingerprint import Fingerprint
912
from e3.archive import create_archive
10-
from e3.fs import sync_tree, rm
13+
from e3.fs import sync_tree, rm, mv
1114
from e3.os.process import Run
1215
from troposphere import awslambda, logs, GetAtt, Ref, Sub
1316

@@ -16,16 +19,178 @@
1619
from e3.aws.troposphere.iam.policy_document import PolicyDocument
1720
from e3.aws.troposphere.iam.policy_statement import PolicyStatement
1821
from e3.aws.troposphere.iam.role import Role
22+
from e3.aws.troposphere.asset import Asset
1923
from e3.aws.util.ecr import build_and_push_image
2024

2125
if TYPE_CHECKING:
22-
from typing import Any
26+
from typing import Any, Callable
2327
from troposphere import AWSObject
2428
from e3.aws.troposphere import Stack
2529

2630
logger = logging.getLogger("e3.aws.troposphere.awslambda")
2731

2832

33+
def package_pyfunction_code(
34+
filename: str,
35+
/,
36+
package_dir: str,
37+
root_dir: str,
38+
populate_package_dir: Callable[[str], None],
39+
runtime: str | None = None,
40+
requirement_file: str | None = None,
41+
) -> None:
42+
"""Package user code with dependencies.
43+
44+
:param filename: name of the archive
45+
:param package_dir: temporary packaging directory
46+
:param root_dir: destination directory for the archive
47+
:param populate_package_dir: callback to populate the package directory with
48+
extra code
49+
:param runtime: the Python runtime
50+
:param requirement_file: the list of Python dependencies
51+
"""
52+
# Install the requirements
53+
if requirement_file is not None:
54+
assert runtime is not None
55+
runtime_config = PyFunction.RUNTIME_CONFIGS[runtime]
56+
p = Run(
57+
[
58+
sys.executable,
59+
"-m",
60+
"pip",
61+
"install",
62+
f"--python-version={runtime.lstrip('python')}",
63+
*(f"--platform={platform}" for platform in runtime_config["platforms"]),
64+
f"--implementation={runtime_config['implementation']}",
65+
"--only-binary=:all:",
66+
f"--target={package_dir}",
67+
"-r",
68+
requirement_file,
69+
],
70+
output=None,
71+
)
72+
assert p.status == 0
73+
74+
# Populate the package directory with extra code
75+
if populate_package_dir is not None:
76+
populate_package_dir(package_dir)
77+
78+
# Create an archive
79+
create_archive(
80+
filename,
81+
from_dir=package_dir,
82+
dest=root_dir,
83+
no_root_dir=True,
84+
)
85+
86+
# Remove the temporary directory
87+
rm(package_dir, recursive=True)
88+
89+
90+
class ChecksumNotComputedError(Exception):
91+
"""Error raised when PyFunctionAsset.checksum was not computed."""
92+
93+
94+
class PyFunctionAsset(Asset):
95+
"""PyFunction code packaged with dependencies."""
96+
97+
def __init__(
98+
self,
99+
name: str,
100+
*,
101+
code_dir: str,
102+
runtime: str,
103+
requirement_file: str | None = None,
104+
) -> None:
105+
"""Initialize PyFunctionAsset.
106+
107+
:param name: name of the archive
108+
:param code_dir: directory that contains the Python code
109+
:param runtime: the Python runtime
110+
:param requirement_file: the list of Python dependencies
111+
"""
112+
self.name = name
113+
self.code_dir = code_dir
114+
self.runtime = runtime
115+
self.requirement_file = requirement_file
116+
self.checksum: str | None = None
117+
118+
@property
119+
def s3_key(self) -> str:
120+
"""Return a unique S3 key with the checksum of the package."""
121+
if self.checksum is None:
122+
raise ChecksumNotComputedError(
123+
"no checksum, was the asset added to the stack?"
124+
)
125+
126+
return f"{self.name}/{self.name}_{self.checksum}.zip"
127+
128+
def populate_package_dir(self, package_dir: str) -> None:
129+
"""Copy user code into package directory.
130+
131+
:param package_dir: directory in which the package content is put
132+
"""
133+
# Add lambda code
134+
sync_tree(self.code_dir, package_dir, delete=False)
135+
136+
def compute_checksum(self, archive_path: str) -> str:
137+
"""Compute the checksum of the archive.
138+
139+
All .pyc files are excluded as they are not reproducible.
140+
141+
:param archive_path: path of the archive
142+
:return: the checksum
143+
"""
144+
fingerprint = Fingerprint()
145+
with ZipFile(archive_path) as zip:
146+
for zip_info in zip.infolist():
147+
if zip_info.is_dir():
148+
fingerprint.add(zip_info.filename, "")
149+
elif not zip_info.filename.endswith(".pyc"):
150+
with zip.open(zip_info) as f:
151+
hash = sha256()
152+
hash.update(f.read())
153+
fingerprint.add(zip_info.filename, hash.hexdigest())
154+
155+
return fingerprint.checksum()
156+
157+
def create_assets_dir(self, root_dir: str) -> None:
158+
"""Populate the assets dir.
159+
160+
:param root_dir: directory where to put assets
161+
"""
162+
# Stop if code already packaged and checksum already computed.
163+
# This allows to possibly add the same asset multiple times to the stack
164+
# without risking to package it each time
165+
if self.checksum is not None:
166+
return
167+
168+
# Directory where the archive is generated
169+
archive_dir = os.path.join(root_dir, self.name)
170+
171+
# Create a temporary packaging directory
172+
package_dir = os.path.join(archive_dir, "package")
173+
174+
# Package the code with dependencies
175+
archive_name = f"{self.name}.zip"
176+
package_pyfunction_code(
177+
archive_name,
178+
package_dir=package_dir,
179+
root_dir=archive_dir,
180+
populate_package_dir=self.populate_package_dir,
181+
runtime=self.runtime,
182+
requirement_file=self.requirement_file,
183+
)
184+
185+
archive_path = os.path.abspath(os.path.join(archive_dir, archive_name))
186+
self.checksum = self.compute_checksum(archive_path)
187+
188+
# Rename the archive with the checksum
189+
checksum_archive_name = f"{self.name}_{self.checksum}.zip"
190+
checksum_archive_path = os.path.join(archive_dir, checksum_archive_name)
191+
mv(archive_path, checksum_archive_path)
192+
193+
29194
class Function(Construct):
30195
"""A lambda function."""
31196

@@ -392,9 +557,10 @@ def __init__(
392557
name: str,
393558
description: str,
394559
role: str | GetAtt | Role,
395-
code_dir: str,
396560
handler: str,
397561
runtime: str,
562+
code_asset: Asset | None = None,
563+
code_dir: str | None = None,
398564
requirement_file: str | None = None,
399565
code_version: int | None = None,
400566
timeout: int = 3,
@@ -412,9 +578,10 @@ def __init__(
412578
:param name: function name
413579
:param description: a description of the function
414580
:param role: role to be asssumed during lambda execution
415-
:param code_dir: directory containing the python code
416581
:param handler: name of the function to be invoked on lambda execution
417582
:param runtime: lambda runtime. It must be a Python runtime.
583+
:param code_asset: asset containing the python code
584+
:param code_dir: directory containing the python code
418585
:param requirement_file: requirement file for the application code.
419586
Required packages are automatically fetched (works only from linux)
420587
and packaged along with the lambda code
@@ -462,65 +629,31 @@ def __init__(
462629
self.code_dir = code_dir
463630
self.requirement_file = requirement_file
464631

632+
if code_asset is not None:
633+
self.code_asset = code_asset
634+
else:
635+
assert (
636+
code_dir is not None
637+
), "code_dir must be provided when code_asset is None"
638+
639+
self.code_asset = PyFunctionAsset(
640+
name=name_to_id(f"{name}Sources"),
641+
code_dir=code_dir,
642+
runtime=runtime,
643+
requirement_file=requirement_file,
644+
)
645+
465646
def resources(self, stack: Stack) -> list[AWSObject]:
466647
"""Compute AWS resources for the construct."""
467648
assert isinstance(stack.s3_bucket, str)
468649
return self.lambda_resources(
469650
code_bucket=stack.s3_bucket,
470-
code_key=f"{stack.s3_key}{self.name}_lambda.zip",
471-
)
472-
473-
def populate_package_dir(self, package_dir: str) -> None:
474-
"""Copy user code into lambda package directory.
475-
476-
:param package_dir: directory in which the package content is put
477-
"""
478-
# Add lambda code
479-
sync_tree(self.code_dir, package_dir, delete=False)
480-
481-
def create_data_dir(self, root_dir: str) -> None:
482-
"""Create data to be pushed to bucket used by cloudformation for resources."""
483-
# Create directory specific to that lambda
484-
package_dir = os.path.join(root_dir, name_to_id(self.name), "package")
485-
486-
# Install the requirements
487-
if self.requirement_file is not None:
488-
assert self.runtime is not None
489-
runtime_config = PyFunction.RUNTIME_CONFIGS[self.runtime]
490-
p = Run(
491-
[
492-
sys.executable,
493-
"-m",
494-
"pip",
495-
"install",
496-
f"--python-version={self.runtime.lstrip('python')}",
497-
*(
498-
f"--platform={platform}"
499-
for platform in runtime_config["platforms"]
500-
),
501-
f"--implementation={runtime_config['implementation']}",
502-
"--only-binary=:all:",
503-
f"--target={package_dir}",
504-
"-r",
505-
self.requirement_file,
506-
],
507-
output=None,
508-
)
509-
assert p.status == 0
510-
511-
# Copy user code
512-
self.populate_package_dir(package_dir=package_dir)
513-
514-
# Create an archive
515-
create_archive(
516-
f"{self.name}_lambda.zip",
517-
from_dir=package_dir,
518-
dest=root_dir,
519-
no_root_dir=True,
651+
code_key=f"{stack.s3_assets_key}{self.code_asset.s3_key}",
520652
)
521653

522-
# Remove temporary directory
523-
rm(package_dir, recursive=True)
654+
def create_assets_dir(self, root_dir: str) -> None:
655+
"""Create assets to be pushed to bucket used by cloudformation for resources."""
656+
self.code_asset.create_assets_dir(root_dir=root_dir)
524657

525658

526659
class Py38Function(PyFunction):

0 commit comments

Comments
 (0)