repro-lambda 0.3.0__tar.gz → 0.4.0__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 (58) hide show
  1. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/CHANGELOG.md +5 -0
  2. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/PKG-INFO +1 -1
  3. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/pyproject.toml +1 -1
  4. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/__init__.py +1 -1
  5. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/build.py +4 -0
  6. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/hasher.py +10 -0
  7. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/manifest.py +42 -0
  8. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/source_stager.py +32 -4
  9. repro_lambda-0.4.0/tests/test_extra_files.py +222 -0
  10. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/uv.lock +1 -1
  11. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.github/workflows/build.yml +0 -0
  12. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.github/workflows/ci.yml +0 -0
  13. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.github/workflows/promote.yml +0 -0
  14. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.github/workflows/publish.yml +0 -0
  15. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.gitignore +0 -0
  16. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/.pre-commit-config.yaml +0 -0
  17. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/LICENSE +0 -0
  18. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/README.md +0 -0
  19. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/SETUP.md +0 -0
  20. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/__main__.py +0 -0
  21. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/catalog.py +0 -0
  22. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/cli.py +0 -0
  23. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/docker_runner.py +0 -0
  24. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/git_guard.py +0 -0
  25. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/promote.py +0 -0
  26. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/s3_uploader.py +0 -0
  27. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/verify.py +0 -0
  28. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/src/repro_lambda/zip_packager.py +0 -0
  29. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/__init__.py +0 -0
  30. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/conftest.py +0 -0
  31. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  32. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  33. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  34. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  35. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  36. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  37. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  38. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  39. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_build_integration.py +0 -0
  40. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_build_nodejs.py +0 -0
  41. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_catalog.py +0 -0
  42. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_cli_build.py +0 -0
  43. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_cli_lock.py +0 -0
  44. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_cli_smoke.py +0 -0
  45. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_docker_runner.py +0 -0
  46. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_docker_runner_nodejs.py +0 -0
  47. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_e2e_nodejs_lambda.py +0 -0
  48. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_e2e_python_lambda.py +0 -0
  49. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_git_guard.py +0 -0
  50. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_hasher.py +0 -0
  51. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_manifest.py +0 -0
  52. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_promote.py +0 -0
  53. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_python_byte_compat_regression.py +0 -0
  54. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_s3_uploader.py +0 -0
  55. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_source_stager.py +0 -0
  56. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_verify.py +0 -0
  57. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_zip_excludes.py +0 -0
  58. {repro_lambda-0.3.0 → repro_lambda-0.4.0}/tests/test_zip_packager.py +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.0 - 2026-06-21
4
+
5
+ ### Added
6
+ - `extra_files` manifest field: bundle prebuilt files or directories into a lambda package alongside its source. Each `[[lambda.extra_files]]` entry has `src` (repo-root-relative, where CI materialized it - e.g. a digest-pinned binary or an extracted release tree), `dest` (package-root-relative), and an optional `executable` flag (sets +x on a file; ignored for directories, which keep source perms). The bytes fold into the content hash via the staged source tree, and the executable bit folds in separately, so flipping it changes the artifact hash even when bytes are identical. This lets a lambda ship vendored CLIs or release trees the consumer's CI downloads and verifies, while the tool itself stays free of any network/tool-download logic. Paths are validated as relative and `..`-free. Specs without `extra_files` hash byte-identically to before.
7
+
3
8
  ## v0.3.0 - 2026-06-21
4
9
 
5
10
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.3.0"
3
+ version = "0.4.0"
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.3.0"
3
+ __version__ = "0.4.0"
@@ -75,6 +75,7 @@ def compute_sha_for(
75
75
  builder=builder,
76
76
  stage_dir=stage_dir,
77
77
  extra_files=extras,
78
+ payload_files=list(spec.extra_files),
78
79
  )
79
80
  return compute_content_hash(
80
81
  staged_source_root=stage_dir / "source",
@@ -83,6 +84,7 @@ def compute_sha_for(
83
84
  base_image=primary_base_image,
84
85
  builder_version=__version__,
85
86
  extra_files=extras,
87
+ payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
86
88
  )
87
89
 
88
90
 
@@ -113,6 +115,7 @@ def build_one(
113
115
  builder=builder,
114
116
  stage_dir=stage_dir,
115
117
  extra_files=extras,
118
+ payload_files=list(spec.extra_files),
116
119
  )
117
120
 
118
121
  sha = compute_content_hash(
@@ -122,6 +125,7 @@ def build_one(
122
125
  base_image=primary_base_image,
123
126
  builder_version=__version__,
124
127
  extra_files=extras,
128
+ payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
125
129
  )
126
130
  bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
127
131
 
@@ -24,6 +24,7 @@ def compute_content_hash(
24
24
  builder_version: str,
25
25
  *,
26
26
  extra_files: list[tuple[Path, str]] | None = None,
27
+ payload_exec: list[tuple[str, bool]] | None = None,
27
28
  ) -> str:
28
29
  """
29
30
  sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
@@ -72,4 +73,13 @@ def compute_content_hash(
72
73
  h.update(_sha256_file(src).encode("ascii"))
73
74
  h.update(b"\n")
74
75
 
76
+ # Payload extra_files (prebuilt binaries/trees) are already hashed by content
77
+ # via the staged source tree above; fold in their executable bit here so that
78
+ # flipping +x changes the artifact hash even when bytes are unchanged. Omitted
79
+ # entirely when empty, preserving byte-identical hashes for specs with none.
80
+ if payload_exec:
81
+ h.update(b"---payload-exec---\n")
82
+ for dest, executable in sorted(payload_exec):
83
+ h.update(f"{dest}={int(executable)}\n".encode())
84
+
75
85
  return h.hexdigest()
@@ -17,6 +17,24 @@ SUPPORTED_ARCHS: tuple[str, ...] = ("arm64", "x86_64")
17
17
  SUPPORTED_PACKAGE_MANAGERS = {"pip", "npm"}
18
18
 
19
19
 
20
+ @dataclass(frozen=True)
21
+ class ExtraFile:
22
+ """A prebuilt file or directory staged into the package alongside the source.
23
+
24
+ `src` is relative to the repo root (where the caller's CI materialized it, e.g.
25
+ a downloaded + digest-pinned binary or an extracted release tree). `dest` is
26
+ where it lands in the package (relative to the package root). For a file,
27
+ `executable` sets the +x bit; for a directory, source perms are preserved and
28
+ `executable` is ignored. The bytes fold into the content hash via the staged
29
+ source tree; the executable flag folds in separately, so flipping it changes
30
+ the artifact hash even when bytes are unchanged.
31
+ """
32
+
33
+ src: str
34
+ dest: str
35
+ executable: bool = False
36
+
37
+
20
38
  @dataclass(frozen=True)
21
39
  class LambdaSpec:
22
40
  logical_name: str
@@ -30,6 +48,7 @@ class LambdaSpec:
30
48
  lambda_at_edge: bool = False
31
49
  hash_extra: str = ""
32
50
  package_json: str = ""
51
+ extra_files: tuple[ExtraFile, ...] = ()
33
52
 
34
53
  @property
35
54
  def resolved_requirements_lock(self) -> str:
@@ -63,6 +82,26 @@ class Manifest:
63
82
  builder: BuilderConfig
64
83
 
65
84
 
85
+ def _parse_extra_files(path: Path, entry: dict) -> tuple[ExtraFile, ...]:
86
+ """Parse + validate a lambda's optional [[lambda.extra_files]] entries."""
87
+ parsed: list[ExtraFile] = []
88
+ for ef in entry.get("extra_files", []):
89
+ src = ef.get("src", "")
90
+ dest = ef.get("dest", "")
91
+ if not src or not dest:
92
+ raise ValueError(
93
+ f"{path}: extra_files entry requires non-empty 'src' and 'dest' (got {ef!r})"
94
+ )
95
+ for field_name, value in (("src", src), ("dest", dest)):
96
+ if value.startswith("/") or ".." in Path(value).parts:
97
+ raise ValueError(
98
+ f"{path}: extra_files {field_name}={value!r} must be a relative path "
99
+ f"without '..' (src is repo-root-relative, dest is package-root-relative)"
100
+ )
101
+ parsed.append(ExtraFile(src=src, dest=dest, executable=bool(ef.get("executable", False))))
102
+ return tuple(parsed)
103
+
104
+
66
105
  def load_manifest(path: Path) -> Manifest:
67
106
  """Parse lambdas.toml and validate semantic invariants."""
68
107
  with path.open("rb") as f:
@@ -119,6 +158,8 @@ def load_manifest(path: Path) -> Manifest:
119
158
  f"point it at the lambda's package.json relative to repo root"
120
159
  )
121
160
 
161
+ extra_files = _parse_extra_files(path, entry)
162
+
122
163
  lambdas.append(
123
164
  LambdaSpec(
124
165
  logical_name=entry["logical_name"],
@@ -132,6 +173,7 @@ def load_manifest(path: Path) -> Manifest:
132
173
  package_manager=pkg,
133
174
  lambda_at_edge=bool(entry.get("lambda_at_edge", False)),
134
175
  hash_extra=entry.get("hash_extra", ""),
176
+ extra_files=extra_files,
135
177
  )
136
178
  )
137
179
 
@@ -7,7 +7,7 @@ import shutil
7
7
  import subprocess
8
8
  from pathlib import Path
9
9
 
10
- from repro_lambda.manifest import BuilderConfig
10
+ from repro_lambda.manifest import BuilderConfig, ExtraFile
11
11
 
12
12
 
13
13
  def _git_ls_files(repo_root: Path, source_dir: str) -> list[str]:
@@ -37,6 +37,29 @@ def _filter_paths(paths: list[str], include: list[str], exclude: list[str]) -> l
37
37
  return kept
38
38
 
39
39
 
40
+ def _stage_payload_files(
41
+ repo_root: Path, target_root: Path, payload_files: list[ExtraFile]
42
+ ) -> None:
43
+ """Stage prebuilt files/dirs (CI-materialized, not git-tracked) into the package.
44
+
45
+ Each lands at target_root/<dest> (the staged source tree, so it ships in the zip
46
+ and folds into the content hash). Files get the +x bit when `executable`; dirs
47
+ are copied recursively with source perms preserved.
48
+ """
49
+ for ef in payload_files:
50
+ src = repo_root / ef.src
51
+ dest = target_root / ef.dest
52
+ if src.is_dir():
53
+ shutil.copytree(src, dest, dirs_exist_ok=True)
54
+ elif src.is_file():
55
+ dest.parent.mkdir(parents=True, exist_ok=True)
56
+ shutil.copy2(src, dest)
57
+ if ef.executable:
58
+ dest.chmod(dest.stat().st_mode | 0o111)
59
+ else:
60
+ raise FileNotFoundError(f"extra_files src not found: {src} (declared src={ef.src!r})")
61
+
62
+
40
63
  def stage_source(
41
64
  repo_root: Path,
42
65
  source_dir: str,
@@ -44,13 +67,16 @@ def stage_source(
44
67
  stage_dir: Path,
45
68
  *,
46
69
  extra_files: list[tuple[Path, str]] | None = None,
70
+ payload_files: list[ExtraFile] | None = None,
47
71
  ) -> list[str]:
48
72
  """
49
73
  Copy git-tracked files under source_dir into stage_dir/source/, preserving perms.
50
74
 
51
- Optionally copy additional files (outside source_dir) directly into stage_dir.
52
- Each entry in extra_files is (src_path, rel_name) where rel_name is the
53
- destination path relative to stage_dir (not stage_dir/source/).
75
+ `extra_files` are build inputs (e.g. the requirements lock) copied to
76
+ stage_dir/<rel_name> - consumed by the container, not shipped in the zip.
77
+
78
+ `payload_files` are prebuilt artifacts copied into stage_dir/source/<dest> so
79
+ they ship in the zip and fold into the content hash.
54
80
 
55
81
  Returns the sorted list of relative paths (from repo_root) that were staged.
56
82
  """
@@ -71,6 +97,8 @@ def stage_source(
71
97
  if src_mode & 0o111:
72
98
  dst.chmod(dst.stat().st_mode | 0o111)
73
99
 
100
+ _stage_payload_files(repo_root, target_root, payload_files or [])
101
+
74
102
  for src_path, rel_name in extra_files or []:
75
103
  if not src_path.is_file():
76
104
  raise FileNotFoundError(f"extra_files source not found: {src_path}")
@@ -0,0 +1,222 @@
1
+ """extra_files: prebuilt file/dir bundling into the content-addressed package."""
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 BuilderConfig, ExtraFile, LambdaSpec, load_manifest
11
+ from repro_lambda.source_stager import stage_source
12
+
13
+ PINNED_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "0" * 64
14
+
15
+
16
+ def _spec(**kw) -> LambdaSpec:
17
+ base = dict(
18
+ logical_name="app",
19
+ source_dir="handler",
20
+ requirements_lock="handler/requirements.${arch}.lock",
21
+ runtime="python3.13",
22
+ arch="arm64",
23
+ handler="app.lambda_handler",
24
+ )
25
+ base.update(kw)
26
+ return LambdaSpec(**base)
27
+
28
+
29
+ # --- manifest parse / validate --------------------------------------------
30
+
31
+
32
+ def _write_manifest(tmp_path: Path, extra_files_toml: str) -> Path:
33
+ p = tmp_path / "lambdas.toml"
34
+ p.write_text(
35
+ "[[lambda]]\n"
36
+ 'logical_name = "app"\n'
37
+ 'source_dir = "handler"\n'
38
+ 'requirements_lock = "handler/requirements.${arch}.lock"\n'
39
+ 'runtime = "python3.13"\n'
40
+ 'arch = "arm64"\n'
41
+ 'handler = "app.lambda_handler"\n'
42
+ f"{extra_files_toml}"
43
+ "\n[builder]\n"
44
+ f'base_image_python = "{PINNED_IMAGE}"\n'
45
+ )
46
+ return p
47
+
48
+
49
+ def test_manifest_parses_extra_files(tmp_path: Path):
50
+ manifest = _write_manifest(
51
+ tmp_path,
52
+ '[[lambda.extra_files]]\nsrc = "builds/bin/terraform"\n'
53
+ 'dest = "bin/terraform"\nexecutable = true\n'
54
+ '[[lambda.extra_files]]\nsrc = "builds/pofix"\ndest = "pofix"\n',
55
+ )
56
+ spec = load_manifest(manifest).lambdas[0]
57
+ assert spec.extra_files == (
58
+ ExtraFile(src="builds/bin/terraform", dest="bin/terraform", executable=True),
59
+ ExtraFile(src="builds/pofix", dest="pofix", executable=False),
60
+ )
61
+
62
+
63
+ def test_manifest_extra_files_defaults_empty(tmp_path: Path):
64
+ manifest = _write_manifest(tmp_path, "")
65
+ assert load_manifest(manifest).lambdas[0].extra_files == ()
66
+
67
+
68
+ def test_manifest_rejects_extra_files_without_dest(tmp_path: Path):
69
+ manifest = _write_manifest(tmp_path, '[[lambda.extra_files]]\nsrc = "builds/bin/terraform"\n')
70
+ with pytest.raises(ValueError, match="non-empty 'src' and 'dest'"):
71
+ load_manifest(manifest)
72
+
73
+
74
+ def test_manifest_rejects_extra_files_parent_traversal(tmp_path: Path):
75
+ manifest = _write_manifest(
76
+ tmp_path,
77
+ '[[lambda.extra_files]]\nsrc = "builds/bin/x"\ndest = "../escape"\n',
78
+ )
79
+ with pytest.raises(ValueError, match="without '..'"):
80
+ load_manifest(manifest)
81
+
82
+
83
+ def test_manifest_rejects_extra_files_absolute_src(tmp_path: Path):
84
+ manifest = _write_manifest(
85
+ tmp_path,
86
+ '[[lambda.extra_files]]\nsrc = "/etc/passwd"\ndest = "bin/x"\n',
87
+ )
88
+ with pytest.raises(ValueError, match="relative path"):
89
+ load_manifest(manifest)
90
+
91
+
92
+ # --- stager ----------------------------------------------------------------
93
+
94
+
95
+ def _git_repo_with_source(tmp_path: Path) -> Path:
96
+ repo = tmp_path / "repo"
97
+ (repo / "handler").mkdir(parents=True)
98
+ (repo / "handler" / "app.py").write_text("def lambda_handler(e, c): return 200\n")
99
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
100
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
101
+ subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
102
+ subprocess.run(["git", "add", "."], cwd=repo, check=True)
103
+ subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
104
+ return repo
105
+
106
+
107
+ def test_stage_payload_file_gets_exec_bit(tmp_path: Path):
108
+ repo = _git_repo_with_source(tmp_path)
109
+ (repo / "builds" / "bin").mkdir(parents=True)
110
+ binary = repo / "builds" / "bin" / "terraform"
111
+ binary.write_bytes(b"\x7fELF fake binary")
112
+
113
+ stage = tmp_path / "stage"
114
+ stage_source(
115
+ repo_root=repo,
116
+ source_dir="handler",
117
+ builder=BuilderConfig(base_image_python=PINNED_IMAGE),
118
+ stage_dir=stage,
119
+ payload_files=[
120
+ ExtraFile(src="builds/bin/terraform", dest="bin/terraform", executable=True)
121
+ ],
122
+ )
123
+ staged = stage / "source" / "bin" / "terraform"
124
+ assert staged.read_bytes() == b"\x7fELF fake binary"
125
+ assert staged.stat().st_mode & 0o111, "executable bit not set"
126
+
127
+
128
+ def test_stage_payload_dir_recursive(tmp_path: Path):
129
+ repo = _git_repo_with_source(tmp_path)
130
+ tree = repo / "builds" / "pofix"
131
+ (tree / "data").mkdir(parents=True)
132
+ (tree / ".tool-versions").write_text("terraform 1.9.0\n")
133
+ (tree / "data" / "x.json").write_text("{}\n")
134
+
135
+ stage = tmp_path / "stage"
136
+ stage_source(
137
+ repo_root=repo,
138
+ source_dir="handler",
139
+ builder=BuilderConfig(base_image_python=PINNED_IMAGE),
140
+ stage_dir=stage,
141
+ payload_files=[ExtraFile(src="builds/pofix", dest="pofix")],
142
+ )
143
+ assert (stage / "source" / "pofix" / ".tool-versions").read_text() == "terraform 1.9.0\n"
144
+ assert (stage / "source" / "pofix" / "data" / "x.json").exists()
145
+
146
+
147
+ def test_stage_payload_missing_src_raises(tmp_path: Path):
148
+ repo = _git_repo_with_source(tmp_path)
149
+ stage = tmp_path / "stage"
150
+ with pytest.raises(FileNotFoundError, match="extra_files src not found"):
151
+ stage_source(
152
+ repo_root=repo,
153
+ source_dir="handler",
154
+ builder=BuilderConfig(base_image_python=PINNED_IMAGE),
155
+ stage_dir=stage,
156
+ payload_files=[ExtraFile(src="builds/missing", dest="bin/x")],
157
+ )
158
+
159
+
160
+ # --- hasher ----------------------------------------------------------------
161
+
162
+
163
+ def _staged_tree(tmp_path: Path) -> tuple[Path, Path]:
164
+ root = tmp_path / "source"
165
+ (root / "bin").mkdir(parents=True)
166
+ (root / "app.py").write_text("x = 1\n")
167
+ (root / "bin" / "terraform").write_bytes(b"BIN")
168
+ lock = tmp_path / "requirements.lock"
169
+ lock.write_text("")
170
+ return root, lock
171
+
172
+
173
+ def _hash(root: Path, lock: Path, *, payload_exec=None) -> str:
174
+ return compute_content_hash(
175
+ staged_source_root=root,
176
+ requirements_lock=lock,
177
+ spec=_spec(),
178
+ base_image=PINNED_IMAGE,
179
+ builder_version="0.3.0",
180
+ payload_exec=payload_exec,
181
+ )
182
+
183
+
184
+ def test_hash_none_equals_empty_payload_exec(tmp_path: Path):
185
+ root, lock = _staged_tree(tmp_path)
186
+ assert _hash(root, lock, payload_exec=None) == _hash(root, lock, payload_exec=[])
187
+
188
+
189
+ def test_hash_changes_when_executable_flag_flips(tmp_path: Path):
190
+ root, lock = _staged_tree(tmp_path)
191
+ off = _hash(root, lock, payload_exec=[("bin/terraform", False)])
192
+ on = _hash(root, lock, payload_exec=[("bin/terraform", True)])
193
+ assert off != on
194
+
195
+
196
+ def test_hash_omits_payload_section_when_empty(tmp_path: Path):
197
+ # Byte-identical to a spec with no extra_files at all (regression guard for
198
+ # the 3 existing lambdas, whose hashes must not move).
199
+ root, lock = _staged_tree(tmp_path)
200
+ assert _hash(root, lock) == _hash(root, lock, payload_exec=[])
201
+
202
+
203
+ # --- build.compute_sha_for integration -------------------------------------
204
+
205
+
206
+ def test_compute_sha_for_reflects_payload_binary(tmp_path: Path):
207
+ repo = _git_repo_with_source(tmp_path)
208
+ (repo / "handler" / "requirements.arm64.lock").write_text("")
209
+ subprocess.run(["git", "add", "."], cwd=repo, check=True)
210
+ subprocess.run(["git", "commit", "-qm", "lock"], cwd=repo, check=True)
211
+ (repo / "builds" / "bin").mkdir(parents=True)
212
+ binary = repo / "builds" / "bin" / "terraform"
213
+
214
+ builder = BuilderConfig(base_image_python=PINNED_IMAGE)
215
+ spec = _spec(extra_files=(ExtraFile(src="builds/bin/terraform", dest="bin/terraform"),))
216
+
217
+ binary.write_bytes(b"VERSION-A")
218
+ sha_a = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
219
+ binary.write_bytes(b"VERSION-B")
220
+ sha_b = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
221
+
222
+ assert sha_a != sha_b, "content hash must track the bundled binary's bytes"
@@ -646,7 +646,7 @@ wheels = [
646
646
 
647
647
  [[package]]
648
648
  name = "repro-lambda"
649
- version = "0.3.0"
649
+ version = "0.4.0"
650
650
  source = { editable = "." }
651
651
  dependencies = [
652
652
  { name = "boto3" },
File without changes
File without changes
File without changes
File without changes