funcd-bundle 0.3.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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .direnv/
8
+ .DS_Store
9
+ # the PyPI build (wheel + sdist), built at publish time
10
+ dist/
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: funcd-bundle
3
+ Version: 0.3.0
4
+ Summary: Bundle a Python funcd function and its locked dependencies for the funcd runtime
5
+ Project-URL: Homepage, https://github.com/pyvvo/funcd-python
6
+ Project-URL: Repository, https://github.com/pyvvo/funcd-python
7
+ Author: The funcd Authors
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pyyaml>=6.0.2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # funcd-bundle
14
+
15
+ Bundles a Python [funcd](https://github.com/pyvvo/funcd) function for deployment: the handler, the exact
16
+ versions of its runtime dependencies from `uv.lock` installed for the funcd runtime's Linux platform, and
17
+ its `funcdctl.yaml`. `funcdctl push` turns the bundle into one OCI artifact.
18
+
19
+ ```bash
20
+ uv add --dev funcd-bundle
21
+ uv run funcd-bundle # dist/<name>/ for linux/<host arch>
22
+ uv run funcd-bundle --platform linux/amd64 # from any host
23
+ funcdctl push dist/<name> <ref> --entry handler.py
24
+ ```
25
+
26
+ - **Functions**: every `<name>.funcdctl.yaml` in the project is a function `<name>`; with none,
27
+ `funcdctl.yaml` is one named after the project directory. The handler is the manifest's `main`, else
28
+ `<name>.py` (or `handler.py` for `funcdctl.yaml`) beside the manifest, as `funcdctl dev` reads it. A handler
29
+ in a subdirectory is copied with its directory.
30
+ - **Dependencies**: `uv export --frozen --no-dev` of the project, without `funcd-shim` and the packages
31
+ only it needs (the runtime ships them). Registry packages install as wheels only (`--only-binary
32
+ :all:`) for CPython 3.14 and manylinux glibc 2.36, with their locked hashes. Workspace members are built
33
+ as pure-Python wheels.
34
+ - **Check**: the vendored modules the handler imports are imported in `python:3.14-slim-bookworm` for the
35
+ target platform (`--no-check` skips it). A foreign platform needs QEMU on the Docker host.
36
+ - **Hermetic**: `--hermetic`, or `hermetic = true` in `[tool.funcd-bundle]`, installs with pip inside
37
+ that image and then runs `post-install`, with `FUNCD_BUNDLE_DIR` and `PYTHONPATH` set to the bundle:
38
+
39
+ ```toml
40
+ [tool.funcd-bundle]
41
+ hermetic = true
42
+ post-install = ["python", "scripts/install_extensions.py"]
43
+ ```
44
+
45
+ Docker steps copy files in and out with `docker cp` and bind no host path.
@@ -0,0 +1,33 @@
1
+ # funcd-bundle
2
+
3
+ Bundles a Python [funcd](https://github.com/pyvvo/funcd) function for deployment: the handler, the exact
4
+ versions of its runtime dependencies from `uv.lock` installed for the funcd runtime's Linux platform, and
5
+ its `funcdctl.yaml`. `funcdctl push` turns the bundle into one OCI artifact.
6
+
7
+ ```bash
8
+ uv add --dev funcd-bundle
9
+ uv run funcd-bundle # dist/<name>/ for linux/<host arch>
10
+ uv run funcd-bundle --platform linux/amd64 # from any host
11
+ funcdctl push dist/<name> <ref> --entry handler.py
12
+ ```
13
+
14
+ - **Functions**: every `<name>.funcdctl.yaml` in the project is a function `<name>`; with none,
15
+ `funcdctl.yaml` is one named after the project directory. The handler is the manifest's `main`, else
16
+ `<name>.py` (or `handler.py` for `funcdctl.yaml`) beside the manifest, as `funcdctl dev` reads it. A handler
17
+ in a subdirectory is copied with its directory.
18
+ - **Dependencies**: `uv export --frozen --no-dev` of the project, without `funcd-shim` and the packages
19
+ only it needs (the runtime ships them). Registry packages install as wheels only (`--only-binary
20
+ :all:`) for CPython 3.14 and manylinux glibc 2.36, with their locked hashes. Workspace members are built
21
+ as pure-Python wheels.
22
+ - **Check**: the vendored modules the handler imports are imported in `python:3.14-slim-bookworm` for the
23
+ target platform (`--no-check` skips it). A foreign platform needs QEMU on the Docker host.
24
+ - **Hermetic**: `--hermetic`, or `hermetic = true` in `[tool.funcd-bundle]`, installs with pip inside
25
+ that image and then runs `post-install`, with `FUNCD_BUNDLE_DIR` and `PYTHONPATH` set to the bundle:
26
+
27
+ ```toml
28
+ [tool.funcd-bundle]
29
+ hermetic = true
30
+ post-install = ["python", "scripts/install_extensions.py"]
31
+ ```
32
+
33
+ Docker steps copy files in and out with `docker cp` and bind no host path.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "funcd-bundle"
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"
5
+ description = "Bundle a Python funcd function and its locked dependencies for the funcd runtime"
6
+ authors = [{ name = "The funcd Authors" }]
7
+ readme = "README.md"
8
+ license = "Apache-2.0"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pyyaml>=6.0.2",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/pyvvo/funcd-python"
16
+ Repository = "https://github.com/pyvvo/funcd-python"
17
+
18
+ [project.scripts]
19
+ funcd-bundle = "funcd_bundle.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/funcd_bundle"]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "mypy>=1.13",
31
+ "pytest>=8.3",
32
+ "ruff>=0.8",
33
+ "types-pyyaml>=6.0.12",
34
+ ]
35
+
36
+ [tool.mypy]
37
+ strict = true
38
+ python_version = "3.12"
39
+ files = ["src", "tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 110
43
+ target-version = "py312"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "N", "UP", "B", "A", "S"]
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/*" = ["S101", "S603", "S607"]
@@ -0,0 +1,5 @@
1
+ """funcd-bundle: bundle a Python funcd function and its locked dependencies (funcd ADR-0144)."""
2
+
3
+ from funcd_bundle.bundle import BundleError, Function, bundle, discover, host_platform
4
+
5
+ __all__ = ["BundleError", "Function", "bundle", "discover", "host_platform"]
@@ -0,0 +1,371 @@
1
+ """Build a funcd Python bundle (funcd ADR-0144).
2
+
3
+ A bundle is a directory `funcdctl push <dir> <ref> --entry <handler>` turns into one OCI layer (ADR-0089):
4
+ the handler, the exact locked versions of its runtime dependencies installed for the runtime's Linux
5
+ platform, and the function's ``funcdctl.yaml``. The runtime ships ``funcd_shim`` and its dependencies, so
6
+ they are pruned.
7
+
8
+ Every container step copies its inputs in and its outputs out as a tar stream (``docker cp``) and binds no
9
+ host path, so it also works from a job container that drives another Docker daemon.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import csv
16
+ import io
17
+ import os
18
+ import platform as host
19
+ import re
20
+ import shlex
21
+ import shutil
22
+ import subprocess
23
+ import tarfile
24
+ import tempfile
25
+ import tomllib
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ import yaml
30
+
31
+ BASE_IMAGE = "python:3.14-slim-bookworm"
32
+ PYTHON_VERSION = "3.14"
33
+ GLIBC = "2_36"
34
+ MANIFEST_SUFFIX = ".funcdctl.yaml"
35
+ GENERIC_MANIFEST = "funcdctl.yaml"
36
+ DEFAULT_HANDLER = "handler.py"
37
+ # The runtime provides the shim and everything only it needs (ADR-0071: fastjsonschema).
38
+ RUNTIME_PACKAGE = "funcd-shim"
39
+ PLATFORMS = {"linux/amd64": "x86_64", "linux/arm64": "aarch64"}
40
+ SKIPPED = {"__pycache__", "tests"}
41
+ MAIN_LINE = re.compile(r"^main:.*$", re.MULTILINE)
42
+ # never streamed into a container: caches, virtualenvs and build outputs
43
+ NOT_COPIED = frozenset(
44
+ {".venv", ".git", "node_modules", "__pycache__", "dist", ".mypy_cache", ".pytest_cache", ".ruff_cache"}
45
+ )
46
+
47
+
48
+ class BundleError(Exception):
49
+ """A step failed; the message names the package, file or command."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Function:
54
+ name: str
55
+ manifest: Path
56
+ handler: Path
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Settings:
61
+ hermetic: bool
62
+ post_install: list[str]
63
+
64
+
65
+ def host_platform() -> str:
66
+ """linux/<host arch>: the platform a bundle targets by default."""
67
+ machine = host.machine().lower()
68
+ if machine in ("arm64", "aarch64"):
69
+ return "linux/arm64"
70
+ if machine in ("x86_64", "amd64"):
71
+ return "linux/amd64"
72
+ raise BundleError(f"unsupported host architecture {machine!r}; pass --platform")
73
+
74
+
75
+ def discover(project: Path) -> dict[str, Function]:
76
+ """Each <name>.funcdctl.yaml is a function <name>; with none, funcdctl.yaml is one named after the
77
+ project. The handler is the manifest's `main`, relative to the project root, else funcdctl's default
78
+ beside the manifest: <name>.py for a stem manifest, handler.py for the generic one (sdk Manifest.Main)."""
79
+ manifests = {p.name[: -len(MANIFEST_SUFFIX)]: p for p in sorted(project.glob("*" + MANIFEST_SUFFIX))}
80
+ defaults = {name: f"{name}.py" for name in manifests}
81
+ if not manifests:
82
+ generic = project / GENERIC_MANIFEST
83
+ if not generic.is_file():
84
+ raise BundleError(f"no {GENERIC_MANIFEST} or *{MANIFEST_SUFFIX} in {project}")
85
+ manifests = {project.resolve().name: generic}
86
+ defaults = {project.resolve().name: DEFAULT_HANDLER}
87
+ functions = {}
88
+ for name, manifest in manifests.items():
89
+ data = yaml.safe_load(manifest.read_text()) or {}
90
+ if not isinstance(data, dict):
91
+ raise BundleError(f"{manifest} is not a mapping")
92
+ main = data.get("main") or defaults[name]
93
+ handler = project / str(main)
94
+ if not handler.is_file():
95
+ raise BundleError(f"function {name}: handler {handler} (from {manifest.name}) does not exist")
96
+ functions[name] = Function(name=name, manifest=manifest, handler=handler)
97
+ return functions
98
+
99
+
100
+ def settings(project: Path) -> Settings:
101
+ pyproject = project / "pyproject.toml"
102
+ table: dict[str, object] = {}
103
+ if pyproject.is_file():
104
+ tool = tomllib.loads(pyproject.read_text()).get("tool", {})
105
+ table = tool.get("funcd-bundle", {})
106
+ hermetic = table.get("hermetic", False)
107
+ post_install = table.get("post-install", [])
108
+ if not isinstance(hermetic, bool):
109
+ raise BundleError("[tool.funcd-bundle] hermetic must be true or false")
110
+ if not isinstance(post_install, list) or not all(isinstance(a, str) for a in post_install):
111
+ raise BundleError("[tool.funcd-bundle] post-install must be a list of strings (an argv)")
112
+ return Settings(hermetic=hermetic, post_install=post_install)
113
+
114
+
115
+ def bundle(project: Path, name: str, platform: str, out: Path, *, hermetic: bool, check: bool) -> Path:
116
+ """Build function `name` of `project` for `platform` into `out` (replaced) and return it."""
117
+ if platform not in PLATFORMS:
118
+ raise BundleError(f"unsupported platform {platform!r}; use one of {', '.join(PLATFORMS)}")
119
+ functions = discover(project)
120
+ if name not in functions:
121
+ raise BundleError(f"no function {name!r} in {project}; found {', '.join(functions)}")
122
+ fn = functions[name]
123
+ conf = settings(project)
124
+ if conf.post_install and not hermetic:
125
+ raise BundleError("[tool.funcd-bundle] post-install runs only in a hermetic build (--hermetic)")
126
+
127
+ if out.exists():
128
+ shutil.rmtree(out)
129
+ out.mkdir(parents=True)
130
+ with tempfile.TemporaryDirectory(prefix="funcd-bundle-") as tmp:
131
+ work = Path(tmp)
132
+ registry, members = _export(project)
133
+ wheels = [_build_wheel(member, work / "wheels") for member in members]
134
+ if hermetic:
135
+ _install_in_container(project, platform, registry, wheels, conf.post_install, out, work)
136
+ else:
137
+ _install_on_host(platform, registry, wheels, out, work)
138
+ vendored = _top_level_modules(out)
139
+ sources = _copy_handler(project, fn, out)
140
+ if check:
141
+ needed = sorted(_imports(sources) & vendored)
142
+ if needed:
143
+ _check(out, platform, needed)
144
+ return out
145
+
146
+
147
+ def _uv() -> str:
148
+ uv = os.environ.get("UV") or shutil.which("uv")
149
+ if not uv:
150
+ raise BundleError("uv not found: run funcd-bundle with `uv run funcd-bundle`")
151
+ return uv
152
+
153
+
154
+ def _run(argv: list[str], *, cwd: Path | None = None, stdin: bytes | None = None) -> bytes:
155
+ try:
156
+ done = subprocess.run(argv, cwd=cwd, input=stdin, capture_output=True, check=False) # noqa: S603
157
+ except FileNotFoundError as err:
158
+ raise BundleError(f"{argv[0]} not found") from err
159
+ if done.returncode != 0:
160
+ detail = (done.stderr or done.stdout).decode(errors="replace").strip()
161
+ raise BundleError(f"`{shlex.join(argv)}` failed:\n{detail}")
162
+ return done.stdout
163
+
164
+
165
+ def _export(project: Path) -> tuple[str, list[Path]]:
166
+ """The locked runtime closure: hashed registry requirements, and the workspace members it includes."""
167
+ text = _run(
168
+ [
169
+ _uv(),
170
+ "export",
171
+ "--frozen",
172
+ "--no-dev",
173
+ "--no-emit-project",
174
+ "--no-editable",
175
+ "--prune",
176
+ RUNTIME_PACKAGE,
177
+ "--no-header",
178
+ "--no-annotate",
179
+ ],
180
+ cwd=project,
181
+ ).decode()
182
+ root = Path(_run([_uv(), "workspace", "dir"], cwd=project).decode().strip())
183
+ registry: list[str] = []
184
+ members: list[Path] = []
185
+ for entry in _requirements(text):
186
+ if entry.startswith(("./", "../", "/")):
187
+ members.append((root / entry).resolve())
188
+ else:
189
+ registry.append(entry)
190
+ return "\n".join(registry) + ("\n" if registry else ""), members
191
+
192
+
193
+ def _requirements(text: str) -> list[str]:
194
+ """Join `uv export` continuation lines into one entry per requirement."""
195
+ entries: list[str] = []
196
+ current = ""
197
+ for line in text.splitlines():
198
+ if not line.strip() or line.lstrip().startswith("#"):
199
+ continue
200
+ current += line.rstrip("\\").strip() + " " if line.endswith("\\") else line.strip()
201
+ if not line.endswith("\\"):
202
+ entries.append(current.strip())
203
+ current = ""
204
+ return entries
205
+
206
+
207
+ def _build_wheel(member: Path, dest: Path) -> Path:
208
+ dest.mkdir(parents=True, exist_ok=True)
209
+ before = set(dest.glob("*.whl"))
210
+ _run([_uv(), "build", "--wheel", "--quiet", "--out-dir", str(dest), str(member)])
211
+ built = sorted(set(dest.glob("*.whl")) - before)
212
+ if len(built) != 1:
213
+ raise BundleError(f"building workspace member {member.name} produced {len(built)} wheels")
214
+ if not built[0].name.endswith("-none-any.whl"):
215
+ raise BundleError(f"workspace member {member.name} is not pure Python ({built[0].name})")
216
+ return built[0]
217
+
218
+
219
+ def _install_on_host(platform: str, registry: str, wheels: list[Path], out: Path, work: Path) -> None:
220
+ target = [
221
+ "--target",
222
+ str(out),
223
+ "--no-deps",
224
+ "--only-binary",
225
+ ":all:",
226
+ "--python-version",
227
+ PYTHON_VERSION,
228
+ "--python-platform",
229
+ f"{PLATFORMS[platform]}-manylinux_{GLIBC}",
230
+ ]
231
+ if registry:
232
+ reqs = work / "requirements.txt"
233
+ reqs.write_text(registry)
234
+ _run([_uv(), "pip", "install", "--quiet", *target, "--require-hashes", "-r", str(reqs)])
235
+ if wheels:
236
+ _run([_uv(), "pip", "install", "--quiet", *target, *map(str, wheels)])
237
+
238
+
239
+ def _install_in_container(
240
+ project: Path,
241
+ platform: str,
242
+ registry: str,
243
+ wheels: list[Path],
244
+ post_install: list[str],
245
+ out: Path,
246
+ work: Path,
247
+ ) -> None:
248
+ pip = (
249
+ "pip install --quiet --no-cache-dir --root-user-action=ignore"
250
+ " --target /out --no-deps --only-binary :all:"
251
+ )
252
+ steps = ["set -e", "mkdir -p /out"]
253
+ if registry:
254
+ steps.append(f"{pip} --require-hashes -r /in/requirements.txt")
255
+ if wheels:
256
+ steps.append(f"{pip} /in/wheels/*.whl")
257
+ if post_install:
258
+ steps.append(shlex.join(post_install))
259
+ inputs = work / "in"
260
+ (inputs / "wheels").mkdir(parents=True)
261
+ (inputs / "requirements.txt").write_text(registry)
262
+ for wheel in wheels:
263
+ shutil.copy2(wheel, inputs / "wheels" / wheel.name)
264
+ env = ["-e", "FUNCD_BUNDLE_DIR=/out", "-e", "PYTHONPATH=/out", "-w", "/src"]
265
+ cid = _create(platform, env, ["sh", "-c", "; ".join(steps)])
266
+ try:
267
+ _copy_in(cid, inputs, "/in")
268
+ _copy_in(cid, project, "/src")
269
+ _start(cid, "hermetic install")
270
+ _copy_out(cid, "/out", out)
271
+ finally:
272
+ _run(["docker", "rm", "-f", cid])
273
+
274
+
275
+ def _check(out: Path, platform: str, modules: list[str]) -> None:
276
+ """Import the vendored modules the handler uses, in the runtime's base image for the target platform."""
277
+ code = "; ".join(f"import {m}" for m in modules)
278
+ cid = _create(platform, ["-e", "PYTHONPATH=/bundle"], ["python", "-c", code])
279
+ try:
280
+ _copy_in(cid, out, "/bundle")
281
+ _start(cid, f"import check ({', '.join(modules)}) on {platform}")
282
+ finally:
283
+ _run(["docker", "rm", "-f", cid])
284
+
285
+
286
+ def _create(platform: str, env: list[str], command: list[str]) -> str:
287
+ return _run(["docker", "create", "--platform", platform, *env, BASE_IMAGE, *command]).decode().strip()
288
+
289
+
290
+ def _start(cid: str, what: str) -> None:
291
+ done = subprocess.run(["docker", "start", "-a", cid], capture_output=True, check=False) # noqa: S603, S607
292
+ if done.returncode != 0:
293
+ detail = (done.stderr + done.stdout).decode(errors="replace").strip()
294
+ raise BundleError(f"{what} failed:\n{detail}")
295
+
296
+
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."""
299
+ buf = io.BytesIO()
300
+ 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
+ )
304
+ _run(["docker", "cp", "-", f"{cid}:/"], stdin=buf.getvalue())
305
+
306
+
307
+ def _copy_out(cid: str, src: str, dest: Path) -> None:
308
+ data = _run(["docker", "cp", f"{cid}:{src}/.", "-"])
309
+ with tarfile.open(fileobj=io.BytesIO(data)) as tar:
310
+ for member in tar.getmembers():
311
+ rel = Path(member.name)
312
+ parts = rel.parts[1:] if rel.parts and rel.parts[0] in (".", Path(src).name) else rel.parts
313
+ if not parts:
314
+ continue
315
+ member.name = str(Path(*parts))
316
+ tar.extract(member, dest, filter="data")
317
+
318
+
319
+ def _copy_handler(project: Path, fn: Function, out: Path) -> list[Path]:
320
+ """The handler file alone when it sits at the project root, else its directory tree; then the manifest.
321
+ Returns the copied Python sources."""
322
+ source = fn.handler.parent
323
+ if source.resolve() == project.resolve():
324
+ shutil.copy2(fn.handler, out / fn.handler.name)
325
+ sources = [fn.handler]
326
+ else:
327
+
328
+ def ignore(_dir: str, names: list[str]) -> list[str]:
329
+ return [n for n in names if n in SKIPPED or n.startswith(".") or n.endswith(".pyc")]
330
+
331
+ shutil.copytree(source, out, dirs_exist_ok=True, ignore=ignore)
332
+ 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())
335
+ (out / GENERIC_MANIFEST).write_text(text)
336
+ return sources
337
+
338
+
339
+ def _top_level_modules(out: Path) -> set[str]:
340
+ """The importable top-level names of the vendored distributions, from each RECORD."""
341
+ names: set[str] = set()
342
+ for record in out.glob("*.dist-info/RECORD"):
343
+ with record.open(newline="") as fh:
344
+ for row in csv.reader(fh):
345
+ if not row:
346
+ continue
347
+ first = Path(row[0]).parts[0]
348
+ if first.endswith((".dist-info", ".data", ".libs")) or first in ("bin", "__pycache__", ".."):
349
+ continue
350
+ if not (row[0].endswith((".py", ".so", ".pyd")) or "/" in row[0]):
351
+ continue
352
+ name = first.split(".")[0]
353
+ if name.isidentifier():
354
+ names.add(name)
355
+ return names
356
+
357
+
358
+ def _imports(sources: list[Path]) -> set[str]:
359
+ """The absolute top-level modules imported by the handler's sources."""
360
+ names: set[str] = set()
361
+ for src in sources:
362
+ try:
363
+ tree = ast.parse(src.read_text(), filename=str(src))
364
+ except SyntaxError as err:
365
+ raise BundleError(f"{src.name}: {err}") from err
366
+ for node in ast.walk(tree):
367
+ if isinstance(node, ast.Import):
368
+ names.update(alias.name.split(".")[0] for alias in node.names)
369
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
370
+ names.add(node.module.split(".")[0])
371
+ return names
@@ -0,0 +1,53 @@
1
+ """`uv run funcd-bundle`: build every function of the project in the current directory (funcd ADR-0144)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from funcd_bundle.bundle import BASE_IMAGE, PLATFORMS, BundleError, bundle, discover, host_platform, settings
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ parser = argparse.ArgumentParser(
14
+ prog="funcd-bundle",
15
+ description="Bundle each function of this uv project for the funcd runtime: the handler, its locked "
16
+ "dependencies for the target Linux platform, and its funcdctl.yaml.",
17
+ )
18
+ parser.add_argument("names", nargs="*", metavar="NAME", help="functions to build (default: all)")
19
+ parser.add_argument("--out", default="dist", type=Path, help="output directory (default: dist)")
20
+ parser.add_argument(
21
+ "--platform",
22
+ action="append",
23
+ choices=sorted(PLATFORMS),
24
+ help="target platform, repeatable (default: linux/<host arch>)",
25
+ )
26
+ parser.add_argument("--hermetic", action="store_true", help=f"install inside {BASE_IMAGE}")
27
+ parser.add_argument("--no-check", dest="check", action="store_false", help="skip the import check")
28
+ args = parser.parse_args(argv)
29
+
30
+ project = Path.cwd()
31
+ try:
32
+ functions = discover(project)
33
+ unknown = [n for n in args.names if n not in functions]
34
+ if unknown:
35
+ parser.error(f"unknown function(s) {', '.join(unknown)}; found {', '.join(functions)}")
36
+ hermetic = args.hermetic or settings(project).hermetic
37
+ platforms = args.platform or [host_platform()]
38
+ for name in args.names or list(functions):
39
+ for platform in platforms:
40
+ out = args.out / name
41
+ if len(platforms) > 1:
42
+ out = args.out / platform.replace("/", "-") / name
43
+ path = bundle(project, name, platform, out, hermetic=hermetic, check=args.check)
44
+ print(f"built {name} for {platform}: {path}")
45
+ print(f"funcdctl push {path} <ref> --entry {functions[name].handler.name}")
46
+ except BundleError as err:
47
+ print(f"funcd-bundle: {err}", file=sys.stderr)
48
+ return 1
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main())