Skip to main content

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. Writing if (wit_fn(...)) gets you a shipped-twice bug.
  • Free wit-bindgen-owned list<T> outputs with the emitted *_list_T_free() — never plain free(). Plain free crashes in dlfree on 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:
    BLAKE3_COMPONENT="${BLAKE3_COMPONENT_WASM:-$HOME/git/blake3-wasm/target/wasm32-wasip1/release/blake3_wasm.wasm}"
    and a --plug "$BLAKE3_COMPONENT" line inside the wac plug argument 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 the CAPS list — ("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