Add a cap from scratch
The step-by-step. Substitute your cap name (say, blake3) throughout.
Every path below is real; every step is one you can copy.
Shortcut: scripts/scaffold-cap.sh writes steps 1, 3, 4, 5, and
8's file skeletons for you. The manual walkthrough below is what to do
when the shortcut can't decide (e.g. you're subclassing an existing
cap, or the facade shape is unusual).
Fast-path commands
make all # fetch SDK + CPython, cross-build python.wasm
make python-composed # wac plug + install shims → build/pylon.wasm
make smoke # run every tests/*_smoke.py under pylon-launch
make smoke-audit # three fast audits (~30s): compose-paths,
# dotted-aliases, py314-patterns
make smoke-audit-full # smoke-audit + full module-hygiene sweep (20+ min)
make check-drift # port-drift + PyPI drift
make check-licenses # third-party license check
make probe FILE=<path> # ad-hoc: run any .py file under
# pylon-launch, auto-stage + tear down
Regen the plan JSON (whenever caps are added, removed, or a sibling
.wasm moves on disk):
python3 scripts/build-pylon-dynlink-plan.py
Install/refresh Python-side shims from every cap's pyforge-pkg.toml
(idempotent):
python3 scripts/install-cap-shims.py
Scaffold a new cap in one command:
scripts/scaffold-cap.sh --help
scripts/scaffold-cap.sh --facade blake3 blake3 tegmentum blake3 hash 0.1.0
0. Pick the linkage mode
Skim Linkage modes. The default answer is compose-time static unless the vendor library is small AND the cap does not need to hold C handles across calls.
1. Ship the sibling repo (or point at one)
The wasm side lives outside this repo:
~/git/blake3-wasm/ # Rust crate: cargo-component build produces
# target/wasm32-wasip1/release/blake3_wasm.wasm
# (build.rs wraps it into a component)
For a runtime-dynlink provider, use
packages-wasm/<name>-dynlink-provider/ in this repo (see the
crc32c and snappy providers for shape).
At minimum, the sibling exports the WIT interface your Python-side C
ext will import. wac plug fails if the interface version does not
match, so pin version = "0.1.0" (or whatever) on both sides.
2. Write the WIT contract
mkdir -p cpython-ext/_blake3_cap/wit
cat > cpython-ext/_blake3_cap/wit/blake3-import.wit <<'EOF'
package tegmentum:python-blake3@0.1.0;
world blake3-import {
import blake3:component/hasher@0.1.0;
}
EOF
The world names what your C ext IMPORTS. Do NOT put any exports
here — CPython is the consumer, not a component in the wasm graph.
3. Generate the C bindings
mkdir -p cpython-ext/_blake3_cap/gen
cd cpython-ext/_blake3_cap
wit-bindgen c wit/ \
--world blake3-import \
--out-dir gen/
You should now have gen/blake3_cap_import.{c,h} and the associated
_component_type.o. All three are checked in — the CPython
cross-build does not have wit-bindgen on $PATH.
Pin wit-bindgen-cli at 0.57.1 to stay compatible with the sibling
component's generator.
4. Write the C extension
Copy from a similar cap. For a compose-static cap without handles,
cpython-ext/_case_cap/_case_capmodule.c is one of the smallest. For
a runtime-dynlink cap,
cpython-ext/_crc32c_dynlink_cap/_crc32c_dynlink_capmodule.c is the
canonical template.
Skeleton:
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include "gen/blake3_cap_import.h"
static PyObject *
py_hash(PyObject *self, PyObject *args)
{
Py_buffer data;
if (!PyArg_ParseTuple(args, "y*", &data)) return NULL;
blake3_cap_import_list_u8_t input = {
.ptr = (uint8_t *)data.buf,
.len = (size_t)data.len,
};
blake3_cap_import_list_u8_t output = {0};
/* wit-bindgen-c: bool ok = 1 (success) / 0 (error). Never `if (ret)`. */
bool ok = blake3_component_hasher_hash(&input, &output);
PyBuffer_Release(&data);
if (!ok) {
PyErr_SetString(PyExc_RuntimeError, "blake3 hasher failed");
return NULL;
}
PyObject *py = PyBytes_FromStringAndSize((const char *)output.ptr, output.len);
blake3_cap_import_list_u8_free(&output); /* NEVER plain free() */
return py;
}
static PyMethodDef methods[] = {
{"hash", py_hash, METH_VARARGS, "Compute blake3 hash of bytes."},
{NULL, NULL, 0, NULL},
};
static struct PyModuleDef mod_def = {
PyModuleDef_HEAD_INIT, "_blake3_cap", NULL, -1, methods,
};
PyMODINIT_FUNC PyInit__blake3_cap(void) {
return PyModule_Create(&mod_def);
}
Two invariants that bite here:
bool ok = wit_fn(...). wit-bindgen-c returns 1 on success, 0 on error. Writingif (wit_fn(...))gets you a shipped-twice bug.- Free wit-bindgen-owned
list<T>outputs with the emitted*_list_T_free()— never plainfree(). Plain free crashes indlfreeon the first call after a successful op.
5. Write the pyforge-pkg.toml manifest
schema = "tegmentum:pylon-pyforge/pkg@0.1.0"
[package]
name = "blake3-cap"
version = "0.1.0"
description = "blake3 hashing over blake3:component/hasher"
pattern = "A"
[extension]
srcdir = "_blake3_cap"
module_name = "_blake3_cap"
c_file = "_blake3_capmodule.c"
wit_dir = "wit"
gen_dir = "gen"
gen_import_c = "blake3_cap_import.c"
gen_import_obj = "blake3_cap_import_component_type.o"
[[capabilities.required]]
interface = "blake3:component/hasher"
version = "0.1.0"
# Optional: user-facing Python facade shipped to Lib/blake3.py.
[[provides]]
module = "blake3"
shim = "blake3.py"
dest = "Lib/blake3.py"
purpose = "Route Python blake3 calls through blake3-wasm"
version = "0.1.0"
[gating]
include_forges = []
exclude_forges = []
6. Wire the C ext into Modules/Setup.local
scripts/wire-cpython-ext.sh reads every
cpython-ext/*/pyforge-pkg.toml and emits the matching lines. Run it
after adding the manifest:
bash scripts/wire-cpython-ext.sh
This appends to deps/cpython-3.14/Modules/Setup.local and to the
per-profile cross-build/wasm32-wasip2/Modules/Setup.local. The next
make build picks up your new C ext.
7. Register the cap in the compose script and plan builder
Two lists to update — they must both know about the new cap:
scripts/compose-python-component.sh: add an env-var default near the top:and aBLAKE3_COMPONENT="${BLAKE3_COMPONENT_WASM:-$HOME/git/blake3-wasm/target/wasm32-wasip1/release/blake3_wasm.wasm}"--plug "$BLAKE3_COMPONENT"line inside thewac plugargument list. Ordering matters: plug the satisfier AFTER the importer(s) if the cap shares an interface with other components (see the zstd-after-arrow comment in the script).scripts/build-pylon-dynlink-plan.py: add a tuple to theCAPSlist —("blake3", "BLAKE3_COMPONENT_WASM", HOME / "git/blake3-wasm/target/wasm32-wasip1/release/blake3_wasm.wasm").
8. Rebuild + install shims + regen plan
make python-composed # rebuilds python.wasm, plugs, produces pylon.wasm
python3 scripts/install-cap-shims.py # copies Lib/blake3.py from your pyforge-pkg.toml
python3 scripts/build-pylon-dynlink-plan.py # regenerates plans/python-pylon-dynlink.json
If the sibling .wasm is missing on disk,
build-pylon-dynlink-plan.py skips it and prints a warning at the
end. Run the sibling repo's build first.
9. Add a smoke test
# tests/blake3_smoke.py
"""Smoke: _blake3_cap → blake3-wasm end-to-end.
Run:
cp tests/blake3_smoke.py deps/cpython-3.14/Lib/_blake3_smoke.py
PYLON_CPYTHON_DIR=$PWD/deps/cpython-3.14 \
packaging/launcher-rs/target/release/pylon-launch \
plans/python-pylon-dynlink.json -c 'import _blake3_smoke'
rm deps/cpython-3.14/Lib/_blake3_smoke.py
"""
import blake3
assert blake3.hash(b"").hex() == "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
print("=== 1/1 PASS ===")
make smoke picks it up automatically (any tests/*_smoke.py runs).
10. Commit
Conventional-commits format. Example:
feat(_blake3_cap): blake3 hashing over blake3:component/hasher