vcraft
Native Python extensions written in V.
Documentation: a guide and a reference, in English and Spanish, are at https://rroblf01.github.io/vcraft/ (https://rroblf01.github.io/vcraft/es/). This README stays the source of truth and every V example in it is built by the test suite.
vcraft is to the V language what PyO3 + maturin is to Rust:
CPython bindings written in V, a code generator that turns annotated V declarations
into a Python API, and a build tool that compiles the result and writes a wheel you
can upload to PyPI. There is no Rust, no C++ and no zlib anywhere in the pipeline.
// src/my_extension.v
module my_extension
// Adds two integers and returns the result.
@[vc_fn]
pub fn add(a int, b int) int {
return a + b
}
>>> import my_extension as m
>>> m.add(2, 3)
5
>>> help(m.add)
add(a: int, b: int) -> int
Adds two integers and returns the result.
Highlights
- One annotation per declaration.
@[vc_fn],@[vc_class],@[vc_method],@[vc_field]and friends become functions, classes, methods and properties, with docstrings and a generated.pyistub for type checkers. - Fast calls. The generated glue calls your V functions with their real types
through
METH_FASTCALL: no boxing layer, no reflection. Call overhead is on par with or below PyO3 in the benchmarks. - Real Python semantics. V errors become exceptions (custom exception classes included), panics are caught instead of killing the interpreter, classes support inheritance, equality, hashing, iteration and garbage-collected reference cycles.
- Zero-copy and GIL-free.
[]u8and[]stringparameters alias Python's buffers,@[vc_nogil]releases the GIL around a call, and free-threaded CPython (3.13t, 3.14t, 3.15t) is supported. - Wheels without the toolchain zoo.
vcraft buildwrites the wheel itself (DEFLATE, ZIP, RECORD, tags) for regular, abi3 and free-threaded builds, manylinux and musllinux, and refuses version combinations that would only fail after install. - CI included.
vcraft generate-ciwrites a GitHub Actions matrix that builds, installs and imports every wheel, using published manylinux/musllinux images and a reusable action.
Quick start
1. Install vcraft (Linux x86_64 or macOS arm64, Python 3.11+):
$ pip install vcraft
$ vcraft --version
2. Install the V compiler. pip ships the tool, not the toolchain, the same way
maturin needs Rust. vcraft needs a V newer than the 0.5.2 release, so it builds the
commit it is tested with into its own cache (about five minutes, once; needs git,
make and a C compiler):
$ vcraft toolchain install
$ vcraft toolchain # which compiler vcraft uses, and whether it is the pinned one
vcraft finds it there by itself. VCRAFT_V=/path/to/v uses another V instead.
You also need a C compiler (gcc or clang) and the CPython headers, which most Python installs already include.
3. Create, develop and build a project:
$ vcraft new my_extension
$ cd my_extension
$ python -m venv .venv && source .venv/bin/activate
$ vcraft develop # build and install into the active venv
$ python main.py # the example `vcraft new` wrote
Hello, world!
5
1
parse_int: invalid literal for int()
$ python -m unittest # the test under tests/
$ vcraft build --release # dist/my_extension-0.1.0-cp314-cp314-<platform>.whl
vcraft new writes a Python package my_extension that re-exports the native module
my_extension_native, so import my_extension is the public name. Pass
--module my_extension if you would rather have no separate native module at all.
4. Ship it. vcraft generate-ci writes a GitHub Actions workflow that builds a
wheel per supported Python and platform, installs and imports each one, and is ready
to publish to PyPI. See Continuous integration.
Table of contents
- Why
- How it works
- The annotation vocabulary
- Type marshalling
- Keyword arguments and defaults
- Classes and properties
- Errors and panics
- The command line
- Wheels
- Generated project layout
- Continuous integration
- Configuration
- Performance
- Project status
- Built with vcraft
- Stability
- Development
- Design notes
- License
Why
V compiles to fast native code through a tiny C backend and has no runtime dependencies of its own. That makes it a good fit for native Python extensions, but there has been no equivalent of PyO3: writing to the CPython C API from V means hand-declaring every foreign function, hand-writing the module initialiser, and hand-rolling the wheel.
vcraft removes all three chores.
| Rust ecosystem | vcraft | |
|---|---|---|
| Native bindings | PyO3 | vlib/vcraft (written in V) |
| Binding generation | #[pyfunction] |
@[vc_fn] |
| Class bindings | #[pyclass] |
@[vc_class] |
| Error translation | Result<T, E> |
!T / error / recover() |
| GIL handling | Python::detach |
@[vc_nogil] |
| Build tool | maturin | vcraft build |
| Local install | maturin develop |
vcraft develop |
| CI | maturin-action |
vcraft-action@v1 |
| Dependency install | cargo add |
v import |
How it works
A CPython extension module is nothing more than a shared library that exports a
single symbol, PyInit_<name>. V can already produce exactly that:
v -shared -o my_extension/_core.cpython-314-x86_64-linux-gnu.so src/
Three properties of V's -shared mode make this work cleanly:
- Symbol control.
-sharedcompiles with-fvisibility=hiddenand-Wl,--exclude-libs,ALL, so only declarations carrying an@[export: '...']attribute appear in the dynamic symbol table.PyInit_my_extensionis exported; nothing else is. - Automatic lifecycle. V emits
_vinit_callerand_vcleanup_calleras ELF constructors and destructors, so the V runtime and its garbage collector are initialised when CPythondlopens the module and torn down when it is unloaded. No manual init hook is needed. - No link against
libpython. Extension modules leave thePy*symbols undefined; the dynamic linker resolves them against the already-loadedlibpythonin the global scope. This is how every CPython extension works, and it is why a wheel built for one CPython minor version stays tied to it.
On top of that, vcraft adds a code generator. It parses your V sources,
discovers the annotated declarations, and emits the glue that CPython needs:
src/my_extension.v ──vcraft codegen──▶ src/_vcraft_generated.v
(and python/my_extension.pyi)
│
▼
v -shared ──▶ my_extension.cpython-314-x86_64-linux-gnu.so
│
▼
dist/my_extension-0.1.0-cp314-cp314-linux_x86_64.whl
├── my_extension.cpython-314-x86_64-linux-gnu.so
└── my_extension.pyi
The extension goes at the root of the wheel, not inside a directory named after it:
a directory with no __init__.py is a namespace package, and Python resolves one
without opening the files inside it, so the module would import empty.
The generated glue is a normal V file. It calls your functions with their real V types, so a call from Python becomes a direct C call with no intermediate marshalling layer and no boxing.
The annotation vocabulary
Annotations are read from your source text. V keeps declaration attributes in the
type checker rather than in the parse tree, so vcraft reads the @[...] block
that immediately precedes each declaration. Any name works; these are the ones
vcraft recognises.
| Annotation | Applies to | Effect |
|---|---|---|
@[vc_fn] |
pub fn |
Exports the function as a module-level Python callable |
@[vc_class] |
pub struct |
Creates a Python type backed by the V struct |
@[vc_method] |
methods | Adds the method to the class of its receiver |
@[vc_field] |
struct fields | Exposes the field as an attribute of the instance |
@[vc_buffer] |
struct fields | A persistent native []T of scalars, freed at dealloc |
@[vc_buffer_typed] |
struct fields | With @[vc_buffer]: expose it as a typed memoryview |
@[vc_property] |
methods | Registers the method as a Python property |
@[vc_base] |
pub struct |
Makes the class inherit the named one |
@[vc_ref] |
struct fields | Exposes the field as a strong reference to another instance |
@[vc_error] |
pub struct |
Makes the struct usable as the error of a !T function |
@[vc_static] |
methods | Registers the method as a staticmethod |
@[vc_classmethod] |
methods | Registers the method as a classmethod |
@[vc_len] |
methods | The method is __len__ (len(instance), truthiness) |
@[vc_getitem] |
methods | The method is __getitem__ (instance[i]) |
@[vc_contains] |
methods | The method is __contains__ (x in instance) |
@[vc_eq] |
pub fn |
The function is __eq__ (==, !=) |
@[vc_lt], @[vc_le], @[vc_gt], @[vc_ge] |
pub fn |
The function is the matching ordering operator |
@[vc_add], @[vc_sub], @[vc_mul], @[vc_truediv], @[vc_floordiv], @[vc_mod], @[vc_pow] |
pub fn |
The function is the matching arithmetic operator |
@[vc_lshift], @[vc_rshift], @[vc_and], @[vc_or], @[vc_xor] |
pub fn |
The function is the matching bitwise operator |
@[vc_radd], @[vc_rsub], … |
pub fn |
The reflected form, reached when the instance is the right operand |
@[vc_neg], @[vc_pos], @[vc_abs], @[vc_invert] |
pub fn |
The function is the unary operator |
@[vc_hash] |
pub fn |
The function is __hash__ |
@[vc_const] |
pub const |
Exposes the constant as a module attribute |
@[vc_defaults] |
pub fn, methods |
Default values: @[vc_defaults: 'step=1, name="x"'] |
@[vc_raw] |
pub fn |
Skips marshalling; you receive and return voidptr yourself |
@[vc_nogil] |
pub fn |
Runs the call with the GIL released |
@[vc_memoryview] |
pub fn, methods |
Returns a native slice as a writable memoryview (no copy) |
@[vc_iter] |
methods | Makes the instance its own iterator (__iter__) |
@[vc_next] |
methods | Produces one item per call (__next__) |
Names are taken from the V declaration, verbatim. Doc comments become __doc__, the
marshalled signature becomes __text_signature__, and the same source also produces
a .pyi stub, so help() and a type checker see the same thing.
The prefix is part of the name rather than a namespace because V rejects two
annotations sharing one: @[vc.fn] and @[vc.raw] on the same declaration is
duplicate attribute 'vc'.
Type marshalling
Values are converted with direct CPython calls whenever a fast path exists — for
example a V int becomes PyLong_AsLongLong, not a round trip through a
generic value tree.
| V | Python | Notes |
|---|---|---|
bool |
bool |
Any object in, by its truth value |
i8, i16, i32, int, i64, isize |
int |
Range-checked: out of range raises OverflowError |
u8, u16, u32, u64, usize |
int |
Negative or out of range raises OverflowError |
f32, f64 |
float |
An int is accepted too |
rune |
int |
The code point |
string |
str |
UTF-8 |
[]u8 |
bytes |
Any bytes-like object in, without copying it |
[]T of the types above, or string |
list |
Any sequence in (a list, a tuple); a list out |
[N]T of the types above |
list |
Any sequence of exactly N items in; a list out |
?T of the types above |
T or None |
none is None, both ways |
map[K]T, K a scalar or string |
dict |
Scalar or str keys; a new dict out |
(A, B, ...), as a result |
tuple |
A multi-value return |
voidptr |
any object | Borrowed; you own the reference |
vcraft.PyObj (result) |
any object | Owned; the reference goes to the caller |
&T, where T is a @[vc_class] |
T |
An instance reference; accepts a subclass |
an enum the project declares |
that enum.IntEnum |
Members keep their names and values |
a plain struct the project declares |
dict |
Field names as keys |
void, !void |
None |
|
!T |
T, or raises |
See Errors and panics |
Fields of a @[vc_class] struct and method parameters follow the same table.
Composites nest, so ?[]int, [][]int and map[string][]int cross both ways as
nested list/dict/None. An enum the project declares becomes an
enum.IntEnum of the same name, so a value reads back as the member, and a
plain struct becomes a dict of its fields. V function types are not
supported yet; they are on the
roadmap. An instance reference (&T) is supported; see
Instance references. A @[vc_class] is constructed from
Python with its class, through new_<class> when one is declared.
Anything not in this table is a compile-time diagnostic pointing at the exact file, line and column, not a runtime surprise.
Keyword arguments and defaults
Every parameter can be passed by position or by name, as in a Python function. A ?T
parameter may be left out and arrives as none; any other parameter gets a default
from @[vc_defaults], since V has no default arguments of its own:
// Formats a count, with an optional step and label.
@[vc_fn]
@[vc_defaults: 'step=1, label="item"']
pub fn describe(count int, step int, label string, unit ?string) string {
suffix := unit or { '' }
return '${label}: ${count * step}${suffix}'
}
>>> m.describe(3)
'item: 3'
>>> m.describe(3, label="box", unit="kg")
'box: 3kg'
>>> m.describe()
TypeError: describe() missing required argument: 'count'
Defaults are literals of the parameter's type (a number, true/false, or a quoted
string), for bool, integer, float and string parameters; anything else is a
diagnostic. A call that passes every parameter by position costs what it did before
keywords existed: one comparison.
Classes and properties
// A counter with state.
//
// Fields must be scalars. A V string inside a Python object would be a pointer
// that V's collector cannot see, because it does not scan memory CPython
// allocated, so the string would be reclaimed while Python still held it. Reach a
// string through a method, which marshals it properly.
@[vc_class]
pub struct Counter {
mut:
// value is the running total.
@[vc_field] value int
@[vc_field] step int
}
// new_counter builds a Counter. It takes no arguments; the convention is that
// `new_<Class>` is the constructor, and an instance is made by calling the type.
pub fn new_counter() &Counter {
return &Counter{ step: 1 }
}
// increment adds step to value and returns the new total.
@[vc_method]
pub fn (mut c Counter) increment() int {
c.value += c.step
return c.value
}
// set_step changes how much each increment adds.
@[vc_method]
pub fn (mut c Counter) set_step(step int) {
c.step = step
}
// is_zero reports whether the value is still zero.
@[vc_method]
@[vc_property]
pub fn (c &Counter) is_zero() bool {
return c.value == 0
}
>>> c = m.Counter()
>>> repr(c)
'Counter(value: 0, step: 1)'
>>> c.increment(), c.increment()
(1, 2)
>>> c.step = 5
>>> c.increment()
7
>>> c.is_zero
False
>>> c.value = 100
>>> c
Counter(value: 100, step: 5)
A class becomes a CPython heap type created with PyType_FromSpec. The state lives
in a block CPython allocates, methods and accessors work on that block in place, and
tp_dealloc frees the block.
An @[vc_eq] and an @[vc_hash] function fill the type's comparison and hash slots.
They are free functions rather than methods, because V allows exactly one receiver per
method and a comparison needs both operands:
@[vc_eq]
pub fn counter_eq(a voidptr, b voidptr) bool {
mut x := Counter{}
mut y := Counter{}
vcraft.load_state(a, voidptr(&x), sizeof(Counter))
vcraft.load_state(b, voidptr(&y), sizeof(Counter))
return x.value == y.value && x.step == y.step
}
@[vc_hash]
pub fn counter_hash(self voidptr) int {
mut c := Counter{}
vcraft.load_state(self, voidptr(&c), sizeof(Counter))
return c.value * 31 + c.step
}
>>> a, b = m.Counter(), m.Counter()
>>> a == b, {a, b}
(True, {Counter(value: 0, step: 1)})
The two go together. Python's dicts assume that two objects which compare equal hash the
same, so a value comparison with an identity hash misses every lookup in a set or a dict
key without reporting anything. Only == and != reach the function; the ordering
operators return NotImplemented, which is what lets Python try the other operand's
reflected method before failing with an error that names the type. Comparing against
another type is False rather than a TypeError, which is what NotImplemented buys.
An @[vc_field] becomes a read/write attribute. Assigning the wrong type raises
TypeError, and deleting one raises AttributeError: both come for free from
registering a setter, rather than from a hand-written check per field. An
@[vc_property] method becomes a read-only property, and a plain @[vc_method]
method takes arguments like any other exposed function.
An annotation goes on the field's own line, @[vc_field] value int. V also accepts an
attribute on the line above, but vcraft reads the field's line and nothing else, so an
@[vc_field] written there leaves the field unexposed. Rather than let it vanish, the
generator warns and names the field, and still builds.
The constructor runs in tp_new, and it may take arguments: new_vector(4) is called by
Vector(4). They are read from the tuple and dict Python passes tp_new with the same
marshalling rules a method's parameters use, so keyword arguments and @[vc_defaults]
work, and the signature reaches the .pyi stub as __init__. An argument-free
constructor is still a TypeError when called with arguments, as before.
A subclass constructor carries its base's parameters as well, in front of its own:
newstate builds the base's half of the state first, so tp_new binds the base's
parameters too and forwards them on. With new_root(n int) and new_mid(m int),
Mid(10, 3) builds Root with 10 and Mid with 3, and a subclass with no
constructor of its own still accepts its base's parameters (so Mid's own new_mid
takes only m). Because both sets travel in the one Python signature the subclass binds,
they need distinct names; a shared name is reported.
Docstrings reach __doc__ on the type, its methods, its properties and its fields.
Inheritance
@[vc_base(Name)] makes a class inherit another. Declaration order does not
matter: a subclass may be written before its base, or in a file that sorts
earlier, and the generator orders the classes itself.
@[vc_class]
@[vc_base(Counter)]
pub struct BoundedCounter {
mut:
@[vc_field] limit int
}
// bump adds `by`, refusing to pass the limit.
//
// The receiver is `BoundedCounter`, which names only `limit`: V has no struct
// inheritance, so `c.value` does not compile here. `vcraft.state_at(1)` is the
// base's own struct inside the live state of the instance the method is running
// on.
@[vc_method]
pub fn (mut c BoundedCounter) bump(by int) !int {
mut base := unsafe { &Counter(vcraft.state_at(1)) }
if base.value + by > c.limit {
return vcraft.raise_domain(.value_error, 'the counter would pass its limit')
}
base.value += by
return base.value
}
What Python sees is ordinary single inheritance: one base, the base's fields,
methods and properties on the subclass, the subclass's own alongside them,
isinstance in both directions behaving as it should, and a Python subclass on
top of it working too.
Two details are worth knowing.
state_at takes a level, not a name. Level 1 is the immediate base, level 2 the
one above it, and so on, so a chain three deep can reach its root from the
grandchild. It is a level rather than a base because the offsets are not uniform:
each generation's state is its own base followed by its own struct, so where a
generation's struct sits depends on how many generations are above it. The
generator computes the address and publishes it; a method asks for the level it
wants. Eight levels is the cap, and a deeper chain is reported as an error rather
than silently truncated.
The pointer is only valid while the method runs. It points into the instance's
inline state, which the method works on in place.
There is nowhere to put it on the receiver: &c is a copy of the subclass struct
and does not contain the base's bytes, so a method cannot tell from it whether it
is holding the whole state or half of it.
>>> b = BoundedCounter()
>>> b.value, b.step, b.limit
(0, 1, 0)
>>> b.limit = 10
>>> b.bump(4)
4
>>> b.increment() # a method of the base
5
>>> repr(b)
'BoundedCounter(value: 5, step: 1, limit: 10)'
>>> isinstance(b, Counter)
True
Instance references
A parameter or result typed &T, where T is a @[vc_class], is an instance
reference: the wrapper passes the instance itself rather than marshalling a
pointer across the boundary.
A &T parameter accepts an instance of T or of a subclass, and the V function
receives a pointer into that instance's live state, so it reads and writes the
same bytes a method of T would. Passing anything else raises TypeError:
// counter_value reads any counter, including a subclass instance.
@[vc_fn]
pub fn counter_value(c &Counter) int {
return c.value
}
// pick returns the instance the pointer came from.
@[vc_fn]
pub fn pick(first &Counter, second &Counter) &Counter {
return first
}
A &T result is handed back to Python as an instance, not a pointer. The
returned pointer has to point into an instance the call already has — the
receiver of a method, or one of its &T arguments — so the wrapper can match it
and give Python the object that owns it:
// self_ref returns the receiver itself.
@[vc_method]
pub fn (c &Counter) self_ref() &Counter {
return c
}
A &T result that points anywhere else raises RuntimeError: vcraft will not
guess at an object it does not own. A plain function (no receiver) that returns
&T therefore needs at least one &T parameter, and that is a compile-time
diagnostic rather than a runtime surprise.
Comparisons, sequence protocols and classmethods
@[vc_eq] and @[vc_hash] are a value comparison and its hash; @[vc_lt], @[vc_le],
@[vc_gt] and @[vc_ge] add ordering, so <, sorted() and heapq work. Each is a
free function over two state pointers, because V allows one receiver per method and an
operator needs two operands:
@[vc_lt]
pub fn counter_lt(a voidptr, b voidptr) bool {
mut x := Counter{}
mut y := Counter{}
vcraft.load_state(a, voidptr(&x), sizeof(Counter))
vcraft.load_state(b, voidptr(&y), sizeof(Counter))
return x.value < y.value
}
@[vc_ge]
pub fn counter_ge(a voidptr, b voidptr) bool {
mut x := Counter{}
mut y := Counter{}
vcraft.load_state(a, voidptr(&x), sizeof(Counter))
vcraft.load_state(b, voidptr(&y), sizeof(Counter))
return x.value >= y.value
}
@[vc_len], @[vc_getitem] and @[vc_contains] fill the sequence slots, so
len(instance), bool(instance), instance[i] and x in instance reach ordinary
methods. @[vc_classmethod] is registered like a static method but CPython passes the
class, so a subclass calling it sees itself. @[vc_const] exposes a pub const literal
as a module attribute:
@[vc_class]
pub struct Range {
mut:
@[vc_field] n int
}
@[vc_fn]
pub fn new_range(n int) &Range {
return &Range{ n: n }
}
@[vc_method]
@[vc_len]
pub fn (r &Range) size() int {
return r.n
}
@[vc_method]
@[vc_getitem]
pub fn (r &Range) at(i int) !int {
if i < 0 || i >= r.n {
return vcraft.raise_domain(.index_error, 'Range index out of range')
}
return i
}
@[vc_method]
@[vc_contains]
pub fn (r &Range) has(x int) bool {
return x >= 0 && x < r.n
}
@[vc_classmethod]
pub fn Range.starting_at(n int) int {
return n
}
@[vc_const]
pub const default_range = 8
Numeric operators
@[vc_add], @[vc_sub], @[vc_mul], @[vc_truediv], @[vc_floordiv], @[vc_mod],
@[vc_pow], @[vc_lshift], @[vc_rshift], @[vc_and], @[vc_or] and @[vc_xor]
fill CPython's number slots, so +, -, *, /, //, %, **, <<, >>, &,
| and ^ work; @[vc_neg], @[vc_pos], @[vc_abs] and @[vc_invert] are the unary
ones. Each is a free function (self &T, operand) -> result, where the operand is a
scalar or another &T and the result is a number or a new T, which vcraft allocates
and fills:
@[vc_class]
pub struct Account {
mut:
@[vc_field] balance int
}
@[vc_fn]
pub fn new_account(balance int) &Account {
return &Account{ balance: balance }
}
@[vc_add]
pub fn account_add(a &Account, n int) Account {
return Account{ balance: a.balance + n }
}
@[vc_sub]
pub fn account_sub(a &Account, b &Account) int {
return a.balance - b.balance
}
@[vc_neg]
pub fn account_neg(a &Account) Account {
return Account{ balance: -a.balance }
}
A scalar on the left (2 + account) reaches the reflected function. Write
@[vc_radd], @[vc_rsub] and the rest with the same shape; for the commutative
operators (add, mul, and, or, xor) the forward function answers the reflected
call when no reflected one is given. An operand the function does not accept leaves the
slot returning NotImplemented, so Python raises its own TypeError rather than vcraft
guessing at a conversion.
A V function can call back into Python with vcraft.call(callee, args) (borrowed
arguments, a new reference back) or vcraft.call_owned(callee, args) (it releases the
arguments), and vcraft.getattr / vcraft.call_method reach an object's methods. The
callable usually arrives as a voidptr parameter:
@[vc_fn]
pub fn apply(f voidptr, x int) !int {
args := [vcraft.to_py_int(i64(x))]
r := vcraft.call_owned(f, args)
if vcraft.error_is_set() {
return error('callback failed')
}
out := vcraft.from_py_int(r, 'result')!
r.decref()
return out
}
Reference fields and cycles
@[vc_ref(Name)] exposes a field as a strong reference to another instance. The field
is declared vcraft.PyObj, because that is what it holds: a pointer and a reference
count, and nothing V's collector would recognise.
@[vc_class]
pub struct Node {
mut:
@[vc_field] label int
@[vc_ref(Node)] peer vcraft.PyObj
}
// link makes two nodes point at each other.
//
// `retain`, not `steal`: a function parameter is borrowed, and the field has to keep the
// object alive on its own.
@[vc_method]
pub fn (mut n Node) link(other voidptr) {
n.peer = vcraft.retain(other)
}
A class holding references is created with Py_TPFLAGS_HAVE_GC and gets tp_traverse
and tp_clear, so a cycle of instances is collected rather than leaked. Two details are
load-bearing and neither is obvious.
Py_TPFLAGS_HAVE_GC makes the type object itself collectable, so CPython calls
tp_traverse and tp_clear on the type as well as on its instances. A type's bytes after
the header are its dict, not V state, so both trampolines check whether they were
handed a type and hand that case to CPython before reading anything.
PyObject_GC_UnTrack reads a collector header that only exists on a collectable
allocation, so the deallocator's untrack is guarded on the flag. A class with no reference
fields does not set it, and untracking its instances unconditionally crashes several
collections later, inside PyObject_GC_UnTrack, with nothing in the frame pointing back.
__repr__ is guarded with Py_ReprEnter for the same reason a cycle needs collecting in
the first place: two nodes that point at each other would otherwise recurse until the C
stack ran out, and the segfault would name neither.
The reference count is maintained by hand, because a PyObj is deliberately not a V
reference type. retain takes a reference of your own on a borrowed pointer, which is
what a method parameter is; set_ref counts one when Python assigns through the property;
clear_ref gives it back in tp_clear and tp_dealloc. A property setter's value is
borrowed, so the field counts its own rather than adopting CPython's.
Weak references are not supported yet: that needs a tp_weaklistoffset inside the
instance and registration in tp_traverse.
Persistent native buffers
An @[vc_buffer] field is a slice of scalars that lives for the object's lifetime, is
not copied on each call, and is freed exactly once at dealloc. It is how numeric state
stays in V across many calls instead of crossing the boundary per element: a stabilizer
tableau, a state vector, a scratch workspace.
// A vector of doubles held in a native buffer.
//
// The backing is allocated by `buffer_new_f64` in the constructor and freed by the
// generated `tp_dealloc`. `memoryview(v)` exposes the first buffer of the class with no
// copy; the others, if any, are reached through methods.
@[vc_class]
pub struct Vector {
mut:
@[vc_buffer] data []f64
}
// new_vector allocates a zeroed buffer of `n` doubles. The constructor takes an
// argument, which `tp_new` binds and passes here.
@[vc_fn]
pub fn new_vector(n int) &Vector {
return &Vector{
data: vcraft.buffer_new_f64(n)
}
}
// set stores a value at `i`, ignoring an out-of-range index.
@[vc_method]
pub fn (mut v Vector) set(i int, value f64) {
if i >= 0 && i < v.data.len {
v.data[i] = value
}
}
// total sums the buffer in V, with no per-element crossing of the boundary.
@[vc_method]
pub fn (v &Vector) total() f64 {
mut sum := 0.0
for x in v.data {
sum += x
}
return sum
}
// resize replaces the buffer with `n` zeroed doubles, refusing while a memoryview is open.
@[vc_method]
pub fn (mut v Vector) resize(n int) ! {
v.data = vcraft.buffer_resize_f64(v.data, n)!
}
>>> v = m.Vector(4) # calls new_vector(4)
>>> v.set(0, 1.5); v.set(1, 2.5)
>>> v.total()
4.0
>>> mv = memoryview(v) # zero-copy; writes go both ways
>>> mv.cast('d')[2] = 9.0
>>> v.total()
13.0
>>> v.resize(2) # refused while the view is alive
BufferError: cannot resize a buffer while a memoryview of it is open
The backing is allocated with the CPython allocator, not V's, because the state is
CPython-owned memory that V's collector does not scan: a V-heap pointer kept there would
look unreachable and be reclaimed while Python still held the object. It is therefore
owned by hand, and the only values an @[vc_buffer] field may hold are the ones the
buffer_new_* helpers return; assigning an ordinary V array to it would hand buffer_free
a pointer it does not own. The first @[vc_buffer] field of a class is the one
memoryview(instance) exposes; a class with more than one gets a warning, and the rest are
read and written through methods. A class that only inherits a buffer is covered by the
base's slot.
That memoryview is a 1-D byte view by default (format "B", itemsize 1, length in
bytes). A @[vc_buffer_typed] beside @[vc_buffer] opts into a typed view instead: one
item per element, with the element's own format and width. A []f64 then reports
format "d", itemsize 8 and a length in elements, so list(memoryview(x)) and
struct.unpack read the values directly. Both annotations go in one block on the field's
own line:
@[vc_class]
pub struct Samples {
mut:
@[vc_buffer; vc_buffer_typed] samples []f64
}
A resize refuses with BufferError while a memoryview is open, because reallocating
moves the memory a live view still points at. A class that holds both an @[vc_buffer] and
an @[vc_ref] is collectable and owns native memory: tp_dealloc releases the references
and frees the buffers before the object goes.
Zero-copy results with @[vc_memoryview]
@[vc_buffer] keeps native state inside an object; @[vc_memoryview] is its mirror
image, applied to a function that returns a slice. Instead of building a Python list and
boxing every element, the result crosses as a writable memoryview over the same memory,
and the view owns the backing: it is freed when Python collects the view, through the same
allocator. The function must therefore return a slice built by buffer_new_* or
buffer_resize_*; an ordinary V array lives on V's heap and must never be handed over.
// A ramp of doubles, returned as a writable memoryview.
//
// The backing comes from `buffer_new_f64`, so the memoryview owns it and frees it when
// the view is collected. A plain `[]f64` returned here would be freed by the wrong
// allocator and crash.
@[vc_fn]
@[vc_memoryview]
pub fn ramp(n int) []f64 {
mut out := vcraft.buffer_new_f64(n)
for i in 0 .. n {
out[i] = f64(i)
}
return out
}
>>> mv = m.ramp(4)
>>> mv.format, mv.itemsize, mv.readonly
('d', 8, False) # writable, so writes go both ways
>>> del mv # frees the native backing
The stub says memoryview. @[vc_memoryview] combines with @[vc_fn] or @[vc_method]
exactly as @[vc_nogil] does, and is refused on a @[vc_raw] function (raw already hands
you the PyObject). The element type must be a buffer scalar -- bool, an integer or a
float -- and the return type a slice or []u8-like sequence of one.
For state that is not a plain slice -- a handle from a C library, a struct full of
pointers -- vcraft.new_handle(value, destructor) wraps a native pointer in an anonymous
PyCapsule, and vcraft.handle_data(capsule) recovers it. The capsule calls destructor
when Python collects it; CPython passes the destructor the capsule object, so call
handle_data there to get the pointer back:
// new_box allocates an int behind a capsule that frees it.
@[vc_fn]
pub fn new_box(value int) vcraft.PyObj {
p := vcraft.native_alloc(usize(sizeof(int)))
if p == unsafe { nil } {
panic('memory allocation failure')
}
unsafe {
*(&int(p)) = value
}
return vcraft.new_handle(p, voidptr(box_free))
}
fn box_free(capsule voidptr) {
vcraft.native_free(vcraft.handle_data(capsule))
}
This is the general escape hatch next to @[vc_raw]: a @[vc_raw] function can take the
capsule as an opaque voidptr and call vcraft.handle_data on it.
Custom error types
@[vc_error] marks a struct as the error of a !T function. V requires msg() and
code(), and the generator reports the annotation on a struct that has not got both rather
than letting it fail at the V compiler with a message about an interface.
There are three ways for such an error to name its Python exception, and they are not equivalent.
code() is the exception. The enum's own numbering is the channel, so nothing else is
needed:
@[vc_error]
pub struct ConfigError {
pub:
detail string
line int
}
pub fn (e ConfigError) msg() string {
return 'line ${e.line}: ${e.detail}'
}
// The code *is* the exception: `PyExc.value_error` is 2.
pub fn (e ConfigError) code() int {
return int(vcraft.PyExc(.value_error))
}
@[vc_fn]
pub fn load(text string) !string {
// ...
return ConfigError{ detail: 'not a name=value line', line: 1 }
}
A code outside the enum is an ordinary error code rather than an exception choice, and
those are common, so anything unrecognised becomes a RuntimeError. code() == 0, which
is every anonymous error('...'), is a RuntimeError too: 0 is PyExc.none, whose
object is the null pointer, and PyErr_SetString writes through it rather than checking.
raise_custom sets an arbitrary class. For an exception vcraft has no name for,
including one the caller defined in Python:
@[vc_fn]
pub fn parse(text string, missing voidptr) !int {
if text.len == 0 || !text.bytes().all(it.is_digit()) {
return vcraft.raise_custom(missing, 'not a number')
}
return text.int()
}
A PyObj field plus the generated raiser. To keep the class in the error value rather
than passing it around, one PyObj field and the raiser the generator emits:
@[vc_error]
pub struct Rejected {
pub:
exc vcraft.PyObj
detail string
}
pub fn (e Rejected) msg() string { return e.detail }
pub fn (e Rejected) code() int { return 0 }
// generated:
// fn vcraft_generated__raise_rejected(e Rejected) IError {
// return vcraft.raise_custom(e.exc.ptr, e.msg())
// }
@[vc_fn]
pub fn checked(value int, exc voidptr) !int {
if value < 0 {
return vcraft_generated__raise_rejected(Rejected{
exc: vcraft.borrow(exc)
detail: 'value must not be negative'
})
}
return value * 2
}
Two PyObj fields are reported rather than guessed at, since there is no way to tell which
one is the class.
What none of these can do is carry the choice inside the error and have the wrapper read
it there: V erases an error to IError by the time the wrapper sees it, and an IError
has a message and nothing else. That is why raise_domain sets the exception at the point
of failure, and why the raiser exists.
Buffers without copying
A []u8 parameter accepts anything bytes-like through the buffer protocol:
// checksum adds every byte it is given.
//
// `bytes`, `bytearray`, `memoryview`: nothing is copied on the way in.
@[vc_fn]
pub fn checksum(data []u8) int {
mut total := 0
for b in data {
total = (total + int(b)) & 0xffffff
}
return total
}
The wrapper acquires a view, aliases it as a V slice for exactly the call, and
releases it on the way out -- including the error paths, which is why the release
is deferred rather than written after the call. The slice must not outlive the
call: the release drops the exporter's reference, and a stored slice would point
at memory nobody owns. Returns copy the other way, because an immutable Python
bytes cannot alias V memory.
Iterators
@[vc_iter] and @[vc_next] always come as a pair: an iterator that cannot produce
items fails at the first next(), and items without an iterator are unreachable,
so a half pair is reported rather than emitted.
@[vc_class]
pub struct Countdown {
mut:
@[vc_field] current int
@[vc_field] start int
}
// rewind resets the countdown, so the same instance can be iterated twice.
@[vc_method]
@[vc_iter]
pub fn (mut c Countdown) rewind() {
c.current = c.start
}
// next yields the current value and steps down, refusing past zero.
@[vc_method]
@[vc_next]
pub fn (mut c Countdown) advance() !int {
if c.current <= 0 {
return vcraft.raise_domain(.stop_iteration, 'no more values')
}
c.current--
return c.current + 1
}
>>> c = Countdown()
>>> c.start = 3
>>> c.current = 3
>>> list(c)
[3, 2, 1]
The instance is its own iterator: @[vc_iter] runs for its side effects and the
slot returns the instance itself. @[vc_next] produces one item per call, and a V
error ends the iteration -- cleanly for StopIteration, loudly for anything else,
which is CPython's own contract for the slot rather than something vcraft
invented.
Errors and panics
A !T return becomes a Python exception:
// Greet someone by name.
@[vc_fn]
pub fn greet(name string) !string {
if name.len == 0 {
return error('name must not be empty')
}
return 'Hello, ${name}!'
}
>>> m.greet('')
RuntimeError: name must not be empty
A bare error('...') carries nothing but a message, so it becomes a
RuntimeError. That is right often enough to hide the problem: Python code that
divides by zero expects ZeroDivisionError, and an except ZeroDivisionError
around a call into a V extension silently stops matching. So a failure that means a
particular exception says so:
// Divides two floats, refusing a zero divisor.
@[vc_fn]
pub fn divide(a f64, b f64) !f64 {
if b == 0.0 {
return vcraft.raise_domain(.zero_division_error, 'division by zero')
}
return a / b
}
>>> m.divide(1.0, 0.0)
ZeroDivisionError: division by zero
The exception is chosen where the failure happens rather than in the wrapper,
because V gives a wrapper nothing to choose from: every error arrives as an
IError holding a message, and a bare message does not say whether it was a
ValueError or a ZeroDivisionError. raise_domain sets the Python exception and
returns an error, and the wrapper's error_is_set check stops the value from
reaching Python as a result. A wrapper never overwrites an exception that is already
set, so the choice survives.
A custom V error struct cannot carry the choice. V 0.5.2 rejects a value whose type
is not IError, and a struct that implements msg() is not one, so the exception
travels out of band through the pending Python exception instead of inside the error
value.
Argument marshalling failures are separate and never reach your code: a wrong
argument type is a TypeError and the wrong number of arguments is a TypeError,
both naming the parameter.
Panics
A V panic prints a message and calls exit(1), which inside CPython would take
the whole interpreter down with it. V 0.5 has Go-style recovery, so every generated
wrapper installs a frame:
fn _vcraft_generated__wrap_first_char(text string) string {
defer {
if message := recover() {
vcraft.raise_runtime_error('panic in V code: ${message}')
}
}
return first_char(text)
}
>>> m.first_char('')
RuntimeError: panic in V code: substr(0, 1) out of bounds (len=0) s=
>>> m.add(2, 3) # the interpreter is still fine
5
A recovered panic is raised rather than reaching Python as a half-written result,
which is what the wrapper's error_is_set check after the call is for. Panic state
is thread-local in V, so this stays correct under free threading. A method works on
the instance in place, as a PyO3 method does, so one that panics halfway keeps the
fields it had already written.
The guard is inlined per trampoline rather than shared through a helper. V emits no forward declaration for a generic function called across modules, so a shared wrapper fails to compile with an implicit-declaration error.
@[vc_nogil] marks a function as pure V with no Python interaction. The GIL is
released around the call, so long-running V code runs in parallel the way
py.allow_threads does in PyO3:
// spin burns time in pure V, so threads can prove the GIL is really released.
//
// Nothing in here touches Python, raises, or allocates in a way the collector would
// need the interpreter for.
@[vc_fn]
@[vc_nogil]
pub fn spin(iterations int) int {
mut total := 0
for i in 0 .. iterations {
total = (total + i * 7) & 0x7fffffff
}
return total
}
>>> import threading
>>> threads = [threading.Thread(target=lambda: spin(3000000)) for _ in range(4)]
>>> [t.start() for t in threads]
>>> [t.join() for t in threads] # each burns its own core
The annotation is a promise, not a hint, and the generator cannot verify purity. No
Python calls, no raise_domain, no touching a PyObj while released:
raise_domain sets a Python exception, which without the GIL corrupts the
interpreter state rather than reporting anything. A !T function fails with a plain
error(...) instead, and the wrapper turns it into an exception after it holds the
GIL again. @[vc_nogil] on a @[vc_raw] function is refused outright: raw means the
function handles PyObject * itself, which is the opposite of pure.
The pairing is exact on every path -- success, !T failure, and panic -- because an
unbalanced release corrupts the GIL count and crashes the next thread switch inside
CPython rather than anywhere near the call. And the per-thread state chain (see reference fields and cycles) is what keeps two threads in two
trampolines from publishing into each other's slots: with the GIL held one global
would do, and without it each thread needs its own.
The command line
vcraft new <name> scaffold a V project ready for Python
vcraft build [options] build a distributable wheel
vcraft develop [options] build and install an editable pointer by default
vcraft develop --copy install a plain extension copy instead
vcraft sdist build a source distribution
vcraft generate-ci emit a GitHub Actions workflow into .github/workflows/
vcraft publish upload the built distributions to PyPI
vcraft info show what vcraft resolved for this project
vcraft clean remove build output
vcraft version print the version
build options:
--release compile with -prod
--abi3 <version> one wheel usable from CPython <version> onwards (3.11+)
--interpreter <path> build against a specific interpreter
--out-dir <dir> output directory (default: dist/)
--platform <tag> override the platform tag
--target <name> cross-compile for a target, e.g. linux-aarch64-gnu
--manylinux <version> claim a manylinux policy, e.g. 2_17
--musllinux <version> claim a musllinux policy, e.g. 1_2
--cc <compiler> C compiler for V to invoke
--cflags <flags> extra flags for the C compiler
--ldflags <flags> extra flags for the C linker
--dry-run print the build plan without building
--free-threading build against a free-threaded interpreter (3.13t+)
--strip strip symbols
--skip-audit do not validate the resulting wheel
--jobs <n> compiler parallelism
--editable point the environment at this build instead of copying
--copy install a copy, which is the opposite of --editable
vcraft never writes to a system location and never installs anything outside
the project. The V runtime module is located relative to the vcraft binary
itself and passed to the compiler with -path, so VMODULES is left untouched.
A project from scratch, end to end:
$ ./scripts/build-vcraft.sh
$ vcraft new mypkg
$ cd mypkg
$ python -m venv .venv && source .venv/bin/activate
$ vcraft develop
$ python -c "import mypkg as m; print(m.greet('world'))"
Hello, world!
Packaging configuration lives in vcraft.toml, not in v.mod. v.mod is V's
manifest and names the V module; a wheel also needs a distribution name, a version,
a licence and a Requires-Python, and none of those are V's business.
See vlib/vcraft_project/README.md for the TOML
subset that is parsed, and for the compiler constraints that shaped it.
Wheels
$ vcraft build --release
$ pip install dist/my_extension-0.1.0-cp314-cp314-linux_x86_64.whl
A wheel is written from scratch in V: DEFLATE, the ZIP container, METADATA, WHEEL,
RECORD with SHA-256, the compatibility tag, and the file name.
zlib is not linked. It is on most build hosts but not all, and linking it makes the
extension depend on a shared library the manylinux and musllinux images have to agree
on. About 250 lines of fixed-Huffman DEFLATE is cheaper than that or than vendoring
zlib, and the output is read by every unzip. A .so compresses to roughly 43%.
RECORD carries base64 digests with the URL-safe alphabet and no padding, because pip
compares the string. A hex digest installs and then fails verification on every file.
The details that are easy to get wrong are written up in
vlib/vcraft_wheel/README.md. The shortest version:
- The distribution name is escaped, a run of
-_.becoming_. The version is escaped too, but only its dashes:-becomes_and.stays.. Writing0-1-0looks equivalent because PEP 440 agrees it is the same version, but the installer splits the file name on-and pip rejects it with "wrong number of parts". - The
.dist-infodirectory is named after the distribution, never the module. pip checks this before opening the archive. - The extension goes at the archive root. A directory named after the module is a
namespace package, and Python resolves one without opening the files inside it, so
the wheel installs and then imports as an empty module with
__file__ofNone.
Hand-written Python under a project's python/ directory is packaged under the same
relative names; embed-pyc = true ships sourceless .pyc files instead. vcraft develop is editable by default: it installs a .pth pointing at the build output and
the project's Python sources, plus distribution metadata, while develop --copy
installs a plain extension copy. pip install -e . uses the generated PEP 660 backend.
Editable wheels point at the local build tree and are refused by vcraft publish.
Cross-compilation targets
--target names the machine a wheel is built for rather than the one building it.
The canonical form is <os>-<arch>-<libc> -- linux-aarch64-gnu,
linux-x86_64-musl, macos-arm64, windows-amd64 -- and Rust-style triples
like aarch64-unknown-linux-gnu are accepted as aliases. There is deliberately
no universal2: a fat binary needs two architectures linked together and one
build makes one, so the tag always names the architecture actually built. A Linux
policy is claimed separately and explicitly, because the tag is a statement about
the libc the binary was linked against:
$ vcraft build --target linux-aarch64-gnu --manylinux 2_17
$ vcraft build --target linux-x86_64-musl --musllinux 1_2 --cc x86_64-linux-musl-gcc
A target without a policy keeps the plain linux_<arch> tag: claiming manylinux is
a statement a build that has not verified its libc must not make. Naming the host's
own target changes nothing -- the wheel is byte-identical to a default build, which
is checked -- and a --platform that disagrees with the target is refused rather
than warned about, because the wheel would lie about what it contains.
--dry-run prints the resolved OS, architecture, tag, extension suffix and compiler
invocation without touching a toolchain. Planning is pure; compiling is not, so a
target whose cross compiler is not installed verifies this far and fails with the
compiler's name when asked to build for real. Foreign execution -- actually running
an aarch64 or musl wheel -- happens in CI, where the images carry the toolchains.
Generated project layout
What vcraft new my_extension writes:
my_extension/
├── vcraft.toml packaging configuration (see Configuration)
├── v.mod Module { name: 'my_extension_native' }
├── pyproject.toml names the PEP 517/660 build backend
├── vcraft_build.py the backend, emitted by the vcraft that wrote it
├── main.py a runnable example; not packaged into the wheel
├── tests/
│ ├── __init__.py
│ └── test_my_extension.py unittest cases for the example
├── README.md .gitignore
├── python/
│ └── my_extension/
│ ├── __init__.py re-exports the native module
│ └── py.typed
└── src/
└── my_extension_native.v your code
vcraft new writes a Python package named after the distribution that re-exports the
native module, so import my_extension is the public name and my_extension_native
stays an implementation detail. There is no configuration for it: hand-written Python
under a project's python/ directory is packaged under the same relative names, so a
project can mix V and Python freely.
vcraft develop (and any other build) adds src/_vcraft_generated.v and
python/<module>.pyi, both generated and git-ignored, installs the extension, and
writes wheels to dist/. The .pyi stub travels in the wheel next to the extension,
so a type checker sees the signatures help() shows. vcraft generate-ci adds
.github/workflows/build.yml.
main.py and tests/ are for the development loop and are not part of the
distribution, though both travel in the sdist.
pyproject.toml is written once by vcraft new and then merged on every build: vcraft
owns its [build-system] table and preserves everything else the project adds to the
file.
Continuous integration
vcraft generate-ci writes .github/workflows/build.yml for your project:
$ vcraft generate-ci
The matrix is derived from vcraft.toml:
- Regular builds: one wheel per supported CPython from the project's
minimum-versionup, for manylinux x86_64 and aarch64 and for macOS arm64. abi3: one wheel per platform covers every CPython from the floor up, plus musllinux x86_64 and aarch64.free-threading: 3.13t, 3.14t and 3.15t wheels for Linux and macOS.
Linux wheels are built inside published images that already carry V and vcraft,
ghcr.io/rroblf01/vcraft-manylinux (derived from PyPA's manylinux_2_28) and
ghcr.io/rroblf01/vcraft-musllinux (Alpine), each on a native runner for its
architecture. Every wheel is then installed and imported in the image or
interpreter it was built for before the job passes, so a wheel that would fail on a
user's machine fails in CI instead. See docker/README.md for
how the images are built.
The build step uses the vcraft action, which mirrors maturin-action. On a runner
it installs vcraft from PyPI and a cached V compiler; with container: it runs
inside one of the images instead. Linux, macOS and Windows runners are supported.
- uses: rroblf01/vcraft/actions/vcraft-action@v1
with:
vcraft-version: v1.4.3
args: build --release
vcraft is also a PEP 517 build backend (pip install . works), so other
wheel-building tools can drive it, but only the workflow above is tested.
Configuration
Packaging configuration lives in vcraft.toml at the project root. vcraft new
writes one. pyproject.toml is the only other file vcraft touches, and it is merged
rather than replaced: a build rewrites just the [build-system] table and leaves every
other table alone. A project's own [project], [dependency-groups] (PEP 735, so
uv sync --group dev works) and [tool.*] config survive vcraft build and
vcraft develop.
[package]
name = "my-extension"
version = "0.1.0"
module = "my_extension"
description = "A Python extension written in V."
license = "MIT"
requires-python = ">=3.11"
readme = "README.md" # the PyPI project page, rendered as Markdown
keywords = ["fast", "parsing"]
classifiers = ["Programming Language :: Other", "Programming Language :: Python :: 3"]
dependencies = []
[build]
minimum-version = "3.11" # oldest CPython the CI matrix builds a wheel for
abi3 = "3.11" # optional: one stable-ABI wheel for 3.11 and newer
free-threading = false # true: build for a free-threaded CPython (3.13t+)
strip = false
embed-pyc = false
gc-free-space-divisor = 2 # Boehm heap growth: 1 favours speed, 2 memory
cflags = "-march=x86-64-v2" # extra C compiler flags, passed to V as -cflags
# ldflags = "" # extra C linker flags, passed as -ldflags
[urls]
Source = "https://github.com/me/my-extension"
Issues = "https://github.com/me/my-extension/issues"
Before 1.0 the build keys were top-level keys and classifiers were [[classifier]]
tables; both are still read throughout 1.x, with a warning saying what to change.
The keys of [build]:
| Key | Default | Meaning |
|---|---|---|
minimum-version |
"3.11" |
Oldest CPython vcraft generate-ci builds a wheel for |
abi3 |
unset | Stable-ABI floor for a single portable wheel, 3.11 or newer |
free-threading |
false |
Build for a free-threaded interpreter and declare it GIL-free |
strip |
false |
Strip the extension |
embed-pyc |
false |
Ship sourceless .pyc files instead of the project's .py |
gc-free-space-divisor |
2 |
Boehm heap growth divisor; 1 trades memory for speed |
cflags |
unset | Extra flags for the C compiler, e.g. -march=x86-64-v2 |
ldflags |
unset | Extra flags for the C linker |
A wheel built with -march carries that CPU requirement, so it belongs in a private build
or a deliberate platform baseline, not in a wheel meant to run on any x86-64.
In [package], readme names the file PyPI shows as the project page (.md is
rendered as Markdown, .rst as reStructuredText); vcraft new writes a
README.md and points at it. Classifiers must be ones PyPI knows, or it rejects
the upload: V has none of its own, so use Programming Language :: Other. Each
entry of [urls] becomes a link in the PyPI sidebar.
vcraft build refuses the combinations that would compile and then fail later: an
interpreter older than 3.11, an abi3 floor below 3.11 or above the interpreter
building it, abi3 together with free-threading (free-threaded CPython has no
stable ABI), and free-threading on anything older than 3.13.
Performance
The same workloads implemented with PyO3, vcraft and zig-maturin, plus pure Python, measured on Linux x86_64 (AMD Ryzen 5 3600), CPython 3.13, vcraft 1.3.0. Time per call, lower is better:
| workload | PyO3 | vcraft | pure Python |
|---|---|---|---|
add(1, 2): call overhead |
71 ns | 73 ns | 37 ns |
greet(name): str in, new str out |
135 ns | 120 ns | 73 ns |
fib(25): pure compute |
237.3 µs | 112.0 µs | 9.36 ms |
sum_floats(100k): list[float] in |
881.1 µs | 223.3 µs | 1.59 ms |
Counter(): object construction |
96 ns | 92 ns | 97 ns |
c.add(1): method call |
67 ns | 68 ns | 59 ns |
count_primes(1e6): compute + native alloc |
2.00 ms | 2.22 ms | 98.07 ms |
The state a subclass inherits is published only by the trampolines that can
observe it, so a class with no base pays nothing for it; on top of that, the
instance state now lives inside the object, so vcraft constructs objects faster
than PyO3 and its attributes and no-argument methods are ahead too. The vcraft
wheel is the smallest of the three and its clean release build the fastest.
Methodology, every workload, memory use and import time are in
benchmark/README.md and
benchmark/results-linux.md.
Project status
Stable since 1.0. The annotation vocabulary, the vcraft.toml keys and the CLI
change only with a deprecation period (see Stability); every change is
listed in the changelog.
What is tested on every commit:
- CPython 3.11, 3.12, 3.13, 3.14 and 3.15 on Linux x86_64, and 3.11 and 3.15 on macOS arm64, with the full suite: runtime, code generator, wheel writer, packaging and the CLI end to end.
- Free-threaded CPython 3.13t, 3.14t and 3.15t on Linux and 3.15t on macOS, importing with the GIL disabled and calling from several threads.
Known limitations:
- Windows is supported for
win_amd64, and is new.vcraft buildlinks awin_amd64.pydagainst the interpreter's own import library, the action andvcraft generate-cirun onwindows-latest, and the core suites pass there. arm64 Windows and MSVC are not supported yet. - V has to be built from source at the pinned commit until V publishes a release newer than 0.5.2.
- aarch64 Linux wheels and images are built on native runners but are not part of the per-commit test matrix.
- Extensions use single-phase initialisation, so a module is shared by all subinterpreters.
Built with vcraft
vqubit is a dependency-free quantum computing framework for Python in the spirit of Cirq: its linear algebra, gate matrices and simulators are written in V. It ships as a single abi3 wheel per platform, CPython 3.11 and newer, and pulls in nothing else. It is the reference for what a vcraft project looks like end to end — source, docs.
Stability
vcraft follows Semantic Versioning. From 1.0, the annotations,
the vcraft.toml keys, the CLI commands and options, and the Python behaviour of the
conversions in the type table are the public surface:
- A minor release (1.1, 1.2...) only adds to it: new annotations, keys, options and types.
- A name that is replaced keeps working for the rest of 1.x, and every use of it draws a warning naming its replacement. It is removed in 2.0, never before.
- Generated glue, the runtime's V API under
vlib/vcraft, and the wheel's internals are not part of the surface: they change whenever the generator needs them to.
Upgrading a 0.x project: MIGRATING.md. Reporting a vulnerability: SECURITY.md.
Renamed in 1.0, still accepted with a warning: @[vc_gil] (now @[vc_nogil]),
@[vc_methods] (now @[vc_method]), top-level build keys in vcraft.toml (now in
[build]) and [[classifier]] tables (now classifiers = [...]).
Requirements
- Python 3.11 to 3.15, or 3.13t/3.14t/3.15t for free-threaded builds
- Linux (glibc or musl), macOS or Windows (
win_amd64) - A V compiler at the pinned commit:
vcraft toolchain installbuilds it - gcc or clang, and the CPython development headers
- Docker, only to build manylinux or musllinux wheels locally
Development
$ git clone https://github.com/rroblf01/vcraft && cd vcraft
$ ./scripts/build-vcraft.sh # builds bin/vcraft
Each test suite is a standalone, standard-library-only script that builds what it needs:
$ python3 tests/project/check_toml.py # vcraft.toml parser
$ python3 tests/packaging/test_pack.py # the PyPI wheel of vcraft itself
$ python3 tests/wheel/test_wheel.py # wheel writer, checked with zipfile and pip
$ python3 tests/runtime/test_runtime.py # the CPython runtime
$ python3 tests/codegen/test_codegen.py # code generator, end to end
$ python3 tests/memory/test_leaks.py # no leaked objects, references or memory
$ python3 tests/fuzz/test_fuzz.py # hostile arguments: no crash, the right exception
$ python3 tests/docs/test_readme.py # every V example in this README builds
$ scripts/run-sanitized.sh python3 tests/fuzz/test_fuzz.py # the same under ASan and UBSan (Linux)
$ python3 tests/cli/test_cli.py # the CLI against real projects and venvs
They run against whichever python3 is first on PATH. Inside this repository,
invoke V only through scripts/vcraft-v.sh: a bare v answers a failed C compile
by bootstrapping the whole compiler, a multi-gigabyte, multi-minute detour, and the
wrapper turns that off and bounds the build's memory.
Releases are cut by pushing tags; see the release workflows in
.github/workflows.
Design notes
A few decisions worth knowing about, and why they were made.
The generated glue is V, not a foreign language. Since the generated file is compiled by V alongside your code, it can call your functions with their real types. There is no tagged union, no serialisation step and no reflection at runtime, so the cost of a call is a C call plus the conversions you asked for.
The CPython headers are included, never re-declared. V does not emit
prototypes for fn C. declarations, so any module that binds to CPython must
#include <Python.h> rather than rely on V to declare them. See finding 1 in
examples/probe/README.md.
METH_FASTCALL is the default calling convention. It is the cheapest way
CPython can pass positional arguments, and it lets the generated wrapper read the
arguments as a borrowed pointer array. Functions with parameters are registered
as METH_FASTCALL | METH_KEYWORDS: a call that passes every parameter by position uses
the array as it is, and only a call with keywords or left-out parameters is bound into
per-parameter slots first.
Annotations come from the source text. V's parse tree does not keep
declaration attributes; they live in the type checker. V's own v.astquery
module documents the same limitation and advises reading the source. vcraft
therefore parses declarations with v.astquery and then reads the @[...] block
immediately above each one. It works on files that do not compile, which keeps
error messages about broken annotations useful.
C struct mirrors use voidptr and mut:. V's ownership rules would demand
initialisers for reference-typed fields, and a zero-initialised literal has to be
able to produce C's all-zero sentinels such as the PyMethodDef terminator.
Describing pointers as voidptr inside a mut: section sidesteps both.
CPython data symbols get C accessors. PyExc_TypeError and friends are
data, not functions, and V cannot name a C global. The runtime ships a small C
file of accessors with a header, pulled in with #flag @VMODROOT/c/....
The V garbage collector runs inside CPython. V uses Boehm–Demers–Weiser by
default. It is statically linked into the extension, it does not replace the C
allocator, so it never interferes with CPython's own memory management, and V
0.5.2 already calls GC_allow_register_threads() during initialisation, which is
precisely the case of a host-created thread entering V code.
Extension modules are process-scoped. PyInit_ is the single-phase
initialisation API, so a module loaded in one interpreter is shared by all of
them. Subinterpreter support would need the multi-phase API; the generated
glue is structured so that switch is a contained change.
The runtime API is stateless. vcraft/args.v is a set of plain functions and
the generated glue keeps the call state in its own locals, passing argument indices
explicitly. The obvious design, a reader object with a consumed cursor, cannot be
compiled at all: see constraint 5 above.
Nothing is installed system-wide. vcraft builds into the project's build/
and installs into the active virtualenv. The V runtime module ships with vcraft
and is passed to the compiler with -path, so VMODULES is never touched.
A module name ending in _test is rejected. V would treat the file as a test
file and silently drop its module export. vcraft new says so instead of producing
an empty shared object.
Compiler constraints
Building the first extension uncovered seven V compiler behaviours that produce a
wrong answer rather than an error. They shaped the runtime and are written up in full
in vlib/vcraft/README.md:
- The V C backend emits no prototypes for
fn C.declarations, so a module that binds to CPython must#include <Python.h>. Without it gcc applies the implicitintreturn rule, truncates the returnedPyObject *to 32 bits, and the interpreter segfaults on a module that loaded cleanly. - A sibling
.c.vfile only exports its declarations to the module named by its ownmoduleline. - CPython's builtin exception types are data symbols, so each one needs a small C accessor, with a header.
- A generic function called across modules gets no forward declaration and does not compile, so the panic guard is inlined per trampoline instead of shared.
- A
mutreceiver method on a struct from another module generates C that passes the struct by value where a pointer is expected. That rules out a reader object with a cursor, and is why the argument helpers take an explicit index. - A file matching
*_test.vis compiled as a V test file and its module export is silently dropped. - The object header is 16 bytes with the GIL and 32 without it, so every struct mirrored from a header has two shapes. The wrong one imports on a GIL interpreter and segfaults on a free-threaded one with nothing pointing at the mirror.
License
MIT. See LICENSE.
Copyright (c) 2026 Ricardo Robles Fernández
Metadata
Release files for vcraft 1.4.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vcraft-1.4.3-py3-none-win_amd64.whl | Python 3 | none | Windows x86-64 | Details |
| vcraft-1.4.3-py3-none-manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.28+ x86-64 | Details |
| vcraft-1.4.3-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
Total release size: 5.9 MB
Release files / vcraft-1.4.3-py3-none-win_amd64.whl
| Download URL | vcraft-1.4.3-py3-none-win_amd64.whl |
|---|---|
| Size | 1.3 MB |
| Tags | Python 3 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
a3af08ead9af666dd4e0e644418ff6fc6d493c8fdacaae35ad85a56022844d6f
|
|
BLAKE2b-256 checksum How to use checksums |
9324e9c1bd4c6dabb982bc25d83ef76cc8c7ebce81f2fd5da281c42f59c8bb56
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / vcraft-1.4.3-py3-none-manylinux_2_28_x86_64.whl
| Download URL | vcraft-1.4.3-py3-none-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 3.5 MB |
| Tags | Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
bdf7b19d5610f95fb000d0102df8d93b6ea8e123b6ca5b9f781802fa4332ca61
|
|
BLAKE2b-256 checksum How to use checksums |
4ae33dbd33d753a877fb43e099bf16e0f6cd1029c1d7d834d817d95842839bec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / vcraft-1.4.3-py3-none-macosx_11_0_arm64.whl
| Download URL | vcraft-1.4.3-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 1.1 MB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
0d17934ed337b8fef1fad28ba57524c7f450cc14c35db1f7319aa63316a3979e
|
|
BLAKE2b-256 checksum How to use checksums |
d92007a20caa14920a69ac79e52417dccc1d95ecabcdbb3e2e314a1672bc4309
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency log