Skip to content

Commit c2a8e20

Browse files
monkutgaborschulzstorkwrangler
authored
(Refresh) Add arm64 architecture support (#1379)
* Add Support for Graviton 2 / ARM Architecture * Changed parameter in README to architecture * Removed redundant blank line * Bugfix + black compliance * Fix: architecture argument not used properly * Fixed black compliance * Fixed architecture label for wheels, added check for invalid architecture * Testing architecture * Added tests for ARM64 architecture * Only allow ARM64 arch if Python version >= 3.8 * Removed redundant references to architecture * Removed redundant references to architecture * Added test for default architecture setting * added .pre-commit hook for black-check and isort-check * Fixed test failure in Python 3.7 * 🔧 re-work regex for abi3 * 🔧 fix Zappa.create_lambda_function to pass a list to 'Architectures' in `create_function()` call. * 🐛 fix typo in json --------- Co-authored-by: DS992 <> Co-authored-by: Gabor Schulz <[email protected]> Co-authored-by: shane <[email protected]>
1 parent 4831479 commit c2a8e20

File tree

5 files changed

+115
-47
lines changed

5 files changed

+115
-47
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,7 @@ to change Zappa's behavior. Use these at your own risk!
897897
"assume_policy": "my_assume_policy.json", // optional, IAM assume policy JSON file
898898
"attach_policy": "my_attach_policy.json", // optional, IAM attach policy JSON file
899899
"apigateway_policy": "my_apigateway_policy.json", // optional, API Gateway resource policy JSON file
900+
"architecture": "x86_64", // optional, Set Lambda Architecture, defaults to x86_64. For Graviton 2 use: arm64
900901
"async_source": "sns", // Source of async tasks. Defaults to "lambda"
901902
"async_resources": true, // Create the SNS topic and DynamoDB table to use. Defaults to true.
902903
"async_response_table": "your_dynamodb_table_name", // the DynamoDB table name to use for captured async responses; defaults to None (can't capture)

test_settings.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,5 +142,19 @@
142142
"delete_local_zip": true,
143143
"binary_support": true,
144144
"additional_text_mimetypes": ["application/custommimetype"]
145-
}
145+
},
146+
"arch_arm64": {
147+
"s3_bucket": "lmbda",
148+
"app_function": "tests.test_app.hello_world",
149+
"delete_local_zip": true,
150+
"debug": true,
151+
"architecture": "arm64"
152+
},
153+
"archfail": {
154+
"s3_bucket": "lmbda",
155+
"app_function": "tests.test_app.hello_world",
156+
"delete_local_zip": true,
157+
"debug": true,
158+
"architecture": "invalid_architecture"
159+
}
146160
}

tests/tests.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,13 @@ def test_get_manylinux_python313(self):
239239

240240
def test_verify_downloaded_manylinux_wheel(self):
241241
z = Zappa(runtime="python3.10")
242-
cached_wheels_dir = os.path.join(tempfile.gettempdir(), "cached_wheels")
243-
expected_wheel_path = os.path.join(
244-
cached_wheels_dir,
245-
"pycryptodome-3.16.0-cp35-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl",
242+
cached_wheels_dir = Path(tempfile.gettempdir()) / "cached_wheels"
243+
expected_wheel_path = (
244+
cached_wheels_dir / "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
246245
)
247246

248247
# check with a known manylinux wheel package
249-
actual_wheel_path = z.get_cached_manylinux_wheel("pycryptodome", "3.16.0")
248+
actual_wheel_path = z.get_cached_manylinux_wheel("pycryptodome", "3.23.0")
250249
self.assertEqual(actual_wheel_path, expected_wheel_path)
251250
os.remove(actual_wheel_path)
252251

@@ -1409,6 +1408,21 @@ def test_load_extended_settings(self):
14091408
zappa_cli.load_settings("test_settings.json")
14101409
self.assertEqual("lmbda", zappa_cli.stage_config["s3_bucket"])
14111410
self.assertEqual(True, zappa_cli.stage_config["touch"])
1411+
self.assertIn("x86_64", zappa_cli.architecture)
1412+
1413+
if sys.version_info.major == 3 and sys.version_info.minor < 8:
1414+
with self.assertRaises(ValueError):
1415+
zappa_cli = ZappaCLI()
1416+
zappa_cli.api_stage = "arch_arm64"
1417+
zappa_cli.load_settings("test_settings.json")
1418+
self.assertIn("arm64", zappa_cli.stage_config["architecture"])
1419+
self.assertIn("arm64", zappa_cli.architecture)
1420+
else:
1421+
zappa_cli = ZappaCLI()
1422+
zappa_cli.api_stage = "arch_arm64"
1423+
zappa_cli.load_settings("test_settings.json")
1424+
self.assertIn("arm64", zappa_cli.stage_config["architecture"])
1425+
self.assertIn("arm64", zappa_cli.architecture)
14121426

14131427
zappa_cli = ZappaCLI()
14141428
zappa_cli.api_stage = "extendofail"
@@ -1427,6 +1441,11 @@ def test_load_extended_settings(self):
14271441
self.assertTrue(zappa_cli.stage_config["touch"]) # First Extension
14281442
self.assertTrue(zappa_cli.stage_config["delete_local_zip"]) # The base
14291443

1444+
zappa_cli = ZappaCLI()
1445+
zappa_cli.api_stage = "archfail"
1446+
with self.assertRaises(ValueError):
1447+
zappa_cli.load_settings("test_settings.json")
1448+
14301449
def test_load_settings__lambda_concurrency_enabled(self):
14311450
zappa_cli = ZappaCLI()
14321451
zappa_cli.api_stage = "lambda_concurrency_enabled"

zappa/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class ZappaCLI:
124124
additional_text_mimetypes = None
125125
tags = [] # type: ignore[var-annotated]
126126
layers = None
127+
architecture = None
127128

128129
stage_name_env_pattern = re.compile("^[a-zA-Z0-9_]+$")
129130

@@ -2261,7 +2262,7 @@ def load_settings(self, settings_file=None, session=None):
22612262
self.dead_letter_config = {"TargetArn": dead_letter_arn} if dead_letter_arn else {}
22622263
self.cognito = self.stage_config.get("cognito", None)
22632264
self.num_retained_versions = self.stage_config.get("num_retained_versions", None)
2264-
2265+
self.architecture = self.stage_config.get("architecture", "x86_64")
22652266
# Check for valid values of num_retained_versions
22662267
if self.num_retained_versions is not None and type(self.num_retained_versions) is not int:
22672268
raise ClickException(
@@ -2331,6 +2332,7 @@ def load_settings(self, settings_file=None, session=None):
23312332
tags=self.tags,
23322333
endpoint_urls=self.stage_config.get("aws_endpoint_urls", {}),
23332334
xray_tracing=self.xray_tracing,
2335+
architecture=self.architecture,
23342336
)
23352337

23362338
for setting in CUSTOM_SETTINGS:

zappa/core.py

Lines changed: 72 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,44 @@
239239
# the Lambda.
240240
# See: https://github.com/Miserlou/Zappa/pull/1730
241241
ALB_LAMBDA_ALIAS = "current-alb-version"
242-
243-
##
244-
# Classes
245-
##
242+
X86_ARCHITECTURE = "x86_64"
243+
ARM_ARCHITECTURE = "arm64"
244+
VALID_ARCHITECTURES = (X86_ARCHITECTURE, ARM_ARCHITECTURE)
245+
246+
247+
def build_manylinux_wheel_file_match_pattern(runtime: str, architecture: str) -> re.Pattern:
248+
# Support PEP600 (https://peps.python.org/pep-0600/)
249+
# The wheel filename is {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
250+
runtime_major_version, runtime_minor_version = runtime[6:].split(".")
251+
python_tag = f"cp{runtime_major_version}{runtime_minor_version}" # python3.13 -> cp313
252+
manylinux_legacy_tags = ("manylinux2014", "manylinux2010", "manylinux1")
253+
if architecture == X86_ARCHITECTURE:
254+
valid_platform_tags = [X86_ARCHITECTURE]
255+
elif architecture == ARM_ARCHITECTURE:
256+
valid_platform_tags = [ARM_ARCHITECTURE, "aarch64"]
257+
else:
258+
raise ValueError(f"Invalid 'architecture', must be one of {VALID_ARCHITECTURES}, got: {architecture}")
259+
260+
manylinux_wheel_file_match = (
261+
rf'^.*{python_tag}-(manylinux_\d+_\d+_({"|".join(valid_platform_tags)})[.])?'
262+
rf'({"|".join(manylinux_legacy_tags)})_({"|".join(valid_platform_tags)})[.]whl$'
263+
)
264+
265+
# The 'abi3' tag is a compiled distribution format designed for compatibility across multiple Python 3 versions.
266+
# An abi3 wheel is built against the stable ABI (Application Binary Interface) of a minimum supported Python version.
267+
# -- make sure cp3XX version is <= to the runtime version (runtime_minor_version)
268+
minimum_minor_version = 5
269+
abi_valid_python_minor_versions = [str(i) for i in range(minimum_minor_version, int(runtime_minor_version) + 1)]
270+
manylinux_suffixes = [r"_\d+_\d+", r"manylinux_\d+_\d+"]
271+
manylinux_suffixes.extend(manylinux_legacy_tags)
272+
manylinux_wheel_abi3_file_match = (
273+
# rf'^.*cp3.-abi3-manylinux({"|".join(manylinux_suffixes)})_({"|".join(valid_platform_tags)}).whl$'
274+
rf'^.*cp3({"|".join(abi_valid_python_minor_versions)})-abi3-'
275+
rf'manylinux(({"|".join(manylinux_suffixes)})_({"".join(valid_platform_tags)})(\.|))+.whl$'
276+
)
277+
combined_match_pattern = rf"({manylinux_wheel_file_match})|({manylinux_wheel_abi3_file_match})"
278+
manylinux_wheel_file_match_pattern = re.compile(combined_match_pattern)
279+
return manylinux_wheel_file_match_pattern
246280

247281

248282
class Zappa:
@@ -263,7 +297,7 @@ class Zappa:
263297
apigateway_policy = None
264298
cloudwatch_log_levels = ["OFF", "ERROR", "INFO"]
265299
xray_tracing = False
266-
300+
architecture = None
267301
##
268302
# Credentials
269303
##
@@ -283,6 +317,7 @@ def __init__(
283317
tags=(),
284318
endpoint_urls={},
285319
xray_tracing=False,
320+
architecture=None,
286321
):
287322
"""
288323
Instantiate this new Zappa instance, loading any custom credentials if necessary.
@@ -304,14 +339,14 @@ def __init__(
304339

305340
self.runtime = runtime
306341

307-
# TODO: Support PEP600 properly (https://peps.python.org/pep-0600/)
308-
self.manylinux_suffix_start = f"cp{self.runtime[6:].replace('.', '')}"
309-
self.manylinux_suffixes = ("_2_24", "2014", "2010", "1")
310-
# TODO: Support aarch64 architecture
311-
self.manylinux_wheel_file_match = re.compile(
312-
rf'^.*{self.manylinux_suffix_start}-(manylinux_\d+_\d+_x86_64[.])?manylinux({"|".join(self.manylinux_suffixes)})_x86_64[.]whl$' # noqa: E501
313-
)
314-
self.manylinux_wheel_abi3_file_match = re.compile(r"^.*cp3.-abi3-manylinux.*_x86_64[.]whl$")
342+
if not architecture:
343+
architecture = X86_ARCHITECTURE
344+
if architecture not in VALID_ARCHITECTURES:
345+
raise ValueError(f"Invalid architecture '{architecture}'. Must be one of: {VALID_ARCHITECTURES}")
346+
347+
self.architecture = architecture
348+
349+
self.manylinux_wheel_file_match = build_manylinux_wheel_file_match_pattern(runtime, architecture)
315350

316351
self.endpoint_urls = endpoint_urls
317352
self.xray_tracing = xray_tracing
@@ -754,7 +789,7 @@ def splitpath(path):
754789
# use the compiled bytecode anyway..
755790
if filename[-3:] == ".py" and root[-10:] != "migrations":
756791
abs_filename = os.path.join(root, filename)
757-
abs_pyc_filename = abs_filename + "c"
792+
abs_pyc_filename = f"{abs_filename}c" # XXX.pyc
758793
if os.path.isfile(abs_pyc_filename):
759794
# but only if the pyc is older than the py,
760795
# otherwise we'll deploy outdated code!
@@ -870,34 +905,33 @@ def get_cached_manylinux_wheel(self, package_name, package_version, disable_prog
870905
"""
871906
Gets the locally stored version of a manylinux wheel. If one does not exist, the function downloads it.
872907
"""
873-
cached_wheels_dir = os.path.join(tempfile.gettempdir(), "cached_wheels")
908+
cached_wheels_dir = Path(tempfile.gettempdir()) / "cached_wheels"
874909

875-
if not os.path.isdir(cached_wheels_dir):
876-
os.makedirs(cached_wheels_dir)
910+
if not cached_wheels_dir.is_dir() or not cached_wheels_dir.exists():
911+
cached_wheels_dir.mkdir(parents=True, exist_ok=True)
877912
else:
878913
# Check if we already have a cached copy
914+
# - get package name from prefix of the wheel file
879915
wheel_name = re.sub(r"[^\w\d.]+", "_", package_name, flags=re.UNICODE)
880-
wheel_file = f"{wheel_name}-{package_version}-*_x86_64.whl"
881-
wheel_path = os.path.join(cached_wheels_dir, wheel_file)
882-
883-
for pathname in glob.iglob(wheel_path):
884-
if re.match(self.manylinux_wheel_file_match, pathname):
885-
logger.info(f" - {package_name}=={package_version}: Using locally cached manylinux wheel")
886-
return pathname
887-
elif re.match(self.manylinux_wheel_abi3_file_match, pathname):
888-
for manylinux_suffix in self.manylinux_suffixes:
889-
if f"manylinux{manylinux_suffix}_x86_64" in pathname:
890-
logger.info(f" - {package_name}=={package_version}: Using locally cached manylinux wheel")
891-
return pathname
916+
valid_architectures = (X86_ARCHITECTURE,)
917+
if self.architecture == ARM_ARCHITECTURE:
918+
valid_architectures = (ARM_ARCHITECTURE, "aarch64")
919+
for arch in valid_architectures:
920+
wheel_file_pattern = f"{wheel_name}-{package_version}-*_{arch}.whl"
921+
for pathname in cached_wheels_dir.glob(wheel_file_pattern):
922+
if self.manylinux_wheel_file_match.match(str(pathname)):
923+
logger.info(f" - {package_name}=={package_version}: Using locally cached manylinux wheel")
924+
return pathname
892925

893926
# The file is not cached, download it.
894927
wheel_url, filename = self.get_manylinux_wheel_url(package_name, package_version)
895928
if not wheel_url:
929+
logger.warning(f" - {package_name}=={package_version}: No manylinux wheel found for this package")
896930
return None
897931

898-
wheel_path = os.path.join(cached_wheels_dir, filename)
932+
wheel_path = cached_wheels_dir / filename
899933
logger.info(f" - {package_name}=={package_version}: Downloading")
900-
with open(wheel_path, "wb") as f:
934+
with wheel_path.open("wb") as f:
901935
self.download_url_with_progress(wheel_url, f, disable_progress)
902936

903937
if not zipfile.is_zipfile(wheel_path):
@@ -917,23 +951,23 @@ def get_manylinux_wheel_url(self, package_name, package_version, ignore_cache: b
917951
every time.
918952
"""
919953
cached_pypi_info_dir = Path(tempfile.gettempdir()) / "cached_pypi_info"
920-
if not cached_pypi_info_dir.is_dir():
921-
os.makedirs(cached_pypi_info_dir)
954+
if not cached_pypi_info_dir.exists():
955+
cached_pypi_info_dir.mkdir(parents=True, exist_ok=True)
922956

923957
# Even though the metadata is for the package, we save it in a
924958
# filename that includes the package's version. This helps in
925959
# invalidating the cached file if the user moves to a different
926960
# version of the package.
927961
# Related: https://github.com/Miserlou/Zappa/issues/899
928962
data = None
929-
json_file_name = "{0!s}-{1!s}.json".format(package_name, package_version)
963+
json_file_name = f"{package_name!s}-{package_version!s}.json"
930964
json_file_path = cached_pypi_info_dir / json_file_name
931965
if json_file_path.exists():
932966
with json_file_path.open("rb") as metafile:
933967
data = json.load(metafile)
934968

935969
if not data or ignore_cache:
936-
url = "https://pypi.python.org/pypi/{}/json".format(package_name)
970+
url = f"https://pypi.python.org/pypi/{package_name}/json"
937971
try:
938972
res = requests.get(url, timeout=float(os.environ.get("PIP_TIMEOUT", 1.5)))
939973
data = res.json()
@@ -949,14 +983,10 @@ def get_manylinux_wheel_url(self, package_name, package_version, ignore_cache: b
949983
return None, None
950984

951985
for f in data["releases"][package_version]:
952-
if re.match(self.manylinux_wheel_file_match, f["filename"]):
986+
if self.manylinux_wheel_file_match.match(f["filename"]):
953987
# Since we have already lowered package names in get_installed_packages
954988
# manylinux caching is not working for packages with capital case in names like MarkupSafe
955989
return f["url"], f["filename"].lower()
956-
elif re.match(self.manylinux_wheel_abi3_file_match, f["filename"]):
957-
for manylinux_suffix in self.manylinux_suffixes:
958-
if f"manylinux{manylinux_suffix}_x86_64" in f["filename"]:
959-
return f["url"], f["filename"].lower()
960990
return None, None
961991

962992
##
@@ -1126,6 +1156,8 @@ def create_lambda_function(
11261156
TracingConfig={"Mode": "Active" if self.xray_tracing else "PassThrough"},
11271157
SnapStart={"ApplyOn": snap_start if snap_start else "None"},
11281158
Layers=layers,
1159+
# zappa currently only supports a single architecture, and uses a str value internally
1160+
Architectures=[self.architecture],
11291161
)
11301162
if not docker_image_uri:
11311163
kwargs["Runtime"] = runtime

0 commit comments

Comments
 (0)