Skip to content

Repository files navigation

Apiary

A libclang/libtooling-based C++ introspection engine. It parses real C++23 with a Clang frontend and, from a single AST walk, emits pybind11/nanobind binding TUs, .pyi type stubs, Sphinx C++ API documentation (cpp/c-domain directives, including macros — no Doxygen), and a structured public-API JSON IR.

Apiary began as einsums-pybind, the binding generator for the Einsums tensor library, and is now a standalone tool. The annotation contract lives in include/apiary/Annotations.hpp (the APIARY_* macros).

Einsums remains the reference consumer: its einsums_add_module(... PYBIND) helper is a thin wrapper over the generic apiary_add_bindings / apiary_aggregate_extension functions (Quick start), driving them to aggregate its modules under a single import einsums. New consumers call those functions directly — there is no Einsums coupling.

Quick start

Annotate the C++ you want to bind:

// mylib/include/mylib/Greeter.hpp
#include <apiary/Annotations.hpp>

namespace mylib {

class APIARY_EXPOSE Greeter {
public:
    APIARY_EXPOSE Greeter();
    APIARY_EXPOSE explicit Greeter(std::string greeting);
    APIARY_EXPOSE std::string say(std::string const &name) const;
};

} // namespace mylib

Wire it up with Apiary's CMake helpers — the same ones whether Apiary is installed (find_package) or vendored (add_subdirectory):

find_package(Apiary REQUIRED)          # or: add_subdirectory(external/apiary)

target_link_libraries(mylib PUBLIC apiary::annotations)   # the APIARY_* header

# Probe the build compiler once for the system/stdlib include paths libtooling
# needs (sets APIARY_SYSTEM_FLAGS).
apiary_detect_toolchain(CXX_STANDARD 20)

# Generate the binding TU (+ .pyi + docs JSON). DEPENDS_TARGETS supplies the
# transitive -I/-D from your library's usage requirements.
apiary_add_bindings(
    BINDING DOCS_JSON
    HEADERS           ${CMAKE_SOURCE_DIR}/mylib/include/mylib/Greeter.hpp
    SOURCE_INCLUDES   mylib/Greeter.hpp
    REGISTER_FUNCTION apiary_register_mylib
    DEPENDS_TARGETS   mylib
    OUTPUT_NAME       mylib
    CXX_STANDARD      20
    OUT_BINDING _tu  OUT_STUB _stub  OUT_DOCS_JSON _docs
)

# Assemble one or more modules into a single Python extension. You supply MAIN
# (the PYBIND11_MODULE body); the helper generates the register header it
# includes and creates the pybind11 target.
apiary_aggregate_extension(
    NAME _core
    MAIN     ${CMAKE_SOURCE_DIR}/mylib/src/main.cpp
    BINDINGS ${_tu}
    MODULES  mylib
    MODULES_HEADER      ${CMAKE_BINARY_DIR}/gen/include/mylib/Modules.hpp
    MODULES_INCLUDE_DIR ${CMAKE_BINARY_DIR}/gen/include
    STUBS ${_stub}  STUBS_TARGET mylib_stubs
    FRAG_DIR ${CMAKE_BINARY_DIR}/gen  PKG_DIR ${CMAKE_BINARY_DIR}/mylib
)
target_link_libraries(_core PRIVATE mylib)

main.cpp includes the generated header and calls the aggregator:

#include <mylib/Modules.hpp>           // generated: declares apiary_register_all()
PYBIND11_MODULE(_core, m) { apiary_register_all(m); }

Build and import — the codegen runs as a build edge (re-fires on header changes):

cmake -S . -B build && cmake --build build
PYTHONPATH=build python3 -c "import _core; print(_core.Greeter('hi').say('world'))"

That's the whole consumer surface — apiary_detect_toolchain, apiary_add_bindings, apiary_aggregate_extension. Call apiary_add_bindings once per module and pass them all to a single apiary_aggregate_extension to aggregate many modules under one extension.

Annotation reference

Every macro is a C++11 attribute that places between the class-key and the class name (or before a function return type). Multiple macros stack. Under non-Clang compilers all macros expand to nothing, so production builds carry no overhead.

Exposure

