Coverage for scripts/build_layer.py: 95%
104 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Build a Lambda layer zip containing the async durable execution SDK."""
3from __future__ import annotations
5import argparse
6import importlib.util
7import logging
8import subprocess
9import sys
10import tempfile
11import zipfile
12from dataclasses import dataclass
13from importlib.metadata import PackageNotFoundError
14from importlib.metadata import version
15from pathlib import Path
16from typing import Sequence
19logger = logging.getLogger(__name__)
20DEFAULT_OUTPUT = Path("dist") / "async-durable-execution-layer.zip"
21LAYER_ROOT = "python"
24@dataclass(frozen=True)
25class LayerBuildResult:
26 """Details about a generated Lambda layer archive."""
28 output_path: Path
29 sdk_source: str
30 file_count: int
31 size_bytes: int
34def default_sdk_spec() -> str:
35 """Return the SDK package spec matching this layer package version."""
37 version = _read_packaged_version()
38 return f"async-durable-execution=={version}"
41def build_layer(
42 *,
43 output_path: Path = DEFAULT_OUTPUT,
44 sdk_source: str | None = None,
45 python_executable: str = sys.executable,
46 pip_args: Sequence[str] = (),
47) -> LayerBuildResult:
48 """Install the SDK into a Lambda layer layout and zip it.
50 The generated archive has the standard Python layer layout:
51 ``python/<installed packages>``.
52 """
54 resolved_source = sdk_source or default_sdk_spec()
55 output_path = output_path.resolve()
57 with tempfile.TemporaryDirectory(prefix="async-durable-layer-") as temp_dir:
58 staging_dir = Path(temp_dir)
59 python_dir = staging_dir / LAYER_ROOT
60 python_dir.mkdir(parents=True)
62 _pip_install(
63 python_executable=python_executable,
64 target_dir=python_dir,
65 sdk_source=resolved_source,
66 pip_args=pip_args,
67 )
69 file_count = create_layer_archive(staging_dir, output_path)
71 return LayerBuildResult(
72 output_path=output_path,
73 sdk_source=resolved_source,
74 file_count=file_count,
75 size_bytes=output_path.stat().st_size,
76 )
79def create_layer_archive(staging_dir: Path, output_path: Path) -> int:
80 """Create a deterministic zip archive from a staged Lambda layer directory."""
82 python_dir = staging_dir / LAYER_ROOT
83 if not python_dir.is_dir():
84 msg = f"Layer staging directory must contain a {LAYER_ROOT!r} directory"
85 raise ValueError(msg)
87 output_path.parent.mkdir(parents=True, exist_ok=True)
88 if output_path.exists():
89 output_path.unlink()
91 file_count = 0
92 with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
93 for path in sorted(staging_dir.rglob("*")):
94 if not path.is_file():
95 continue
96 if _should_skip_archive_path(path):
97 continue
98 archive_path = path.relative_to(staging_dir).as_posix()
99 info = zipfile.ZipInfo(archive_path)
100 info.date_time = (1980, 1, 1, 0, 0, 0)
101 info.external_attr = (0o644 & 0xFFFF) << 16
102 with path.open("rb") as file:
103 archive.writestr(info, file.read(), compress_type=zipfile.ZIP_DEFLATED)
104 file_count += 1
106 return file_count
109def _pip_install(
110 *,
111 python_executable: str,
112 target_dir: Path,
113 sdk_source: str,
114 pip_args: Sequence[str],
115) -> None:
116 command = [
117 python_executable,
118 "-m",
119 "pip",
120 "install",
121 "--upgrade",
122 "--no-compile",
123 "--target",
124 str(target_dir),
125 *pip_args,
126 sdk_source,
127 ]
128 logger.info("Installing %s into %s", sdk_source, target_dir)
129 subprocess.run(command, check=True)
132def _should_skip_archive_path(path: Path) -> bool:
133 return "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"}
136def _read_packaged_version() -> str:
137 source_path = Path(__file__).resolve()
138 for parent in source_path.parents:
139 version_path = parent / "VERSION.py"
140 if version_path.exists():
141 return _load_version_from_path(version_path)
143 try:
144 return version("async-durable-execution")
145 except PackageNotFoundError as error:
146 msg = "Unable to resolve async-durable-execution version"
147 raise RuntimeError(msg) from error
150def _load_version_from_path(path: Path) -> str:
151 spec = importlib.util.spec_from_file_location("_async_durable_layer_version", path)
152 if spec is None or spec.loader is None:
153 msg = f"Unable to load version from {path}"
154 raise RuntimeError(msg)
156 module = importlib.util.module_from_spec(spec)
157 spec.loader.exec_module(module)
158 return module.__version__
161def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
162 parser = argparse.ArgumentParser(
163 description="Build an AWS Lambda layer zip for async-durable-execution"
164 )
165 parser.add_argument(
166 "--output",
167 type=Path,
168 default=DEFAULT_OUTPUT,
169 help=f"Layer zip output path (default: {DEFAULT_OUTPUT})",
170 )
171 parser.add_argument(
172 "--sdk-source",
173 default=None,
174 help=(
175 "Package spec or local path to install into the layer "
176 f"(default: {default_sdk_spec()})"
177 ),
178 )
179 parser.add_argument(
180 "--python",
181 default=sys.executable,
182 help="Python executable used to run pip",
183 )
184 parser.add_argument(
185 "--pip-arg",
186 action="append",
187 default=[],
188 help="Additional argument to pass to pip install; repeat for multiple args",
189 )
190 parser.add_argument(
191 "--verbose",
192 action="store_true",
193 help="Enable verbose logging",
194 )
195 return parser.parse_args(argv)
198def main(argv: Sequence[str] | None = None) -> int:
199 args = parse_args(argv)
200 logging.basicConfig(
201 level=logging.INFO if args.verbose else logging.WARNING,
202 format="%(levelname)s: %(message)s",
203 )
205 try:
206 result = build_layer(
207 output_path=args.output,
208 sdk_source=args.sdk_source,
209 python_executable=args.python,
210 pip_args=args.pip_arg,
211 )
212 except subprocess.CalledProcessError as error:
213 print(f"Failed to install layer dependencies: {error}", file=sys.stderr)
214 return 1
215 except (OSError, RuntimeError, ValueError, zipfile.BadZipFile) as error:
216 print(f"Failed to build layer: {error}", file=sys.stderr)
217 return 1
219 print(
220 "Built Lambda layer "
221 f"{result.output_path} with {result.file_count} files "
222 f"({result.size_bytes} bytes) from {result.sdk_source}"
223 )
224 return 0
227if __name__ == "__main__":
228 raise SystemExit(main())