POST Python ABI Commitments¶
POST Python compiles a typed subset of Python to a
shared library. That library's stable C ABI is a set of pp_* export
symbols — the wrappers external code links against — described in a versioned
JSON export manifest emitted at build time (post-py build --emit-manifest).
Everything else in the library (the __pp_* compute kernels, helper symbols) is
private implementation detail that is free to churn.
abicheck treats that manifest as the ABI contract and gives you two things:
| You want to… | Use |
|---|---|
| Compare two builds but only flag changes to the committed surface | abicheck compare … with contract.overlays.post_manifest in .abicheck.yml |
| Check the manifest itself — is it consistent with the binary? did it break vs the previous version? | the abicheck.post_manifest Python API (below) |
The manifest¶
A POST manifest is a JSON document. The top level requires post_abi (an
integer that must increase on any ABI-breaking release) and exports (the
committed symbols); each export must declare its signature — params and
return_dtype (a no-arg/void export spells "params": [] / "return_dtype":
""). c_symbol defaults to pp_<name> when omitted, so an export needs at
least a name or an explicit c_symbol:
{
"post_abi": 1,
"exports": [
{
"name": "gammaln",
"c_symbol": "pp_gammaln",
"params": ["Float64"],
"return_dtype": "Float64",
"ufunc": { "loop_symbol": "pp_gammaln_ufunc_loop", "signature": "()->()" }
}
]
}
| Field | Meaning |
|---|---|
post_abi |
Contract version. Must be a genuine integer; must bump on a breaking change. |
exports[] |
One entry per committed export (must be present — a genuinely empty surface is "exports": []). |
c_symbol |
The committed pp_* C symbol external code links against. Defaults to pp_<name> if omitted (the POST convention); a manifest whose real symbol was renamed independently of name is caught by manifest↔binary validation, not the manifest-only gate. |
params / return_dtype |
Required dtypes forming the export's signature. A param is a dtype string ("Float64") or an object with a string dtype ({"dtype": "Float64", "is_array": true}); return_dtype is a string ("" = void). |
ufunc.loop_symbol |
For vectorized exports, the committed NumPy-ufunc loop symbol (also part of the surface). |
Unknown fields are tolerated (the spec is a draft); malformed ones — a missing
post_abi/exports, a non-integer version, a duplicate c_symbol, a parameter
object with no dtype — are rejected so a bad manifest cannot quietly hide a
change.
contract.overlays.post_manifest — scope a diff to the committed surface¶
When you compare two POST builds, most of the diff is private-kernel churn you
do not care about. Name the manifest in the project's .abicheck.yml and
abicheck compare scopes the verdict to the committed pp_*/ufunc-loop
symbols only:
Name the config with --config: the overlay narrows what gates, so an
auto-discovered .abicheck.yml (one a pull request could edit) is not
trusted to apply it, and the run says so on stderr. In the GitHub Action,
point the build-config input at the reviewed config.
- A change to a committed symbol (
pp_gammalnsignature change, removal, a dropped/renamed ufunc loop) drives the verdict as usual. - A change to anything not committed — a private
__pp_*kernel, an internal helper, an added non-committed export — is moved to the filtered ledger and does not affect the verdict. - Nothing that could hide a real break is filtered. Type-layout changes,
internal-leak findings, and loader-contract changes (
SONAME,DT_NEEDED) are always kept — a struct passed to a committed export or a changed SONAME breaks clients regardless of the export set.
See exactly what was demoted in the always-disclosed filtered ledger (text) or
under the surface_scope key (-o json=...). The manifest surface is
authoritative, so this works independently of public-header scoping; the
filtered ledger is always reported so a clean verdict never hides that
filtering happened.
The manifest is a project setting, so there is no per-run flag for it (the
former compare flag was removed; see
contract:). Point --config at a
different .abicheck.yml to compare under a different manifest. The overlay
applies to a single-pair compare. A directory/package comparison and a
compare --no-baseline audit note on stderr that they do not apply it.
Removed symbols and the pp_* namespace
When the configured manifest is the new one, a committed wrapper that
was removed in the release is no longer listed there. Binary scoping
recovers such removals by the pp_* committed namespace so the removal
still breaks — but a committed ufunc loop_symbol that is not
pp_-prefixed may fall outside this recovery. The manifest-to-manifest
checks below (diff / gate) see both versions and are the
authoritative gate for loop-symbol renames/removals; treat the
compare overlay as the best-effort binary-level surface filter.
Exit codes are the standard compare codes — 0 compatible, 2 source
break, 4 ABI break (or the severity-aware scheme when any --severity-* flag
is set) — so it drops straight into a CI gate:
# Fail the build only on a change to the committed POST surface
# (with contract.overlays.post_manifest set in .abicheck.yml).
abicheck compare libmylib.v1.so libmylib.v2.so --config .abicheck.yml
Checking the manifest itself (Python API)¶
The manifest-native checks are available as a small library —
abicheck.post_manifest — for release scripts and CI. Each returns a result
object plus a format_* reporter.
1. Validate the manifest against the built library¶
Confirm every promised pp_* and ufunc-loop symbol is actually exported
(ELF / PE-COFF / Mach-O). Run it right after post-py build:
from pathlib import Path
from abicheck.post_manifest import validate_from_binary, format_validation_report
result = validate_from_binary(Path("manifest.json"), Path("libmylib.so"))
print(format_validation_report(result))
if not result.passed: # a promised symbol is missing from the binary
raise SystemExit(1)
2. Diff two manifest versions¶
A compiler-independent diff keyed by c_symbol, using the manifest's own
dtypes (which a stripped binary no longer carries). Removed exports, changed
signatures, and changed ufunc loop signatures/symbols are breaking; added
exports and added ufunc facets are compatible:
from abicheck.post_manifest import load_manifest, diff_manifests, format_diff_report
diff = diff_manifests(load_manifest(Path("v1.json")), load_manifest(Path("v2.json")))
print(format_diff_report(diff, "v1", "v2"))
if diff.is_breaking:
...
3. Gate the post_abi version bump¶
The release check: a breaking change must be accompanied by a post_abi
increase.
from abicheck.post_manifest import load_manifest, check_version_gate, format_gate_report
gate = check_version_gate(load_manifest(Path("v1.json")), load_manifest(Path("v2.json")))
print(format_gate_report(gate, "v1", "v2"))
if gate.violated: # breaking change without a post_abi bump
raise SystemExit(1)
Typical release flow¶
post-py build --emit-manifest→ produceslibmylib.so+manifest.json.- Post-build:
validate_from_binary(...)— did the build actually export everything the manifest promises? - On a release PR:
check_version_gate(old_manifest, new_manifest)— was a breaking change matched by apost_abibump? (fails the PR if not) - Optional, binary-level:
abicheck compare old.so new.sowithcontract.overlays.post_manifestconfigured — diff the actual binaries, scoped to the committed surface, so private-kernel churn stays out of the verdict.
Note
Steps 1–3 are a Python library today (no dedicated CLI subcommand yet);
step 4 — compare with the manifest overlay configured — is the CLI
entry point.