Macro Purpose
APIARY_EXPOSE Mark a declaration for binding. Without this, the codegen ignores it.
APIARY_HIDE Suppress binding for an otherwise-exposed declaration (e.g. an inherited member).

Naming

Macro Purpose
APIARY_RENAME("py_name") Override the Python identifier used for the binding.
APIARY_MODULE("submodule") Place the binding inside a Python submodule. Dotted names ("tensor.algebra") request nested submodules.
APIARY_EXCEPTION Bind the class as a Python exception via py::register_exception<T> instead of py::class_<>. C++ class must derive from std::exception (or compatible). pybind11-only.

Class options

Macro Purpose
APIARY_HOLDER(std::shared_ptr) Override the pybind11 holder type. Default is std::unique_ptr.
APIARY_BUFFER_PROTOCOL Flip on pybind11's buffer protocol. Pair with BUFFER_FROM.
APIARY_BUFFER_FROM(helper) Free function helper(T&) returning py::buffer_info; codegen wraps it in a .def_buffer() lambda.
APIARY_IMPLICIT_FROM(Source) Emit py::implicitly_convertible<Source, Class>() after the binding.
APIARY_DYNAMIC_ATTR Allow Python instances to carry arbitrary attributes.
APIARY_NOCOPY Skip generation of the copy-ctor binding.
APIARY_NOMOVE Skip generation of the move-ctor binding.
APIARY_NO_BASES Force-skip emission of base-class arguments. Usually unnecessary — the emitter auto-skips bases that aren't themselves bound.
APIARY_READONLY On a field — bind as def_readonly instead of def_readwrite.

Method / free-function options

Macro Purpose
APIARY_RVP(reference_internal) Set return_value_policy. Argument is the unqualified policy name.
APIARY_KEEP_ALIVE(0, 1) Emit py::keep_alive<nurse, patient>().
APIARY_RELEASE_GIL Wrap the call in py::call_guard<py::gil_scoped_release>().
APIARY_OPERATOR("__add__") Bind the method as a Python operator instead of a named function.

Properties

APIARY_GETTER("name") and APIARY_SETTER("name") get merged into one .def_property("name", &get, &set) when the codegen sees a matching name on a getter/setter pair. A @getter with no matching @setter becomes a .def_property_readonly.

Documentation

