Python Binding Architecture
This document is the source of truth for the Datoviz v0.4 Python binding architecture. Datoviz has
one generated ctypes binding over libdatoviz. The top-level datoviz package is the normal
call form and adds policy-declared NumPy adaptation for selected array arguments. datoviz.raw is
the exact C-shaped call form of the same binding.
High-level Python plotting, object-oriented convenience APIs, and scientific workflow sugar belong above Datoviz, currently in the GSP/VisPy2 layer. Datoviz v0.4 owns the C engine and the generated binding that honestly exposes that C engine to Python.
This boundary is also an AI-facing contract: coding agents should not infer a Python plotting API
from the existence of the binding. Generated Python examples should use import datoviz as dvz
unless they are specifically demonstrating exact pointer/count FFI through datoviz.raw.
Goals
- Generate a pure Python
ctypesbinding from the v0.4 public C API. - Keep the binding close enough to C that C examples, headers, docs, and Python calls map directly to the same symbols.
- Make the extracted C API metadata inspectable, diffable, and reusable by tests, docs, and future generators.
- Keep hand-written binding policy separate from extracted C truth.
- Validate struct layout, ownership-sensitive returns, and shared-library loading with focused automated checks.
Non-Goals
- Do not recreate the v0.3 object-oriented Python plotting API inside Datoviz.
- Do not optimize the Python surface for short names at the cost of C traceability.
- Do not bake NumPy convenience conversion into every generated pointer signature.
- Do not make the generated Python file the only source of API metadata.
Pipeline
The binding generation remains a two-stage process:
C public headers + configured build definitions
|
v
build/bindings/datoviz_api.json
|
v
datoviz/_ctypes.py
The first stage extracts normalized C API metadata. The second stage consumes that metadata and
emits the Python ctypes module.
This separation is intentional:
- C extraction and Python code generation are different problems.
datoviz_api.jsoncan be inspected and reviewed during v0.4 API churn.- API extraction can be tested without importing Python bindings or loading
libdatoviz. - The same metadata can later feed documentation, ABI reports, CFFI experiments, or other tooling.
- Binding policy can be applied in the second pass without corrupting the extracted C model.
Recommended Files
Use these locations for the v0.4 binding rewrite:
tools/bindings/
extract_api.py # C/Clang -> normalized JSON
generate_ctypes.py # JSON -> datoviz/_ctypes.py
generate_ctypes_abi.py # C probe -> build/bindings/ctypes_abi.json
validate_ctypes_abi.py # ctypes sizeof/offset checks
spec/bindings/
README.md # this binding architecture and policy
ctypes.yml # machine-readable binding policy, later
ARRAY_FACADE.md # top-level NumPy-adaptation plan
EXAMPLE_PYTHON_GENERATION.md # C example -> Python binding documentation plan
API_JSON_SCHEMA.md # JSON schema notes, later
build/bindings/
datoviz_api.json # extracted C API metadata
ctypes_abi.json # generated C sizeof/alignof/offsetof facts
ctypes_report.json # optional validation report
datoviz/
__init__.py # documented array-aware package entry point
raw.py # exact pointer/count call form
_ctypes.py # generated implementation, do not edit by hand
_array_facade.py # generated NumPy adaptation
The old v0.3-style names parse_headers.py, build_ctypes.py, and headers.json are not part of
the v0.4 design. The v0.4 names state the actual stage and artifact, and git history remains the
reference for the removed v0.3 generator.
Just Commands
The binding workflow should be exposed through narrow commands:
just api-json # generate build/bindings/datoviz_api.json
just ctypes # generate datoviz/_ctypes.py from JSON
# and datoviz/_array_facade.py from JSON plus policy
just ctypes-abi # generate build/bindings/ctypes_abi.json
just ctypes-check # validate generated ctypes against C ABI facts
just ctypes-smoke # import/load/create-destroy smoke
just ctypes-render-smoke # offscreen render/capture smoke, skips without runtime support
just ctypes-package-smoke # editable and wheel install smoke
just bindings # run the full binding workflow
Extractor
The v0.4 extractor should use Clang AST metadata instead of extending the old pyparsing header parser. The old parser proved the approach, but it only handles a subset of C and is too brittle for the current public headers.
The extractor should parse the public Datoviz headers with the same include paths and feature definitions used by the configured build. It should emit C facts, not Python binding decisions.
The extracted JSON should include at least:
- exported
DVZ_EXPORTfunctions and their exact C names; - concrete structs and unions, including fields, arrays, size, alignment, and offsets when available;
- opaque structs and handle-like pointer targets;
- enums and enum values;
- typedefs, including callback typedefs;
- constants intended for public binding use;
- header path, feature guard, and documentation metadata where available.
Top-level public headers such as datoviz/scene.h, datoviz/app.h, and datoviz/canvas.h must be
included in the extraction scope. Public support headers such as datoviz/ffi.h and intentionally
public packet helpers such as datoviz/scene/frame_packets.h must be listed explicitly when they
are not reached by the umbrella path. The old include/datoviz/*/*.h-only traversal is
insufficient.
JSON Policy
build/bindings/datoviz_api.json describes the C API. It must not pre-decide ctypes details such
as whether a pointer becomes ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(T), or a helper
wrapper.
The generated exact ctypes surface tracks the exported shared-library ABI, not every declaration
that appears in installed headers. A declaration without DVZ_EXPORT may be public source-level
documentation or an inline/support declaration, but it is not a callable binding function until it
is an exported ABI symbol.
Binding-specific policy belongs in spec/bindings/ctypes.yml or an equivalent source-controlled
manifest. That manifest can describe decisions that are not safely inferable from C syntax:
- included and excluded headers;
- excluded symbols;
- ownership-sensitive return values;
- functions used to release owned returns;
- pointer arguments that should accept arrays;
- byte-size, count, stride, and shape argument relationships;
- callback lifetime rules;
- feature-gated APIs that should or should not appear in a given build.
For example, a function returning an owned char* must not blindly use ctypes.c_char_p as its
restype, because that can lose the original pointer and make the matching destroy function unsafe.
The policy layer should mark the ownership rule and the emitter should choose a safe representation.
Python Import Surface
The documented normal Python import is:
import datoviz as dvz
scene = dvz.dvz_scene()
dvz.dvz_scene_destroy(scene)
Use datoviz.raw only for the exact pointer/count call form:
import datoviz.raw as raw
datoviz/raw.py is the explicit public exact-call module:
from ._ctypes import * # noqa
datoviz/_ctypes.py is generated and private. It should not be documented as the preferred import
path and should not be edited by hand.
The binding roles are:
datoviz.__init__: documented package entry point with policy-declared NumPy adaptation;datoviz.raw: exact C-shaped call form for explicit pointers, counts, bytes, and callbacks;datoviz._ctypes: generated implementation detail.
The top-level package should not promise Pythonic plotting objects or prefixless aliases. It
preserves dvz_* names while accepting NumPy arrays for policy-declared data arguments. See
ARRAY_FACADE.md.
Naming Policy
The Python binding preserves exact C symbol names. Do not remove the dvz_ prefix in either the
top-level call form or datoviz.raw.
This is canonical:
dvz.dvz_scene()
dvz.dvz_scene_destroy(scene)
This is not part of the binding contract:
dvz.scene()
Keeping exact names is preferred because:
- Python calls map directly to C headers and examples.
- Search, debugging, stack traces, and docs all use the same symbol names.
- Prefixless aliases create collision risk with structs, enum values, helper functions, and future package-level utilities.
- Many C names only make sense with their namespace intact, such as
dvz_grid_panel,dvz_panel_axis, anddvz_axis_style.
Prefixless aliases are not part of either the top-level call form or datoviz.raw. They should not
drive generation policy.
Type Mapping Policy
The exact call form should be conservative:
- opaque
Dvz*handles become emptyctypes.Structureclasses passed by pointer; - concrete structs and unions become
ctypes.Structureorctypes.Unionwith verified layout; - enums become integer-compatible Python enum classes or integer constants, as decided by the emitter;
void*staysctypes.c_void_punless binding policy marks a safer special case;const char*may usectypes.c_char_pfor borrowed input strings;- owned
char*returns require explicit ownership policy and a matching destroy function; - callbacks require generated
ctypes.CFUNCTYPEdefinitions and documented lifetime rules.
When a canonical C API returns a public record by value and datoviz.raw cannot safely express
that layout yet, prefer a dvz_ffi_* out-pointer helper over changing the canonical dvz_* API.
The generated C reference should distinguish "emitted", "skipped by policy", and "available through
dvz_ffi_*" states.
For RC1, do not add dvz_ffi_* wrappers for every skipped by-value return. Add wrappers only when a
datoviz.raw, WASM, or release example needs that exact initializer. The current geometry and
reference-grid wrappers cover the first release-facing set.
NumPy support belongs only where binding policy declares a pointer/count or pointer/byte-size relationship. It should not become the default treatment for every pointer argument.
Validation
Binding work is not complete until the generated binding is checked against the C build.
Required validation should include:
- import and shared-library load smoke;
- simple non-graphics function smoke, such as
dvz_time_monotonic_ns; - create/destroy smoke for
DvzScene; - narrow scene/app smoke when runtime support is available;
- ABI layout checks comparing
ctypes.sizeof, alignment, and field offsets with C-generated facts; - ownership smoke for owned returns such as scene JSON when those symbols are included.
The ABI facts should be generated from C into build/bindings/ctypes_abi.json, then compared with
the generated Python classes. Do not rely on blanket assumptions such as _pack_ = 8.
The current generator emits _fields_ only for layout-safe records it can validate with ctypes.
Records that depend on cglm vector/matrix alignment are intentionally kept opaque until the raw
binding has an explicit aligned-structure policy.
For RC1, keep cglm-aligned records opaque in datoviz.raw. Use pointer-based APIs or targeted
dvz_ffi_* helpers instead of attempting a broad aligned-record mapping late in the release cycle.
Examples
Keep datoviz.raw examples small and close to the generated C surface:
examples/python/raw/lifecycle.pyproves import, timer calls, and scene create/destroy.examples/python/raw/offscreen_point.pybuilds a tiny point scene with explicitctypesarrays, renders offscreen when a runtime is available, and verifies PNG capture.
These examples are low-level integration proof of the exact call form, not a Pythonic plotting API.
Migration From v0.3 Tooling
The removed v0.3 tooling demonstrated a useful architecture:
tools/parse_headers.py -> build/headers.json -> tools/build_ctypes.py -> datoviz/_ctypes.py
For v0.4, keep the two-stage architecture but use the active tools/bindings/ implementation:
- replace pyparsing extraction with Clang-based extraction;
- rename scripts to reflect their roles;
- move binding scripts under
tools/bindings/; - move generated JSON under
build/bindings/; - keep source-controlled policy under
spec/bindings/; - keep
datoviz/_ctypes.pyas generated output; - add
datoviz/raw.pyas the explicit exact-call module; - make
import datoviz as dvzthe documented normal import; - preserve exact
dvz_*,Dvz*, andDVZ_*names in the Python surface.