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.
- funcd_bundle-0.3.0/.gitignore +10 -0
- funcd_bundle-0.3.0/PKG-INFO +45 -0
- funcd_bundle-0.3.0/README.md +33 -0
- funcd_bundle-0.3.0/pyproject.toml +49 -0
- funcd_bundle-0.3.0/src/funcd_bundle/__init__.py +5 -0
- funcd_bundle-0.3.0/src/funcd_bundle/bundle.py +371 -0
- funcd_bundle-0.3.0/src/funcd_bundle/cli.py +53 -0
- funcd_bundle-0.3.0/tests/test_bundle.py +237 -0
- funcd_bundle-0.3.0/uv.lock +421 -0
|
@@ -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,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())
|