funcd-bundle 0.3.0__tar.gz → 0.3.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.
@@ -8,3 +8,5 @@ __pycache__/
8
8
  .DS_Store
9
9
  # the PyPI build (wheel + sdist), built at publish time
10
10
  dist/
11
+ # scripts/agent/d's cached dev-shell environment
12
+ .cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: funcd-bundle
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Bundle a Python funcd function and its locked dependencies for the funcd runtime
5
5
  Project-URL: Homepage, https://github.com/pyvvo/funcd-python
6
6
  Project-URL: Repository, https://github.com/pyvvo/funcd-python
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "funcd-bundle"
3
3
  # Not bumped per release: the vX.Y.Z git tag is the version, stamped into the build at publish time.
4
- version = "0.3.0"
4
+ version = "0.3.2"
5
5
  description = "Bundle a Python funcd function and its locked dependencies for the funcd runtime"
6
6
  authors = [{ name = "The funcd Authors" }]
7
7
  readme = "README.md"
@@ -264,8 +264,8 @@ def _install_in_container(
264
264
  env = ["-e", "FUNCD_BUNDLE_DIR=/out", "-e", "PYTHONPATH=/out", "-w", "/src"]
265
265
  cid = _create(platform, env, ["sh", "-c", "; ".join(steps)])
266
266
  try:
267
- _copy_in(cid, inputs, "/in")
268
- _copy_in(cid, project, "/src")
267
+ _copy_in(cid, inputs, "/in", NOT_COPIED)
268
+ _copy_in(cid, project, "/src", NOT_COPIED)
269
269
  _start(cid, "hermetic install")
270
270
  _copy_out(cid, "/out", out)
271
271
  finally:
@@ -277,7 +277,8 @@ def _check(out: Path, platform: str, modules: list[str]) -> None:
277
277
  code = "; ".join(f"import {m}" for m in modules)
278
278
  cid = _create(platform, ["-e", "PYTHONPATH=/bundle"], ["python", "-c", code])
279
279
  try:
280
- _copy_in(cid, out, "/bundle")
280
+ # a vendored package may hold a dist or node_modules directory of its own: check the tree as built
281
+ _copy_in(cid, out, "/bundle", frozenset({"__pycache__"}))
281
282
  _start(cid, f"import check ({', '.join(modules)}) on {platform}")
282
283
  finally:
283
284
  _run(["docker", "rm", "-f", cid])
@@ -294,13 +295,11 @@ def _start(cid: str, what: str) -> None:
294
295
  raise BundleError(f"{what} failed:\n{detail}")
295
296
 
296
297
 
297
- def _copy_in(cid: str, src: Path, dest: str) -> None:
298
- """Stream src into the container at dest as a tar (no bind mount), leaving out caches and virtualenvs."""
298
+ def _copy_in(cid: str, src: Path, dest: str, skip: frozenset[str]) -> None:
299
+ """Stream src into the container at dest as a tar (no bind mount), leaving out the names in skip."""
299
300
  buf = io.BytesIO()
300
301
  with tarfile.open(fileobj=buf, mode="w") as tar:
301
- tar.add(
302
- src, arcname=dest.lstrip("/"), filter=lambda ti: None if Path(ti.name).name in NOT_COPIED else ti
303
- )
302
+ tar.add(src, arcname=dest.lstrip("/"), filter=lambda ti: None if Path(ti.name).name in skip else ti)
304
303
  _run(["docker", "cp", "-", f"{cid}:/"], stdin=buf.getvalue())
305
304
 
306
305
 
@@ -330,8 +329,14 @@ def _copy_handler(project: Path, fn: Function, out: Path) -> list[Path]:
330
329
 
331
330
  shutil.copytree(source, out, dirs_exist_ok=True, ignore=ignore)
332
331
  sources = [p for p in source.rglob("*.py") if not SKIPPED.intersection(p.relative_to(source).parts)]
333
- # inside the bundle the handler sits at the root, so a `main` must name it there (funcdctl dev reads it)
334
- text = MAIN_LINE.sub(f"main: {fn.handler.name}", fn.manifest.read_text())
332
+ # inside the bundle the handler sits at the root, so `main` must name it there (funcdctl dev reads it); a
333
+ # manifest without one gets one, else the bundle's generic manifest would default to handler.py
334
+ main = f"main: {fn.handler.name}"
335
+ text, found = MAIN_LINE.subn(main, fn.manifest.read_text())
336
+ if not found:
337
+ if text and not text.endswith("\n"):
338
+ text += "\n"
339
+ text += main + "\n"
335
340
  (out / GENERIC_MANIFEST).write_text(text)
336
341
  return sources
337
342
 
@@ -33,7 +33,12 @@ def main(argv: list[str] | None = None) -> int:
33
33
  unknown = [n for n in args.names if n not in functions]
34
34
  if unknown:
35
35
  parser.error(f"unknown function(s) {', '.join(unknown)}; found {', '.join(functions)}")
36
- hermetic = args.hermetic or settings(project).hermetic
36
+ conf = settings(project)
37
+ hermetic = args.hermetic or conf.hermetic
38
+ if conf.post_install and not hermetic:
39
+ parser.error(
40
+ "[tool.funcd-bundle] post-install needs a hermetic build (hermetic = true or --hermetic)"
41
+ )
37
42
  platforms = args.platform or [host_platform()]
38
43
  for name in args.names or list(functions):
39
44
  for platform in platforms:
@@ -7,6 +7,7 @@ Each skips with the reason when its requirement is absent. CI has both.
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import re
10
11
  import shutil
11
12
  import socket
12
13
  import subprocess
@@ -18,6 +19,7 @@ import pytest
18
19
 
19
20
  from funcd_bundle import BundleError, bundle, discover, host_platform
20
21
  from funcd_bundle.bundle import _check
22
+ from funcd_bundle.cli import main
21
23
 
22
24
  FOREIGN = {"linux/arm64": "linux/amd64", "linux/amd64": "linux/arm64"}
23
25
  ELF_MACHINE = {"linux/amd64": 0x3E, "linux/arm64": 0xB7}
@@ -176,8 +178,12 @@ def test_scenario_bundle_includes_workspace_package(workspace: Path, tmp_path: P
176
178
  def test_scenario_bundle_check_catches_mismatch(workspace: Path, tmp_path: Path) -> None:
177
179
  foreign = FOREIGN[host_platform()]
178
180
  out = bundle(workspace, "reader", foreign, tmp_path / "reader", hermetic=False, check=False)
179
- with pytest.raises(BundleError, match="import check"):
181
+ with pytest.raises(BundleError, match="import check") as err:
180
182
  _check(out, host_platform(), ["orjson"])
183
+ detail = str(err.value).split("\n", 1)[1]
184
+ assert re.search("ModuleNotFoundError|ImportError", detail), (
185
+ f"the import error itself is reported: {detail}"
186
+ )
181
187
 
182
188
 
183
189
  # scenario: bundle-hermetic-post-install
@@ -218,6 +224,24 @@ def test_post_install_needs_hermetic(tmp_path: Path) -> None:
218
224
  bundle(tmp_path, tmp_path.name, host_platform(), tmp_path / "out", hermetic=False, check=False)
219
225
 
220
226
 
227
+ def test_cli_post_install_needs_hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
228
+ _write(
229
+ tmp_path,
230
+ {
231
+ "pyproject.toml": (
232
+ '[project]\nname = "x"\nversion = "0"\n\n[tool.funcd-bundle]\npost-install = ["true"]\n'
233
+ ),
234
+ "funcdctl.yaml": MANIFEST,
235
+ "src/handler.py": "",
236
+ },
237
+ )
238
+ monkeypatch.chdir(tmp_path)
239
+ with pytest.raises(SystemExit) as exit_:
240
+ main(["--out", str(tmp_path / "out")])
241
+ assert exit_.value.code == 2, "a post-install without hermetic is a usage error"
242
+ assert not (tmp_path / "out").exists(), "nothing is bundled"
243
+
244
+
221
245
  def test_default_handler_follows_funcdctl(tmp_path: Path) -> None:
222
246
  _write(
223
247
  tmp_path, {"counter.funcdctl.yaml": "runtime: python314\n", "counter.py": "def handle(c, e): ...\n"}
@@ -231,6 +255,26 @@ def test_default_handler_follows_funcdctl(tmp_path: Path) -> None:
231
255
  )
232
256
 
233
257
 
258
+ def test_issue_154_stem_manifest_without_main_names_bundled_handler(tmp_path: Path) -> None:
259
+ manifest = "runtime: python314\nhandler: handle\n"
260
+ project = tmp_path / "project"
261
+ _write(
262
+ project,
263
+ {
264
+ "pyproject.toml": '[project]\nname = "extract"\nversion = "0.1.0"\nrequires-python = ">=3.12"\n',
265
+ "extract.funcdctl.yaml": manifest,
266
+ "extract.py": "def handle(ctx, event): ...\n",
267
+ },
268
+ )
269
+ _uv("lock", "--offline", "--python", "3.14", cwd=project)
270
+ out = bundle(project, "extract", host_platform(), tmp_path / "extract", hermetic=False, check=False)
271
+
272
+ assert discover(out)["extract"].handler == out / "extract.py", (
273
+ "the bundle's generic manifest resolves to the bundled handler, not handler.py"
274
+ )
275
+ assert (out / "funcdctl.yaml").read_text() == manifest + "main: extract.py\n"
276
+
277
+
234
278
  def test_missing_handler_is_named(tmp_path: Path) -> None:
235
279
  _write(tmp_path, {"funcdctl.yaml": "runtime: python314\nmain: src/nope.py\n"})
236
280
  with pytest.raises(BundleError, match="nope.py"):
File without changes
File without changes