Doxygen comments (/// or /** */) above an exposed declaration become the Python docstring automatically. Override explicitly with APIARY_DOC("text").

Template instantiation

Templated classes need an explicit instantiation directive — pybind11 binds concrete types, not templates.

Cross-product (APIARY_INSTANTIATE): each parameter list is keyed by the exact C++ template-parameter name. The codegen matches by name, not position, so the order in the macro is free. Python names are auto-derived from the values.

template <typename T, int rank>
class APIARY_EXPOSE
    APIARY_INSTANTIATE(Matrix,
        T(float, double),
        rank(1, 2))
Matrix { ... };
// Produces: Matrix_float_1, Matrix_float_2, Matrix_double_1, Matrix_double_2

Single instantiation (APIARY_INSTANTIATE_AS): pin one concrete type to a chosen Python name. Use this when one template parameter depends on another (e.g. Alloc = std::allocator<T>), which a flat cross-product can't express.

APIARY_INSTANTIATE_AS("Matrix2d_double",
                              Matrix<double, 2, std::allocator<double>>)

Cross-product with name template (APIARY_INSTANTIATE_TEMPLATE): same matching rules; placeholders in the name template use the C++ template-parameter names too.

template <typename Element, int Rank>
class APIARY_EXPOSE
    APIARY_INSTANTIATE_TEMPLATE("Block_{Element}_{Rank}",
        Block,
        Element(float, double),
        Rank(1, 2))
Block { ... };
// Produces: Block_float_1, Block_float_2, Block_double_1, Block_double_2

Placeholder values are sanitized to valid Python identifiers (std::complex<double>std_complex_double).

Free-function template instantiation

APIARY_INSTANTIATE_AS also works on templated free functions — each directive defines one instantiation. Multiple directives sharing a Python name turn into a pybind11 overload set; the codegen picks the right one at call site via Python's argument types.

template <typename T>
APIARY_EXPOSE
APIARY_INSTANTIATE_AS("scale", mylib::Array<float>)
APIARY_INSTANTIATE_AS("scale", mylib::Array<double>)
void scale(typename T::ValueType factor, T *A);

Same-signature overloads → dtype dispatcher

When two or more INSTANTIATE_AS lines share a Python name AND their argument signatures are identical (only the return type or value-type differs), the codegen automatically collapses them into a single Python entry that takes a dtype="..." kwarg and dispatches at runtime:

template <typename T>
APIARY_EXPOSE
APIARY_INSTANTIATE_AS("zeros", float)
APIARY_INSTANTIATE_AS("zeros", double)
APIARY_INSTANTIATE_AS("zeros", std::complex<float>)
APIARY_INSTANTIATE_AS("zeros", std::complex<double>)
Array<T> zeros(std::string name, std::vector<size_t> dims);
// Python: zeros("X", [4, 4], dtype="float64")

Recognized dtype aliases (numpy convention): float32/f4/f/single (float), float64/f8/d (double), complex64/c8/F (complex), complex128/complex/c16/D (complex). The default dtype is float64 if double is in the group, otherwise the first instantiation's first alias.

Bool template parameters → kwargs

For functions templated on leading bool parameters (e.g. template <bool TransA, bool TransB, typename T>), pair APIARY_TEMPLATE_KWARGS with APIARY_INSTANTIATE_BOOLS. The codegen expands 2^N combinations internally and emits one Python entry per dtype taking each bool as a kw-only argument:

template <bool TransA, bool TransB, typename T>
APIARY_EXPOSE
APIARY_TEMPLATE_KWARGS("trans_a", "trans_b")
APIARY_INSTANTIATE_BOOLS("gemm", mylib::Array<float>, float)
APIARY_INSTANTIATE_BOOLS("gemm", mylib::Array<double>, double)
void gemm(U alpha, T const &A, T const &B, U beta, T *C);
// Python: gemm(1.0, A, B, 0.0, C, trans_a=True, trans_b=False)

The first INSTANTIATE_BOOLS argument is the Python name (shared across the bool fan-out); the rest are the non-bool template args. The 2^N bool combinations are generated automatically; the codegen then emits a single Python def per dtype with an internal if-chain dispatcher.

Member-template instantiation

Use APIARY_INSTANTIATE_MEMBER_AS to bind a templated method with its own template parameters (independent of the enclosing class's parameters). Multiple directives stack; same-signature ones with recognized dtypes auto-merge into a dtype= dispatcher exactly like free-function INSTANTIATE_AS.

template <typename T>
class APIARY_EXPOSE Workspace { ... };

class Workspace {
    template <typename U>
    APIARY_EXPOSE
    APIARY_INSTANTIATE_MEMBER_AS("declare_array",
                                          U=mylib::Array<float>)
    APIARY_INSTANTIATE_MEMBER_AS("declare_array",
                                          U=mylib::Array<double>)
    U &declare_array(std::string name, std::vector<size_t> dims);
};

APIARY_INSTANTIATE_MEMBER (no _AS) is the same idea for members whose own parameters depend on the class's. Argument is a Name=Type pair like Dim=std::vector<size_t>.

Variadic constructors

Templated classes often have parameter-pack constructors whose arity depends on a template parameter. The codegen needs to know how many arguments to bind per instantiation.

template <typename T, size_t rank>
struct APIARY_EXPOSE
    APIARY_INSTANTIATE_AS("Matrix_double_2",
                                  Matrix<double, 2, std::allocator<double>>)
Matrix {
    template <typename... Dims>
    APIARY_EXPOSE
    APIARY_VARIADIC_FROM(rank, size_t)   // pack -> rank-many size_t args
    Matrix(std::string name, Dims... dims);
};

For Matrix_double_2, this binds a ctor with signature (std::string, size_t, size_t). For Matrix_double_3 it would be (std::string, size_t, size_t, size_t).

The first arg names the template parameter that gives the count; the second is the concrete C++ type each expanded slot should take. The last function parameter is assumed to be the pack.

Validation

The codegen emits Clang-style file:line errors and exits non-zero on problems. Common cases:

Error Cause
unknown parameter keyword 'X' (template parameters are: ...) Either a typo, or an upstream #define mangled the keyword before stringification.
class name '<X>' in directive payload does not match Same cause: macro expansion changed the class name token.
expected N parameter list(s), got M Number of Param(...) groups doesn't match the template signature.
parameter keyword '<X>' specified more than once Duplicate group.
missing parameter list for template parameter '<X>' A template parameter has no matching group.

The strict name-match for INSTANTIATE / INSTANTIATE_TEMPLATE is the load-bearing guard against random macro expansion. If some upstream header has #define Element WHATEVER, the codegen sees WHATEVER(...), fails the match against the real Element parameter, and emits a diagnostic instead of producing wrong bindings.

Emitter diagnostics

Those errors all come from reading the annotations. A second class of problem is only visible while writing the output: an input apiary parsed happily but cannot represent in the target language. Those are reported against the header that declares them, with a stable check id:

Handle.hpp:44:49: warning: apiary: method 'value' is &-qualified; the emitted
static_cast omits the qualifier and so names no overload. The generated code
will not compile. [unrepresentable-overload]

--list-diagnostics prints the check ids, their current severities and what each means. Severity is per check and settable, so a project can ratchet:

apiary --list-diagnostics
apiary --diagnostic=skipped-entity=note ...      # opt in to an off-by-default check
apiary --diagnostic=unrepresentable-pack=error ...  # pin one, once it is clean
apiary --werror ...                              # promote every warning

They default to warning rather than error on purpose. Each describes output that is already wrong, but apiary has always accepted these inputs and emitted something for them; failing the build on day one would break consumers on behavior they have been living with. Fix, then pin.

--diagnostic is validated before any work starts, so a typo in a check id or severity exits 2 without writing a file. A run that emitted under a policy the caller did not ask for would be the same silence the diagnostics exist to end.

Nothing is written until the exit status is known. Emission is where the emitters discover what they cannot represent, so the status is not knowable until every artifact has been generated — but it is knowable before any of them reaches disk. A failing run therefore leaves no output: a build system that reruns failed edges recovers either way, but a manual invocation or a tolerant driver would otherwise be left with a partial file that looks fresh. Same instinct as the empty-module refusal, which declines to write rather than leave an empty-but-valid TU for the next step to consume happily.

How it builds

  1. Configureapiary_add_bindings() emits an add_custom_command per set of headers, and apiary_aggregate_extension() writes the register header (<prefix>all()) and creates the pybind11_add_module target. (In Einsums, einsums_finalize_pybind() calls these once per opted-in module.)

  2. Build — ninja resolves the dependency chain:

    • the apiary tool builds first;
    • your C++ library/targets build;
    • for each unit, apiary runs over its headers and emits <name>_pybind.cpp, containing a void <register-function>(py::module_ &m);
    • the consumer's MAIN and every generated TU compile;
    • the extension links them into one Python module.

Touching an annotated header re-fires only that unit's codegen edge (via the add_custom_command's DEPENDS ${HEADERS}) and re-links the extension.

Known limitations

  • Default constructors with conditional requires clauses that are compile-time deleted for some instantiations may emit spurious bindings. Use APIARY_HIDE to suppress per-method.
  • Cross-product with dependent parameters can't be expressed (Alloc = std::allocator<T>). Fall back to one APIARY_INSTANTIATE_AS per concrete type.
  • System header detection assumes Clang's -print-resource-dir is available and (on macOS) xcrun --show-sdk-path. A conda env with clangdev + llvmdev satisfies both. Other setups may need to set APIARY_CLANG_RESOURCE_DIR / APIARY_SYSROOT manually before the first configure.
  • requires requires { … } clauses block doxygen attachment — clang's getRawCommentForDecl doesn't associate /// comments with a function template that has a nested requires-expression. Flatten to a single requires (A && B && …) clause and the comment (and thus the Python docstring) will attach.
  • Stub-side metafunction expansion isn't supported — return types involving RemoveComplexT<T> and friends fall back to Any in the generated .pyi. The runtime binding is correct; only the static type information is reduced.

Backend target

apiary can emit code against either pybind11 (default) or nanobind. Pass --target {pybind11,nanobind} on the command line:

apiary --target nanobind --module myext header.hpp -- ...

The output differs in:

  • Headers<pybind11/...> vs <nanobind/...>, with nanobind's STL bindings split per-type (<nanobind/stl/string.h>, <nanobind/stl/vector.h>, etc.)
  • Module macroPYBIND11_MODULE vs NB_MODULE
  • Namespacepy:: vs nb::
  • Return value policypy::return_value_policy::reference_internal vs nb::rv_policy::reference_internal
  • Buffer protocol — pybind11 emits .def_buffer() lambdas; nanobind doesn't have an equivalent directive (use nb::ndarray<> for tensor protocol instead). APIARY_BUFFER_FROM directives are silently dropped under the nanobind target.

The apiary_aggregate_extension helper uses pybind11 today. Switching the pipeline to nanobind requires also swapping pybind11_add_module for nanobind_add_module and the matching find_package(nanobind). The --target flag is what makes the rest of that switch a one-line change in the cmake hook.

Stub generation (.pyi)

Every codegen invocation also produces a Python type-stub fragment, emitted alongside the generated .cpp:

build/gen/mylib_core_pybind.cpp   # bindings
build/gen/mylib_core.pyi          # stub fragment

A finalize step (scripts/apiary_aggregate_stubs.py, located at APIARY_SCRIPTS_DIR) runs as the STUBS_TARGET custom target apiary_aggregate_extension wires up, after the extension is linked. It splits each fragment by the # %%submodule: <name> sentinels the emitter inserts and merges them into per-submodule files in the package directory:

build/lib/mylib/
├── _core.cpython-…so      # the C extension
├── _core.pyi              # top-level entities
├── linalg.pyi             # entities tagged @module("linalg")
├── graph.pyi              # entities tagged @module("graph")
├── __init__.py / .pyi     # runtime + stub re-exporting _core
└── py.typed               # PEP 561 marker

What pyright sees

Type translation runs per-instantiation:

# scale (free function with INSTANTIATE_AS for four dtypes)
@overload
def scale(factor: float, A: ArrayF) -> None: ...
@overload
def scale(factor: float, A: ArrayD) -> None: ...
@overload
def scale(factor: complex, A: ArrayC) -> None: ...
@overload
def scale(factor: complex, A: ArrayZ) -> None: ...

# zeros (auto-detected dtype dispatcher)
def zeros(name: str, dims: list[int], dtype: str = "float64") \
    -> ArrayF | ArrayD | ArrayC | ArrayZ: ...

# gemm (TEMPLATE_KWARGS bool fan-out)
@overload
def gemm(alpha: float, A: ArrayF, B: ArrayF, beta: float,
         C: ArrayF, *, trans_a: bool = False, trans_b: bool = False) -> None: ...
@overload
def gemm(alpha: complex, A: ArrayC, B: ArrayC, beta: complex,
         C: ArrayC, *, trans_a: bool = False, trans_b: bool = False) -> None: ...

Doxygen comments above an exposed declaration become Python docstrings. @getter / @setter pairs become @property declarations. Rich-comparison dunders (__eq__, __lt__, …) are widened to take object to satisfy LSP — a stub typed __eq__(self, other: Vec3) would otherwise trip pyright's reportIncompatibleMethodOverride.

Cross-module name resolution

When a function in one module takes a type from another module (e.g. a function taking an Array<T> whose binding lives in a different module), the visitor records the external annotated class with is_external=true purely for name resolution. The C++ emitter ignores externals (their binding lives in the owning module's TU); the .pyi emitter uses them to map Array<float, std::allocator<float>>ArrayF so cross-module signatures resolve without needing a shared registry across codegen invocations.

Type-resolution pipeline

For each per-instantiation parameter / return type, the .pyi emitter:

  1. Substitutes template names on the raw C++ type (preserving forms like typename T::ValueType for re-resolution).
  2. Tries the canonical (typedef-expanded) form via clang's getCanonicalType() if the as-written form fails — catches alias templates like Array<T>BasicArray<T, std::allocator<T>>.
  3. Inlines typename Class<args>::ValueType references with the class's first type argument.
  4. Substitutes any known cpp_to_py-mapped class instantiation in nested types (so std::tuple<Array<float>, ...> reduces to tuple[ArrayF, ...]).
  5. Falls back to Any when none of the above produces a Python-valid identifier — pyright will surface the gap rather than the stub silently mistyping.

Python shell modules

Each Python submodule needs a tiny .py shell next to _core.so. The recommended pattern uses PEP 562 __getattr__ so the C extension isn't loaded until first attribute access:

# mylib/graph.py
import importlib as _importlib

def __getattr__(name):
    if name.startswith("_"):
        raise AttributeError(name)
    core = _importlib.import_module("._core.graph", "mylib")
    attr = getattr(core, name)
    globals()[name] = attr  # cache for subsequent lookups
    return attr

The generated <sub>.pyi describes the static surface; the .py shell is just a runtime trampoline.

Configure-time conditional bindings

Annotated headers can use #if/#else/#endif against any configure-time define, including everything that your project's config-define generator writes into <mylib/Config.hpp>. The codegen tool runs Clang's full preprocessor and only sees the active branch:

#include <mylib/Config.hpp>
#include <mylib/cuda/DeviceAllocator.hpp>

template <typename T, size_t rank, typename Alloc>
struct APIARY_EXPOSE
    APIARY_INSTANTIATE_AS("Matrix_double_2",
                                  Matrix<double, 2, std::allocator<double>>)
#if defined(MYLIB_HAVE_CUDA)
    APIARY_INSTANTIATE_AS("Matrix_double_2_cuda",
                                  Matrix<double, 2, cuda::DeviceAllocator<double>>)
#endif
Matrix { ... };

When MYLIB_WITH_CUDA=ON, the GPU instantiation is added; toggling it off and reconfiguring drops it. The generated Defines.hpp files are in every codegen edge's DEPENDS, so re-configure → re-fire codegen automatically. Also forwarded: every INTERFACE_COMPILE_DEFINITIONS reachable from the module's MODULE_DEPENDENCIES (gets -D flags on the codegen invocation).

  • Visibility warnings when linking the extension (weak symbols across the consumer's library and the generated TU) are cosmetic on macOS; symbols still resolve correctly.

Tool architecture (for contributors)

src/
  main.cpp              CLI driver: ClangTool + per-TU IR accumulation +
                        emit pass. Drives the post-IR passes
                        (compute_python_overloads, compute_properties)
                        before invoking the C++ and .pyi emitters.
                        Tracks total error count, exits non-zero.
  Visitor.hpp/.cpp      RecursiveASTVisitor that walks declarations,
                        filters by apiary: annotation, builds
                        the Module IR. Captures annotated classes from
                        outside the current module's headers as
                        ``is_external`` for cross-module name resolution.
  IR.hpp/.cpp           BoundClass / BoundMethod / BoundField / BoundEnum /
                        BoundFunction / BoundParam / BoundInstantiation /
                        BoundProperty / PythonOverload. Plus a
                        deterministic textual dump (``--dump-ir``).
  AnnotationParser.hpp/.cpp
                        Splits raw "apiary:<directive>:<args>"
                        payloads into structured Directive records.
                        Knows about free-form-tail directives (doc,
                        instantiate, holder) where the tail may contain
                        ':'.
  InstantiateParser.hpp/.cpp
                        Parses INSTANTIATE / INSTANTIATE_TEMPLATE
                        payloads into ParamGroup lists, respects nested
                        ``<>`` and ``()``. Provides cross_product and
                        sanitize_python_name helpers.
  TypeTranslator.hpp/.cpp
                        Wraps Clang's PrintingPolicy for fully-qualified
                        pretty C++ types. Also provides
                        ``translate_python_type`` (and the string-only
                        variant ``translate_python_type_string``) that
                        maps fundamentals + std containers to their
                        Python equivalents.
  DocExtractor.hpp/.cpp
                        Pulls doxygen text from
                        ASTContext::getRawCommentForDeclNoCache, strips
                        leading ``///``/``*`` markers and decoration
                        banner lines (``//////``, ``=====``, ``-----``).
  PythonOverloads.hpp/.cpp
                        Post-IR pass that decides how each free
                        function's raw instantiation list collapses
                        into Python entries: NonTemplate,
                        SingleInstantiation, OverloadSet,
                        DtypeDispatcher, TemplateKwargsDispatcher.
                        Both emitters consume the precomputed view.
  Properties.hpp/.cpp   Post-IR pass that walks each class's methods
                        and collapses @getter/@setter pairs into
                        BoundClass.properties.
  Emitter.hpp/.cpp      IR -> pybind11 C++ (or nanobind, via
                        ``--target``). Two output modes:
                        PYBIND11_MODULE(name, m) (standalone fixtures /
                        goldens) or void register_<Module>(py::module_ &m)
                        (autogen aggregator path). In-process
                        clang::format::reformat() picks up the project's
                        .clang-format.
  PyiEmitter.hpp/.cpp   IR -> Python .pyi stubs. Walks the same IR plus
                        the post-pass views, emits per-submodule blocks
                        delimited by ``# %%submodule: <name>`` sentinels
                        for the aggregator to split.

scripts/
  apiary_aggregate_stubs.py    Reads every ``*.pyi`` fragment in
                        ``--frag-dir`` and merges by submodule sentinel
                        into per-submodule files in ``--pkg-dir``.
                        Writes a shared header per output and the
                        PEP-561 ``py.typed`` marker.

tests/
  fixtures/             Annotated headers used by the emitter tests.
  golden/               Expected emitter output (regen with REGEN=1).
  run_smoke.sh          IR-dump substring assertions covering
                        BoundClass/BoundMethod/BoundFunction shapes,
                        property merge, submodule routing, Python
                        type translation, and default-value sanitization.
  run_golden.sh         pybind11 emitter golden-file diff.

Adding a new directive

  1. Define the macro in include/apiary/Annotations.hpp.
  2. If its tail can contain :, add it to directive_takes_free_form_tail in AnnotationParser.cpp.
  3. Handle it in the emitter (most directives are read via DirectiveView in Emitter.cpp's class-body / method-emission helpers).
  4. Add a fixture under tests/fixtures/ and regenerate goldens (REGEN=1 tests/run_golden.sh ...).
  5. Exercise it end-to-end with a Python smoke test (build an extension that uses the directive, then assert on the result).

Examples

Property pair → Python @property

class APIARY_EXPOSE Resource {
public:
    /// Read-only-from-Python access to the underlying name.
    APIARY_GETTER("name")
    std::string const &get_name() const;

    /// Pythonic name setter.
    APIARY_SETTER("name")
    void set_name(std::string const &n);
};

Generated stub:

class Resource:
    @property
    def name(self) -> str:
        """Read-only-from-Python access to the underlying name."""
        ...
    @name.setter
    def name(self, value: str) -> None: ...

Operator overload

class APIARY_EXPOSE Vec3 {
public:
    /// Component-wise equality.
    APIARY_EXPOSE APIARY_OPERATOR("__eq__")
    bool operator==(Vec3 const &other) const;
};

Generated stub (note the object widening for LSP compliance):

class Vec3:
    def __eq__(self, other: object) -> bool:
        """Component-wise equality."""
        ...

Submodule routing

namespace APIARY_MODULE("graph") cg {

APIARY_EXPOSE
class Graph { ... };

APIARY_EXPOSE
void execute(Graph &g);

} // namespace cg

Both Graph and execute end up in mylib.graph. The aggregator writes them into build/lib/mylib/graph.pyi. Anything outside the namespace block (or any entity tagged with its own APIARY_MODULE("…")) routes to the chosen submodule.

Conditional binding gated on a config define

#include <mylib/Config.hpp>

APIARY_EXPOSE
APIARY_INSTANTIATE_AS("Matrix_double_2",
                              Matrix<double, 2, std::allocator<double>>)
#if defined(MYLIB_HAVE_CUDA)
APIARY_INSTANTIATE_AS("Matrix_double_2_cuda",
                              Matrix<double, 2, cuda::DeviceAllocator<double>>)
#endif
template <typename T, size_t rank, typename Alloc>
class Matrix { ... };

Toggle MYLIB_WITH_CUDA and reconfigure — the codegen picks up the Defines.hpp mtime change and re-fires automatically; the CUDA instantiation appears (or disappears) in the generated bindings + stubs.

About

A libclang-based C++ introspection tool: generates pybind11/nanobind bindings, .pyi stubs, Sphinx docs, and a public-API IR from annotated headers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages