How to build, test and extend erlang_python. Read this before your first change; each recipe lists every file a change of that kind touches, so nothing is left half done. Design background is in architecture, file ownership in code map, message shapes in protocols.
You need OTP 27 or later, Python 3.12 or later with headers, CMake and a C compiler.
rebar3 compile # runs do_cmake.sh / do_build.sh, builds priv/py_nif.so
PYTHON_CONFIG=python3.13-config rebar3 compile # pick an interpreterNotes:
c_src/py_nif.cincludes the other.cfiles; there is no per-file compile. A change in anyc_srcfile rebuilds the one NIF.- The interpreter used at build time is the one embedded at run time.
python3-configonPATHis the default. rm -rf _buildwhen switching Python versions or build flags; CMake caches the interpreter.
rebar3 ct --readable=compact # everything
rebar3 ct --suite test/py_isolated_SUITE # one suite
rebar3 ct --suite test/py_context_SUITE --case test_call # one case
rebar3 dialyzer && rebar3 xref # required before a PR
make lint-docs # snippets in README and docs/
make check-code-map # every file in the code mapNotes:
test/test.configturns memory limits on for the whole run; it is applied automatically.- The
testprofile pullsiommapfor the shared memory suites; users add it to their own deps. - Free-threaded Python: build and run with
PYTHON_GIL=0and a3.13tinterpreter; cases that need the GIL skip themselves. - ASan: configure CMake by hand, then let rebar3 pick the objects up:
rm -rf _build && mkdir -p _build/cmake && cd _build/cmake
cmake ../../c_src -DENABLE_ASAN=ON -DENABLE_UBSAN=ON && cmake --build . && cd ../..
rebar3 compile
LD_PRELOAD=$(gcc -print-file-name=libasan.so) ASAN_OPTIONS=detect_leaks=0 rebar3 ctCI runs the matrix in .github/workflows/ci.yml: OTP 27 to 29 with Python
3.12 to 3.14 on Ubuntu and macOS, FreeBSD 14 through vmactions/freebsd-vm,
free-threaded 3.13t, and ASan. A PR is merged when all of them are green.
CI covers FreeBSD, but the isolated mode (procctl, no cgroups, no
/dev/shm) is easier to debug in a local VM. On an Apple Silicon Mac:
# 1. image (arm64 on Apple Silicon, amd64 elsewhere)
curl -O https://download.freebsd.org/releases/VM-IMAGES/14.1-RELEASE/aarch64/Latest/FreeBSD-14.1-RELEASE-arm64-aarch64.qcow2.xz
xz -d FreeBSD-14.1-RELEASE-arm64-aarch64.qcow2.xz
qemu-img resize FreeBSD-14.1-RELEASE-arm64-aarch64.qcow2 +20G
# 2. boot headless, ssh on host port 2222
qemu-system-aarch64 -M virt -accel hvf -cpu host -m 4096 -smp 4 \
-bios /opt/homebrew/share/qemu/edk2-aarch64-code.fd \
-drive file=FreeBSD-14.1-RELEASE-arm64-aarch64.qcow2,if=virtio,format=qcow2 \
-netdev user,id=n0,hostfwd=tcp::2222-:22 -device virtio-net-pci,netdev=n0 \
-nographic -serial mon:stdioOn the console, log in as root (no password), set one, and enable ssh:
passwd
sysrc sshd_enable=YES
sed -i '' 's/^#PermitRootLogin no/PermitRootLogin yes/' /etc/ssh/sshd_config
service sshd start
pkg install -y erlang-runtime28 python313 py313-numpy cmake gmake git
fetch -o /root/rebar3 https://github.com/erlang/rebar3/releases/download/3.25.0/rebar3
chmod +x /root/rebar3Then from the host, ship the tree and run:
git archive --format=tgz -o /tmp/ep.tgz HEAD
scp -P 2222 /tmp/ep.tgz root@127.0.0.1:/root/
ssh -p 2222 root@127.0.0.1 'rm -rf ep && mkdir ep && tar -C ep -xzf ep.tgz && cd ep \
&& export PATH=/usr/local/lib/erlang28/bin:$PATH PYTHON_CONFIG=python3.13-config \
&& /root/rebar3 compile && /root/rebar3 ct --readable=compact'erlang-runtime28 installs outside the default PATH; pkg info -l erlang-runtime28 | grep bin/erl shows where. Use -accel kvm and the
amd64 image on Linux hosts.
Every recipe ends with the same three steps: a test case, rebar3 dialyzer && rebar3 xref, and an entry in CHANGELOG.md under the
unreleased version.
- Implement
static ERL_NIF_TERM nif_x(ErlNifEnv*, int, const ERL_NIF_TERM[])in thec_srcfile that owns the area (seec_src/README.md). - Add
{"x", Arity, nif_x, Flags}to thePY_*_NIFSmacro at the end of that file (nif_funcs[]inc_src/py_nif.cconcatenates them; NIFs that live inpy_nif.cgo in its own block there).FlagsisERL_NIF_DIRTY_JOB_CPU_BOUNDorERL_NIF_DIRTY_JOB_IO_BOUNDwhen the NIF can block or run Python,0otherwise. - Add the stub, its
-specand a@doctosrc/py_nif.erl, and the export. - Follow the rules in
c_src/README.md: only the context thread touches a context's Python objects; release the GIL around every blocking wait; respect the lock order onpy_context_t. - Test it from a suite through the Erlang API that uses it, not through
py_nifdirectly, unless it is a test helper.
The erlang module has three implementations that must agree:
- Embedded modes, C: add
erlang_x_impland its entry in the method table ofc_src/py_callback.c(PyMethodDef erlang_methods). Names starting with_are internal helpers for the Python package. - Embedded modes, Python: if the function is written in Python, add it to
priv/_erlang_impl/__init__.pyand to__all__; the C module copies it ontoerlangat import (see the bootstrap code near the end ofpy_callback.c). - Isolated mode: add it to
install_erlang_moduleinpriv/_erlang_impl/_isolated.py. Anything the child cannot support goes through_not_supported(name)so the user gets a clearRuntimeError, never a silent difference. - Document it in the guide of its area and in
README.md(API reference section), and add the row totest/coverage_audit.md. - Test it in a cross-mode suite so it runs in
workerandisolatedgroups.
Options arrive as the map given to py_context:new/1. Pool contexts are
started by py_context_sup:start_context/2 with the mode only, so an
option that must apply pool-wide also needs py_context_router and the
supervisor to carry it.
- Embedded modes: read it where the context is set up in
src/py_context.erl(init/4and the helpers it calls;memory_limit,preload,ownerandstart_timeoutare the existing examples). If the C side needs it, pass it tonif_context_createand extend the options parsing there. - Isolated mode: read it in
src/py_isolated.erl(start_child/1assembles the child command line and environment;rlimits,cgroup,python,restart,max_restarts,kill_afterare the examples) and, if the child must know, add it to the{init, Opts}request handled by_initinpriv/_erlang_impl/_isolated.py. - If the option makes no sense in one mode, reject it there with an error that names the option rather than ignoring it.
- Document it in the options table of the relevant guide
(
docs/isolated.md,docs/workers.md,docs/memory.md) and test both the effect and the rejection.
- Name it
test/py_<area>_SUITE.erl; Python helpers go intest/py_test_<area>.pyand are imported aftersys.path.insert(0, TestDir)(seepy_reentrant_SUITEfor the pattern). - Start and stop the application in
init_per_suite/end_per_suite; the default pool ispy:start_contexts/0. - Behaviour every mode must share runs in groups:
groups/0withworkerandisolated, contexts created withpy_context:new(#{mode => Mode})frominit_per_group(py_isolated_SUITEshows the layout). - Skip, do not fail, when a platform or interpreter cannot run a case:
{skip, Reason}with the reason a human can act on. - Add the suite to the table in
docs/code-map.mdand the cases that cover a documented API totest/coverage_audit.md; a new module also needs a row in its Modules table.make check-code-mapverifies both. - Cases that measure time or memory print their numbers with
ct:paland assert only on invariants, never on absolute timings.
- Create
docs/<name>.mdin the task-oriented form: one paragraph on what it is and when you need it, then steps with code, then short notes. Second person, Erlang snippets, no hype. - Every Erlang snippet must call real exports at the right arity and
every Python snippet must parse:
make lint-docschecks both.<!-- skip-lint -->above a fence exempts it; say why in the prose. - Register the page in both
extraslists ofrebar.config(the flat list and the grouped one) sorebar3 ex_docbuilds it, and link it from the guide or README section that leads to it. - If the page documents a new API, add its rows to
test/coverage_audit.md.
rebar3 ct,rebar3 dialyzer,rebar3 xref,make lint-docsall clean locally.CHANGELOG.mdupdated under the unreleased version:Added,Changed,RemovedorFixed. Removing a public function is a major version.- A change that reverses or extends a decision in
docs/decisions/gets a new record there; the old one is not edited. - The PR text says what the change intends and which path it takes; the diff already lists the files.
- One squashed commit per PR.