Tutorial: wrap an OLS regression library
This walks through the full pipeline end to end: write a small Julia library, run build_library to compile it and emit wrappers, pip install the generated package, and call it from Python. The worked example lives in examples/ols/ and stays buildable as the package evolves.
The subject is ordinary least squares (OLS) regression, which exercises several JLWInterop types:
| JLWInterop type | Where it appears in ols |
|---|---|
CMatrix{Float64} | design matrix X |
CVector{Float64} | response y, coefficients, predictions |
Float64 (primitive) | r_squared |
CString | output buffer for summary_report |
JLWStatus (direct) | return of predict |
JLWStatus (embedded) | FitResult.status field |
CMatrix and CVector are specific cases of CArray, the multi-dimensional array type in JLWInterop.
The core of the algorithm is a single statement, X \ y, using Julia's own LinearAlgebra for computation.
1. The Julia source
examples/ols/src/ols.jl defines three Base.@ccallable entrypoints. Note that all three of these take arguments with types defined in JLWInterop:
module ols
using JLWInterop
using LinearAlgebra
struct FitResult
status::JLWStatus
coeffs::CVector{Float64}
r_squared::Float64
end
Base.@ccallable function fit(X::CMatrix{Float64},
y::CVector{Float64},
coeffs_buf::CVector{Float64})::FitResult
# … shape checks, then `coeffs = X \\ y`; copy into `coeffs_buf`,
# compute R^2, and return FitResult with JLWStatus(0,…) on success
# or JLWStatus(code, msg) on a recognized failure.
end
Base.@ccallable function predict(coeffs::CVector{Float64},
X::CMatrix{Float64},
out::CVector{Float64})::JLWStatus
Base.@ccallable function summary_report(result::FitResult,
buf::CString)::JLWStatuscoeffs_buf and out are caller-allocated buffers: the library writes into them but does not own them. This is generally true for JLWInterop types that hold pointers, CArray and CString.
Errors travel back as a JLWStatus, either returned directly (predict, summary_report) or embedded in a return struct (fit's FitResult). The Python emitter recognizes both forms and translates a non-zero code into a JLWError exception — see Error handling.
2. The entry Project.toml
A minimal Project.toml for the library:
name = "ols"
uuid = "7e81292c-b63a-42d7-9477-255b6fedc2ed"
version = "0.1.0"
[deps]
JLWInterop = "65e54657-ed21-41a3-96db-71ab7fa6d94b"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
[compat]
JLWInterop = "0.1"
julia = "1.13"There are two things to point out:
The requirement for
julia = "1.13"cannot be changed to an earlier Julia release, as the features needed to build language bindings shipped in Julia 1.13. The OLS example here calls into BLAS via\, and that path needs Julia 1.13.0-rc2 or later (or any build of thebackports-release-1.13branch from 2026-05-20 onward).The
[deps]here describe what must be baked intools.so. Keep it minimal, without build tooling or test dependencies. If you need a[sources]entry to point at a local checkout, use an absolute path:juliacrelocates the project into a temporary directory before compiling, so relative[sources]paths cannot be resolved, andbuild_libraryrejects them.
3. Build the library and Python package
Two distinct environments are in play during a build:
- The entry project (
examples/ols/Project.toml, activated byjulia --project=.) declares the runtime deps just described. - A build-env (
examples/ols/build-env/Project.toml) declares the build tooling — JuliaLibWrapping and JuliaC.build.jlpushes this directory ontoLOAD_PATHso thatusing JuliaLibWrappingresolves there. Keeping the build tooling out of the entry project's[deps]is what lets your library remain reasonably minimal.
The build-env's Project.toml is just:
[deps]
JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2"
JuliaLibWrapping = "d61f35a8-f6af-436f-bc10-cee6b101f7bd"
[compat]
JuliaC = "0.3"
JuliaLibWrapping = "0.1"
julia = "1.13"Henceforth it will be assumed that you are in the directory containing both environments (e.g., examples/ols):
Instantiate each environment from your shell:
julia --project=build-env -e 'using Pkg; Pkg.instantiate()'
julia --project -e 'using Pkg; Pkg.instantiate()'To handle the two-environment split, we'll push build-env onto LOAD_PATH, making it reachable, and then pop it again once the build concludes. Here is the build.jl script:
push!(LOAD_PATH, joinpath(@__DIR__, "build-env"))
using JuliaLibWrapping, JuliaC
standard_build(@__DIR__; libname = "ols", verbose = true)
pop!(LOAD_PATH)using JuliaC is what activates JuliaLibWrapping's weak dependency on JuliaC.jl; without it build_library errors with a hint pointing at this line.
standard_build is a convenience wrapper around build_library for the conventional layout — src/<libname>.jl as the entry, out/ as the artifact directory, both a C header and a bundled Python ctypes package named <libname>_py. For layouts outside that convention, or to drop one of the targets, call build_library directly; standard_build's docstring shows the equivalent expansion.
Then run the build from your shell:
julia --project=. build.jlAfter a successful build, out/ contains:
out/
├── ols.so # compiled shared library
├── ols.abi.json # ABI metadata emitted by juliac
├── ols.h # C header (CTarget output)
├── pyproject.toml # Python package metadata
└── ols_py/
├── __init__.py
├── _lowlevel.py # mechanical ctypes bindings (regenerated every build)
├── _facade.py # idiomatic surface (written once; user-editable)
└── bundle/ # juliac --bundle tree: libjulia, stdlibs, BLAS, …bundle = true is essential for a pip install user who has no Julia on their machine. See the bundling section of the overview for what is in the bundle tree and how the loader finds libjulia from inside the wheel.
4. Install the Python package
Create a clean virtualenv with no system Julia, and install. Put the virtualenv outside the entry-project directory (here, /tmp/ols-venv): juliac copies the whole project tree on every build, and a venv nested inside it has symlinks that break that copy.
python -m venv /tmp/ols-venv
source /tmp/ols-venv/bin/activate
pip install numpy
pip install -e ./outThe leading ./ matters: pip install out would treat out as a package name and fetch an unrelated project from PyPI. The -e (editable) flag installs the package in place, so the _facade.py edits you make in the next section take effect without reinstalling.
The bundled libjulia and stdlibs live inside the wheel; the loader in _lowlevel.py searches bundle/lib/ first so the baked-in RUNPATH resolves them at import time, with no LD_LIBRARY_PATH required.
5. Call it from Python
Verifying that the basics work
To verify that things work, first we'll try calling predict, which is auto-wrapped because its arguments and return are all recognized as known types to JuliaLibWrapping's emitter. We'll call predict twice, once with correct inputs and once with incorrect ones to verify that errors work as expected:
import numpy as np
from ols_py import predict, JLWError
coeffs = np.array([0.06, 1.98])
X = np.asfortranarray(np.column_stack([np.ones(5), np.arange(1.0, 6.0)]))
out = np.zeros(5)
predict(coeffs, X, out)
# out is now X @ coeffs
try:
bad = np.asfortranarray(np.zeros((5, 3))) # wrong number of columns
predict(coeffs, bad, out)
except JLWError as e:
print(e.code, e.message) # 1, "coeffs length must match X cols"This should be run (or copy/pasted line-by-line) in a python shell running in the activated environment. If out is array([2.04, 4.02, 6. , 7.98, 9.96]), and you see the expected error message, all is working as expected.
np.asfortranarray is required for any CMatrix{T} argument: JLWInterop's CArray is column-major, and the automatically created façade rejects a row-major view rather than silently transposing. In a moment you'll see how to edit the wrapper, so you can choose any interface you wish.
Making edits to the wrapper
In contrast with predict, fit is not automatically wrapped: it returns a FitResult, and JuliaLibWrapping declines to make choices about what that should look like from the Python perspective. The starter façade re-exports it from _lowlevel with a TODO: hand-wrap comment naming the obstacle. You edit _facade.py to provide the wrapper you want. The mechanical layer still raises JLWError on a non-zero status, so a sklearn-flavored hand wrap is just:
# in ols_py/_facade.py, replacing the auto-generated TODO line
def fit(X, y):
X = np.asfortranarray(X)
y = np.ascontiguousarray(y, dtype=np.float64)
coeffs = np.zeros(X.shape[1])
result = _lowlevel.fit(
_lowlevel.CMatrix_Float64.from_numpy(X),
_lowlevel.CVector_Float64.from_numpy(y),
_lowlevel.CVector_Float64.from_numpy(coeffs),
)
return coeffs, resultNote that fit accepts a plain (row-major) X and converts it with np.asfortranarray internally, so callers are spared the manual step that predict required. Alongside the coefficients we return the raw result struct, which carries r_squared and is what summary_report (next) consumes. A production wrapper might instead bundle these into a small result object exposing coeffs, r_squared, and a .summary() method; this returns the pieces directly to keep the example short.
summary_report is also a hand-wrap case (its FitResult argument is an unrecognized struct). The caller allocates a writable CString buffer, passes it in, and decodes the bytes after the call:
def summary_report(result_struct, capacity=256):
import ctypes
buf_bytes = (ctypes.c_uint8 * capacity)()
buf = _lowlevel.CString(
length=capacity,
data=ctypes.cast(buf_bytes, ctypes.POINTER(ctypes.c_uint8)),
)
_lowlevel.summary_report(result_struct, buf)
return bytes(buf_bytes).rstrip(b"\x00").decode("utf-8")Trying out fit and summary_report
With both wrappers in place, the two compose directly. fit is the inverse direction of predict: hand it a design matrix and observed responses, get back the coefficients. To make the result easy to check, we feed it the predictions from the predict call above as the observations — a perfectly linear y, so fit should recover the same [0.06, 1.98] with an R² of 1.0. Restart Python first so the _facade.py edits are picked up (the editable install means no reinstall is needed):
import numpy as np
from ols_py import fit, summary_report
X = np.column_stack([np.ones(5), np.arange(1.0, 6.0)]) # row-major is fine here
y = np.array([2.04, 4.02, 6.0, 7.98, 9.96]) # the predictions from §5, now treated as data
coeffs, result = fit(X, y)
print(coeffs) # ≈ [0.06, 1.98] — the coefficients predict() used
print(result.r_squared) # 1.0 (the points lie exactly on a line)
print(summary_report(result)) # "OLS fit: 2 coefficients, R^2 = 1.0"The result returned by fit is exactly the FitResult struct summary_report expects, so the two chain without any glue. Try perturbing one entry of y and re-running: the coefficients shift slightly and result.r_squared drops below 1.0, which the summary string reflects.
6. Adding an entrypoint later
When you add a new Base.@ccallable to ols.jl:
_lowlevel.pyis regenerated on everywrite_wrapper/build_librarycall — your new entrypoint shows up automatically._facade.pyis written once and then never touched. To pick up new entrypoints in the starter façade, delete the file and rebuild; JuliaLibWrapping will regenerate it (auto-wrapping where it can, leaving# TODO: hand-wrapmarkers where it cannot).__init__.pyis regenerated to re-export from_facade.
You trigger all of this by re-running the same build:
julia --project=. build.jlwith one current caveat: the build is not yet idempotent over an existing out/. The bundle step copies files without overwriting, so a second build into a populated out/ fails with something like '…/ols-bundle/share/julia/cert.pem' exists. Until JuliaLibWrapping#46 is resolved, clear the bundle tree first:
rm -rf out/ols-bundle
julia --project=. build.jlClear only the bundle tree, not the whole out/: a blanket rm -rf out also deletes your hand-edited _facade.py, which would then be regenerated as a fresh starter (losing your wrappers). _lowlevel.py, pyproject.toml, and __init__.py are rewritten in place, and because you installed with pip install -e, a restarted Python session picks them up with no reinstall.
A common pattern is to keep _facade.py checked into your own repository alongside the build script, treating it as hand-written glue. If you need to automatically wrap new functions, currently the best option is to create a branch in which you delete it, regenerate it with a fresh build, and then copy the new pieces you want to keep into your existing hand-edited _facade.py and make any additional edits needed for the new code.