repro-lambda 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. repro_lambda-0.4.2/.github/workflows/move-major-tag.yml +28 -0
  2. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/CHANGELOG.md +11 -1
  3. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/PKG-INFO +1 -1
  4. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/SETUP.md +38 -10
  5. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/pyproject.toml +1 -1
  6. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/__init__.py +1 -1
  7. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/build.py +7 -1
  8. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/docker_runner.py +8 -2
  9. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/hasher.py +21 -1
  10. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/manifest.py +48 -0
  11. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/verify.py +2 -1
  12. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_docker_runner.py +2 -2
  13. repro_lambda-0.4.2/tests/test_per_lambda_builder.py +231 -0
  14. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/uv.lock +1 -1
  15. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/build.yml +0 -0
  16. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/ci.yml +0 -0
  17. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/promote.yml +0 -0
  18. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/publish.yml +0 -0
  19. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.gitignore +0 -0
  20. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.pre-commit-config.yaml +0 -0
  21. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/LICENSE +0 -0
  22. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/README.md +0 -0
  23. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/__main__.py +0 -0
  24. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/catalog.py +0 -0
  25. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/cli.py +0 -0
  26. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/git_guard.py +0 -0
  27. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/promote.py +0 -0
  28. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/s3_uploader.py +0 -0
  29. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/source_stager.py +0 -0
  30. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/zip_packager.py +0 -0
  31. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/__init__.py +0 -0
  32. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/conftest.py +0 -0
  33. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  34. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  35. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  36. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  37. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  38. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  39. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  40. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  41. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_build_integration.py +0 -0
  42. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_build_nodejs.py +0 -0
  43. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_catalog.py +0 -0
  44. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_build.py +0 -0
  45. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_lock.py +0 -0
  46. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_smoke.py +0 -0
  47. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_docker_runner_nodejs.py +0 -0
  48. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_e2e_nodejs_lambda.py +0 -0
  49. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_e2e_python_lambda.py +0 -0
  50. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_extra_files.py +0 -0
  51. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_git_guard.py +0 -0
  52. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_hasher.py +0 -0
  53. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_manifest.py +0 -0
  54. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_promote.py +0 -0
  55. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_python_byte_compat_regression.py +0 -0
  56. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_s3_uploader.py +0 -0
  57. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_source_stager.py +0 -0
  58. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_verify.py +0 -0
  59. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_zip_excludes.py +0 -0
  60. {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_zip_packager.py +0 -0
@@ -0,0 +1,28 @@
1
+ name: move-major-tag
2
+
3
+ # On every semver release tag (vX.Y.Z), force-move the sliding MAJOR tag vX to it,
4
+ # so consumers can pin `@v0` (or `@v1` once 1.x ships) and always get the latest
5
+ # backward-compatible reusable workflows. While the project is 0.x the major is `v0`.
6
+ #
7
+ # Safe by construction: publish.yml triggers on `v*.*.*` only, so moving a non-semver
8
+ # tag (v0) never loop-triggers a publish; and a tag pushed by GITHUB_TOKEN does not
9
+ # retrigger any workflow.
10
+ on:
11
+ push:
12
+ tags: ["v*.*.*"]
13
+
14
+ permissions:
15
+ contents: write
16
+
17
+ jobs:
18
+ move:
19
+ runs-on: ubuntu-24.04
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ - name: Force-move sliding major tag
23
+ env:
24
+ TAG: ${{ github.ref_name }}
25
+ run: |
26
+ MAJOR="${TAG%%.*}" # v0.4.1 -> v0
27
+ git tag -f "$MAJOR" "$TAG"
28
+ git push -f origin "refs/tags/$MAJOR"
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2 - 2026-06-21
4
+
5
+ ### Added
6
+ - Per-lambda builder overrides: any `[[lambda]]` may now set `base_image_python`, `include_patterns`, or `exclude_patterns` to override the `[builder]` defaults for itself. An override fully REPLACES the matching default (lists are not merged); an unset field inherits `[builder]`. A per-lambda `base_image_python` must still be digest-pinned (validated at manifest load). This lets one lambda build on its own base image or filter its source more tightly than the others - e.g. a lambda that bundles a large prebuilt tree can narrow `include_patterns` to just its runtime modules so unrelated file changes no longer re-key its artifact. The resolved per-lambda builder (base-image digest + include/exclude lists + builder version) folds into the content hash, so changing an override re-keys only that lambda. Manifests with no per-lambda overrides resolve to the `[builder]` defaults unchanged. The builder version bump re-keys all content hashes, as expected.
7
+
8
+ ## v0.4.1 - 2026-06-21
9
+
10
+ ### Fixed
11
+ - Lower the pip `--platform` floor from `manylinux_2_28` to `manylinux_2_17` (manylinux2014) for both arches. pip's explicit `--platform` does not expand a higher manylinux tag down to lower-baseline wheels, so with `--only-binary=:all:` a `2_28` floor failed to find compiled wheels that ship only `manylinux_2_17` for a given Python/arch (e.g. `pydantic-core`). `manylinux_2_17` is the broadest baseline the AWS Lambda base images (Amazon Linux 2023, glibc 2.34) still run, and it matches `2_17` wheels plus any lower baseline. Pure-Python lambdas are unaffected (their `py3-none-any` wheels never depended on the platform). The builder version bump re-keys all content hashes, as expected.
12
+
3
13
  ## v0.4.0 - 2026-06-21
4
14
 
5
15
  ### Added
@@ -16,7 +26,7 @@
16
26
 
17
27
  jobs:
18
28
  promote:
19
- uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v1
29
+ uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v0
20
30
  with:
21
31
  source_sha: ${{ inputs.source_sha }}
22
32
  promoter_role_arn: arn:aws:iam::<account>:role/<promoter-role>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf.
5
5
  Project-URL: Homepage, https://github.com/antonbabenko/repro-lambda
6
6
  Project-URL: Repository, https://github.com/antonbabenko/repro-lambda
@@ -260,20 +260,21 @@ on:
260
260
 
261
261
  jobs:
262
262
  build:
263
- uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1
263
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
264
264
  with:
265
265
  manifest_path: lambdas.toml
266
- repro_lambda_version: "0.2.1"
267
- secrets:
268
- aws-dev-role-arn: ${{ secrets.AWS_DEV_ROLE_ARN }}
269
- aws-prod-role-arn: ${{ secrets.AWS_PROD_ROLE_ARN }}
266
+ aws_dev_role_arn: arn:aws:iam::<dev-account-id>:role/gha-lambda-builder-dev
267
+ dev_bucket: <env>-lambda-artifacts
268
+ # Optional - master-push upload to a prod bucket:
269
+ # aws_prod_role_arn: arn:aws:iam::<prod-account-id>:role/gha-lambda-builder-prod
270
+ # prod_bucket: <env>-lambda-artifacts
270
271
  ```
271
272
 
272
- Store the role ARNs (from the Terraform outputs above) as repo or organization
273
- secrets:
274
-
275
- - `AWS_DEV_ROLE_ARN` `gha-lambda-builder-dev` role from the dev account
276
- - `AWS_PROD_ROLE_ARN` `gha-lambda-builder-prod` role from the prod account
273
+ Pin the reusable workflow with the sliding major tag `@v0` - it auto-moves to the
274
+ latest backward-compatible 0.x release on every tag (switch to `@v1` once repro-lambda
275
+ ships 1.0). The role ARNs are not secrets: the boundary is the OIDC trust policy plus
276
+ the key-level bucket immutability, so they are plain inputs (only the account IDs,
277
+ which are public), not stored secrets.
277
278
 
278
279
  ## Per-Lambda manifest
279
280
 
@@ -302,6 +303,33 @@ Pin the `base_image_python` to a specific digest with `docker pull <image> &&
302
303
  docker inspect --format='{{index .RepoDigests 0}}' <image>` — never use a
303
304
  floating tag in production.
304
305
 
306
+ ### Per-lambda builder overrides
307
+
308
+ `[builder]` sets the defaults for every lambda. Any `[[lambda]]` may override
309
+ `base_image_python`, `include_patterns`, or `exclude_patterns` for itself. An
310
+ override REPLACES the default for that field (lists are not merged); an unset
311
+ field inherits `[builder]`. Use this when one lambda needs a different base image
312
+ or a tighter file filter (so it re-hashes only on changes that affect it):
313
+
314
+ ```toml
315
+ [[lambda]]
316
+ logical_name = "worker"
317
+ source_dir = "src/worker"
318
+ requirements_lock = "src/worker/requirements.${arch}.lock"
319
+ runtime = "python3.13"
320
+ arch = "arm64"
321
+ handler = "worker.handler"
322
+ # Override: only this lambda's runtime modules trigger a rebuild, and it builds
323
+ # on its own pinned base image. base_image_python must still be digest-pinned.
324
+ base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<other-digest>"
325
+ include_patterns = ["worker/**/*.py", "worker/**/*.json"]
326
+ exclude_patterns = ["**/tests/**"]
327
+ ```
328
+
329
+ The resolved per-lambda builder (base-image digest + include/exclude lists +
330
+ builder version) folds into the content hash, so changing an override re-keys
331
+ that lambda's artifact while leaving the others untouched.
332
+
305
333
  ## Terraform consumer — `s3_existing_package`
306
334
 
307
335
  In the Terraform that creates your Lambda function, point at the artifact in
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -12,7 +12,7 @@ from repro_lambda import __version__
12
12
  from repro_lambda.catalog import Catalog, CatalogEntry
13
13
  from repro_lambda.docker_runner import build_nodejs_lambda, build_python_lambda
14
14
  from repro_lambda.hasher import compute_content_hash
15
- from repro_lambda.manifest import BuilderConfig, LambdaSpec
15
+ from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
16
16
  from repro_lambda.s3_uploader import S3Uploader, UploadResult
17
17
  from repro_lambda.source_stager import stage_source
18
18
 
@@ -61,6 +61,7 @@ def compute_sha_for(
61
61
  builder: BuilderConfig,
62
62
  ) -> str:
63
63
  """Stage source and compute the content hash; tempdir disposed on exit."""
64
+ builder = resolve_builder(builder, spec)
64
65
  with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
65
66
  stage_dir = Path(td)
66
67
  lock_path = repo_root / spec.resolved_requirements_lock
@@ -85,6 +86,8 @@ def compute_sha_for(
85
86
  builder_version=__version__,
86
87
  extra_files=extras,
87
88
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
89
+ include_patterns=builder.include_patterns,
90
+ exclude_patterns=builder.exclude_patterns,
88
91
  )
89
92
 
90
93
 
@@ -99,6 +102,7 @@ def build_one(
99
102
  dry_run: bool = False,
100
103
  ) -> BuildOutcome:
101
104
  """Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
105
+ builder = resolve_builder(builder, spec)
102
106
  target_bucket = _bucket_for(spec, bucket)
103
107
 
104
108
  with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
@@ -126,6 +130,8 @@ def build_one(
126
130
  builder_version=__version__,
127
131
  extra_files=extras,
128
132
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
133
+ include_patterns=builder.include_patterns,
134
+ exclude_patterns=builder.exclude_patterns,
129
135
  )
130
136
  bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
131
137
 
@@ -12,9 +12,15 @@ ARCH_TO_DOCKER_PLATFORM: dict[str, str] = {
12
12
  "x86_64": "linux/amd64",
13
13
  }
14
14
 
15
+ # manylinux_2_17 (== manylinux2014) is the broadest baseline the AWS Lambda base
16
+ # images (Amazon Linux 2023, glibc 2.34) still run, and pip's explicit --platform does
17
+ # NOT expand a higher tag (e.g. 2_28) down to lower-baseline wheels. Many compiled
18
+ # wheels (e.g. pydantic-core) ship only manylinux_2_17 for a given Python/arch, so a
19
+ # 2_28 floor misses them with --only-binary=:all:. 2_17 matches 2_17 wheels and, via
20
+ # pip's tag expansion, any lower baseline too.
15
21
  ARCH_TO_PIP_PLATFORM: dict[str, str] = {
16
- "arm64": "manylinux_2_28_aarch64",
17
- "x86_64": "manylinux_2_28_x86_64",
22
+ "arm64": "manylinux_2_17_aarch64",
23
+ "x86_64": "manylinux_2_17_x86_64",
18
24
  }
19
25
 
20
26
  ARCH_TO_NPM_CPU: dict[str, str] = {
@@ -25,11 +25,14 @@ def compute_content_hash(
25
25
  *,
26
26
  extra_files: list[tuple[Path, str]] | None = None,
27
27
  payload_exec: list[tuple[str, bool]] | None = None,
28
+ include_patterns: list[str] | None = None,
29
+ exclude_patterns: list[str] | None = None,
28
30
  ) -> str:
29
31
  """
30
32
  sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
31
33
  + sha256(requirements_lock) + spec scalars + base_image + builder_version
32
- + optional extra_files keyed by destination relname (e.g. "package.json").
34
+ + optional resolved include/exclude filter lists + optional extra_files keyed by
35
+ destination relname (e.g. "package.json").
33
36
 
34
37
  Inputs are concatenated with newline separators in a fixed order, then hashed.
35
38
 
@@ -37,6 +40,12 @@ def compute_content_hash(
37
40
  (dest_relname, file_bytes) only - not the host path - so the hash is
38
41
  host-path-independent. Callers with no extras produce byte-identical hashes
39
42
  to v0.1 (the extras section is omitted entirely when extra_files is falsy).
43
+
44
+ include_patterns / exclude_patterns are the RESOLVED per-lambda filter lists.
45
+ They are folded sorted (membership is order-independent, so a pure reorder does
46
+ not re-key) and only when not None, so an explicit empty list (replace-with-empty)
47
+ hashes differently from an unset/None filter. Callers passing None omit the
48
+ section entirely, preserving hashes for code paths that do not resolve a builder.
40
49
  """
41
50
  h = hashlib.sha256()
42
51
 
@@ -62,6 +71,17 @@ def compute_content_hash(
62
71
  h.update(f"base_image={base_image}\n".encode())
63
72
  h.update(f"builder_version={builder_version}\n".encode())
64
73
 
74
+ if include_patterns is not None:
75
+ h.update(b"---include---\n")
76
+ for pat in sorted(include_patterns):
77
+ h.update(pat.encode("utf-8"))
78
+ h.update(b"\n")
79
+ if exclude_patterns is not None:
80
+ h.update(b"---exclude---\n")
81
+ for pat in sorted(exclude_patterns):
82
+ h.update(pat.encode("utf-8"))
83
+ h.update(b"\n")
84
+
65
85
  if extra_files:
66
86
  h.update(b"---extras---\n")
67
87
  # Sort by relname so staging order does not perturb the hash.
@@ -49,6 +49,11 @@ class LambdaSpec:
49
49
  hash_extra: str = ""
50
50
  package_json: str = ""
51
51
  extra_files: tuple[ExtraFile, ...] = ()
52
+ # Per-lambda builder overrides (REPLACE-once-set; None = inherit the [builder]
53
+ # default). base_image_python must stay digest-pinned. Resolved via resolve_builder().
54
+ base_image_python: str | None = None
55
+ include_patterns: list[str] | None = None
56
+ exclude_patterns: list[str] | None = None
52
57
 
53
58
  @property
54
59
  def resolved_requirements_lock(self) -> str:
@@ -82,6 +87,26 @@ class Manifest:
82
87
  builder: BuilderConfig
83
88
 
84
89
 
90
+ def resolve_builder(default: BuilderConfig, spec: LambdaSpec) -> BuilderConfig:
91
+ """Resolve the effective builder for one lambda (REPLACE-once-set overrides).
92
+
93
+ Each per-lambda override fully REPLACES the matching [builder] default when set;
94
+ an unset override (None) inherits the default. An explicitly empty list replaces
95
+ with empty (stages/filters nothing) - the caller's choice, distinct from unset.
96
+ base_image_nodejs has no per-lambda override yet (add when a node lambda needs it).
97
+ """
98
+ return BuilderConfig(
99
+ base_image_python=spec.base_image_python or default.base_image_python,
100
+ base_image_nodejs=default.base_image_nodejs,
101
+ include_patterns=(
102
+ default.include_patterns if spec.include_patterns is None else spec.include_patterns
103
+ ),
104
+ exclude_patterns=(
105
+ default.exclude_patterns if spec.exclude_patterns is None else spec.exclude_patterns
106
+ ),
107
+ )
108
+
109
+
85
110
  def _parse_extra_files(path: Path, entry: dict) -> tuple[ExtraFile, ...]:
86
111
  """Parse + validate a lambda's optional [[lambda.extra_files]] entries."""
87
112
  parsed: list[ExtraFile] = []
@@ -160,6 +185,26 @@ def load_manifest(path: Path) -> Manifest:
160
185
 
161
186
  extra_files = _parse_extra_files(path, entry)
162
187
 
188
+ override_base_image = entry.get("base_image_python")
189
+ if override_base_image is not None and "@sha256:" not in override_base_image:
190
+ raise ValueError(
191
+ f"{path}: lambda {entry.get('logical_name')!r} base_image_python override must be "
192
+ f"pinned by digest (got {override_base_image!r}; need image@sha256:<digest>)"
193
+ )
194
+ override_include = entry.get("include_patterns")
195
+ override_exclude = entry.get("exclude_patterns")
196
+ for fname, fval in (
197
+ ("include_patterns", override_include),
198
+ ("exclude_patterns", override_exclude),
199
+ ):
200
+ if fval is not None and (
201
+ not isinstance(fval, list) or not all(isinstance(x, str) for x in fval)
202
+ ):
203
+ raise ValueError(
204
+ f"{path}: lambda {entry.get('logical_name')!r} {fname} override "
205
+ f"must be a list of strings (got {fval!r})"
206
+ )
207
+
163
208
  lambdas.append(
164
209
  LambdaSpec(
165
210
  logical_name=entry["logical_name"],
@@ -174,6 +219,9 @@ def load_manifest(path: Path) -> Manifest:
174
219
  lambda_at_edge=bool(entry.get("lambda_at_edge", False)),
175
220
  hash_extra=entry.get("hash_extra", ""),
176
221
  extra_files=extra_files,
222
+ base_image_python=override_base_image,
223
+ include_patterns=list(override_include) if override_include is not None else None,
224
+ exclude_patterns=list(override_exclude) if override_exclude is not None else None,
177
225
  )
178
226
  )
179
227
 
@@ -7,7 +7,7 @@ import tempfile
7
7
  from pathlib import Path
8
8
 
9
9
  from repro_lambda.docker_runner import build_nodejs_lambda, build_python_lambda
10
- from repro_lambda.manifest import BuilderConfig, LambdaSpec
10
+ from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
11
11
  from repro_lambda.source_stager import stage_source
12
12
 
13
13
 
@@ -45,6 +45,7 @@ def verify_reproducible(
45
45
 
46
46
  Returns (sha_build_1, sha_build_2) on match. Raises ReproducibilityError on mismatch.
47
47
  """
48
+ builder = resolve_builder(builder, spec)
48
49
  lock_path = repo_root / spec.resolved_requirements_lock
49
50
  extras = _extras_for(spec, lock_path, repo_root)
50
51
 
@@ -20,8 +20,8 @@ def test_arch_mapping_tables_are_complete():
20
20
  def test_arch_mapping_values():
21
21
  assert ARCH_TO_DOCKER_PLATFORM["arm64"] == "linux/arm64"
22
22
  assert ARCH_TO_DOCKER_PLATFORM["x86_64"] == "linux/amd64"
23
- assert ARCH_TO_PIP_PLATFORM["arm64"] == "manylinux_2_28_aarch64"
24
- assert ARCH_TO_PIP_PLATFORM["x86_64"] == "manylinux_2_28_x86_64"
23
+ assert ARCH_TO_PIP_PLATFORM["arm64"] == "manylinux_2_17_aarch64"
24
+ assert ARCH_TO_PIP_PLATFORM["x86_64"] == "manylinux_2_17_x86_64"
25
25
 
26
26
 
27
27
  def test_build_python_lambda_raises_on_unknown_arch(tmp_path: Path):
@@ -0,0 +1,231 @@
1
+ """Per-lambda builder overrides (base image / include / exclude), REPLACE-once-set."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from repro_lambda.build import compute_sha_for
9
+ from repro_lambda.hasher import compute_content_hash
10
+ from repro_lambda.manifest import (
11
+ BuilderConfig,
12
+ LambdaSpec,
13
+ load_manifest,
14
+ resolve_builder,
15
+ )
16
+
17
+ PINNED_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "0" * 64
18
+ OTHER_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "1" * 64
19
+
20
+
21
+ def _spec(**kw) -> LambdaSpec:
22
+ base = dict(
23
+ logical_name="app",
24
+ source_dir="handler",
25
+ requirements_lock="handler/requirements.${arch}.lock",
26
+ runtime="python3.13",
27
+ arch="arm64",
28
+ handler="app.lambda_handler",
29
+ )
30
+ base.update(kw)
31
+ return LambdaSpec(**base)
32
+
33
+
34
+ def _default_builder() -> BuilderConfig:
35
+ return BuilderConfig(
36
+ base_image_python=PINNED_IMAGE,
37
+ include_patterns=["**/*.py", "**/*.json"],
38
+ exclude_patterns=["tests/**"],
39
+ )
40
+
41
+
42
+ # --- manifest parse / validate --------------------------------------------
43
+
44
+
45
+ def _write_manifest(tmp_path: Path, lambda_overrides_toml: str) -> Path:
46
+ p = tmp_path / "lambdas.toml"
47
+ p.write_text(
48
+ "[[lambda]]\n"
49
+ 'logical_name = "app"\n'
50
+ 'source_dir = "handler"\n'
51
+ 'requirements_lock = "handler/requirements.${arch}.lock"\n'
52
+ 'runtime = "python3.13"\n'
53
+ 'arch = "arm64"\n'
54
+ 'handler = "app.lambda_handler"\n'
55
+ f"{lambda_overrides_toml}"
56
+ "\n[builder]\n"
57
+ f'base_image_python = "{PINNED_IMAGE}"\n'
58
+ )
59
+ return p
60
+
61
+
62
+ def test_manifest_parses_per_lambda_builder_overrides(tmp_path: Path):
63
+ manifest = _write_manifest(
64
+ tmp_path,
65
+ f'base_image_python = "{OTHER_IMAGE}"\n'
66
+ 'include_patterns = ["**/*.py"]\n'
67
+ 'exclude_patterns = ["tests/**", "docs/**"]\n',
68
+ )
69
+ spec = load_manifest(manifest).lambdas[0]
70
+ assert spec.base_image_python == OTHER_IMAGE
71
+ assert spec.include_patterns == ["**/*.py"]
72
+ assert spec.exclude_patterns == ["tests/**", "docs/**"]
73
+
74
+
75
+ def test_manifest_overrides_default_to_none(tmp_path: Path):
76
+ spec = load_manifest(_write_manifest(tmp_path, "")).lambdas[0]
77
+ assert spec.base_image_python is None
78
+ assert spec.include_patterns is None
79
+ assert spec.exclude_patterns is None
80
+
81
+
82
+ def test_manifest_explicit_empty_include_is_empty_list_not_none(tmp_path: Path):
83
+ spec = load_manifest(_write_manifest(tmp_path, "include_patterns = []\n")).lambdas[0]
84
+ assert spec.include_patterns == [] # explicit empty != unset
85
+
86
+
87
+ def test_manifest_rejects_unpinned_override_base_image(tmp_path: Path):
88
+ manifest = _write_manifest(
89
+ tmp_path, 'base_image_python = "public.ecr.aws/lambda/python:3.13"\n'
90
+ )
91
+ with pytest.raises(ValueError, match="must be pinned by digest"):
92
+ load_manifest(manifest)
93
+
94
+
95
+ def test_manifest_rejects_non_list_include(tmp_path: Path):
96
+ manifest = _write_manifest(tmp_path, 'include_patterns = "**/*.py"\n')
97
+ with pytest.raises(ValueError, match="must be a list of strings"):
98
+ load_manifest(manifest)
99
+
100
+
101
+ def test_manifest_rejects_non_string_in_exclude(tmp_path: Path):
102
+ manifest = _write_manifest(tmp_path, "exclude_patterns = [1, 2]\n")
103
+ with pytest.raises(ValueError, match="must be a list of strings"):
104
+ load_manifest(manifest)
105
+
106
+
107
+ # --- resolve_builder (REPLACE-once-set) ------------------------------------
108
+
109
+
110
+ def test_resolve_inherits_default_when_unset():
111
+ default = _default_builder()
112
+ resolved = resolve_builder(default, _spec())
113
+ assert resolved.base_image_python == PINNED_IMAGE
114
+ assert resolved.include_patterns == ["**/*.py", "**/*.json"]
115
+ assert resolved.exclude_patterns == ["tests/**"]
116
+
117
+
118
+ def test_resolve_replaces_each_set_field():
119
+ default = _default_builder()
120
+ spec = _spec(
121
+ base_image_python=OTHER_IMAGE,
122
+ include_patterns=["src/**/*.py"],
123
+ exclude_patterns=["docs/**"],
124
+ )
125
+ resolved = resolve_builder(default, spec)
126
+ assert resolved.base_image_python == OTHER_IMAGE
127
+ assert resolved.include_patterns == ["src/**/*.py"]
128
+ assert resolved.exclude_patterns == ["docs/**"]
129
+
130
+
131
+ def test_resolve_empty_list_replaces_with_empty():
132
+ default = _default_builder()
133
+ resolved = resolve_builder(default, _spec(exclude_patterns=[]))
134
+ assert resolved.exclude_patterns == [] # replace, not inherit
135
+
136
+
137
+ def test_resolve_preserves_default_nodejs_image():
138
+ default = BuilderConfig(base_image_python=PINNED_IMAGE, base_image_nodejs="node@sha256:abc")
139
+ resolved = resolve_builder(default, _spec(base_image_python=OTHER_IMAGE))
140
+ assert resolved.base_image_nodejs == "node@sha256:abc"
141
+
142
+
143
+ # --- hash folds resolved include/exclude -----------------------------------
144
+
145
+
146
+ def _staged_tree(tmp_path: Path) -> tuple[Path, Path]:
147
+ root = tmp_path / "source"
148
+ root.mkdir(parents=True)
149
+ (root / "app.py").write_text("x = 1\n")
150
+ lock = tmp_path / "requirements.lock"
151
+ lock.write_text("")
152
+ return root, lock
153
+
154
+
155
+ def _hash(root: Path, lock: Path, **kw) -> str:
156
+ return compute_content_hash(
157
+ staged_source_root=root,
158
+ requirements_lock=lock,
159
+ spec=_spec(),
160
+ base_image=PINNED_IMAGE,
161
+ builder_version="0.4.2",
162
+ **kw,
163
+ )
164
+
165
+
166
+ def test_hash_none_filters_is_stable(tmp_path: Path):
167
+ root, lock = _staged_tree(tmp_path)
168
+ assert _hash(root, lock) == _hash(root, lock, include_patterns=None, exclude_patterns=None)
169
+
170
+
171
+ def test_hash_rekeys_when_exclude_differs(tmp_path: Path):
172
+ root, lock = _staged_tree(tmp_path)
173
+ a = _hash(root, lock, include_patterns=["**/*.py"], exclude_patterns=["tests/**"])
174
+ b = _hash(root, lock, include_patterns=["**/*.py"], exclude_patterns=["tests/**", "docs/**"])
175
+ assert a != b
176
+
177
+
178
+ def test_hash_none_differs_from_explicit_empty(tmp_path: Path):
179
+ root, lock = _staged_tree(tmp_path)
180
+ unset = _hash(root, lock, include_patterns=None)
181
+ empty = _hash(root, lock, include_patterns=[])
182
+ assert unset != empty # unset omits the section; [] emits an empty section marker
183
+
184
+
185
+ def test_hash_pattern_reorder_does_not_rekey(tmp_path: Path):
186
+ root, lock = _staged_tree(tmp_path)
187
+ a = _hash(root, lock, exclude_patterns=["a/**", "b/**"])
188
+ b = _hash(root, lock, exclude_patterns=["b/**", "a/**"])
189
+ assert a == b # membership is order-independent -> sorted fold
190
+
191
+
192
+ # --- compute_sha_for integration (git-backed) ------------------------------
193
+
194
+
195
+ def _git_repo(tmp_path: Path) -> Path:
196
+ repo = tmp_path / "repo"
197
+ (repo / "handler" / "tests").mkdir(parents=True)
198
+ (repo / "handler" / "app.py").write_text("def lambda_handler(e, c): return 200\n")
199
+ (repo / "handler" / "tests" / "test_app.py").write_text("def test_x(): pass\n")
200
+ (repo / "handler" / "requirements.arm64.lock").write_text("")
201
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
202
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
203
+ subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
204
+ subprocess.run(["git", "add", "."], cwd=repo, check=True)
205
+ subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
206
+ return repo
207
+
208
+
209
+ def test_compute_sha_for_rekeys_on_per_lambda_exclude(tmp_path: Path):
210
+ repo = _git_repo(tmp_path)
211
+ default = BuilderConfig(
212
+ base_image_python=PINNED_IMAGE,
213
+ include_patterns=["**/*.py"],
214
+ exclude_patterns=[],
215
+ )
216
+ # Lambda A keeps tests; lambda B excludes them -> different staged tree -> different sha.
217
+ spec_keep = _spec()
218
+ spec_drop = _spec(exclude_patterns=["**/tests/**"])
219
+ sha_keep = compute_sha_for(repo_root=repo, spec=spec_keep, builder=default)
220
+ sha_drop = compute_sha_for(repo_root=repo, spec=spec_drop, builder=default)
221
+ assert sha_keep != sha_drop
222
+
223
+
224
+ def test_compute_sha_for_rekeys_on_per_lambda_base_image(tmp_path: Path):
225
+ repo = _git_repo(tmp_path)
226
+ default = BuilderConfig(base_image_python=PINNED_IMAGE, include_patterns=["**/*.py"])
227
+ sha_default = compute_sha_for(repo_root=repo, spec=_spec(), builder=default)
228
+ sha_override = compute_sha_for(
229
+ repo_root=repo, spec=_spec(base_image_python=OTHER_IMAGE), builder=default
230
+ )
231
+ assert sha_default != sha_override
@@ -646,7 +646,7 @@ wheels = [
646
646
 
647
647
  [[package]]
648
648
  name = "repro-lambda"
649
- version = "0.4.0"
649
+ version = "0.4.2"
650
650
  source = { editable = "." }
651
651
  dependencies = [
652
652
  { name = "boto3" },
File without changes
File without changes
File without changes