Classes¶
PyOZ exposes Zig structs as Python classes automatically. Define a struct, register it with pyoz.class(), and PyOZ generates a full Python class with constructor, properties, and methods.
Defining a Class¶
const Point = struct {
x: f64,
y: f64,
};
pub const MyModule = pyoz.module(.{
.name = "mymodule",
.classes = &.{
pyoz.class("Point", Point),
},
});
This automatically creates:
- A constructor:
Point(x=1.0, y=2.0) - Read/write properties for
xandy - Automatic type conversion between Zig and Python
Private Fields¶
Fields starting with an underscore (_) are treated as private and are not exposed to Python:
const MyClass = struct {
// Public fields - exposed to Python
name: []const u8,
value: i64,
// Private fields - NOT exposed to Python
_internal_counter: i64,
_cache: ?SomeType,
};
Private fields:
- Are NOT exposed as Python properties (accessing
obj._internal_counterraisesAttributeError) - Are NOT included in
__init__arguments (onlynameandvalueabove) - Are NOT included in generated
.pyitype stubs - Are zero-initialized when the object is created
- Can still be accessed and modified by Zig methods
This is useful for:
- Internal implementation details that shouldn't be part of the public API
- Fields with types that can't be converted to Python (e.g., Zig-specific structs)
- Caches, buffers, or state that users shouldn't manipulate directly
const Counter = struct {
count: i64, // Public: users can read/write this
_step: i64, // Private: internal implementation detail
pub fn increment(self: *Counter) void {
self.count += self._step; // Methods can access private fields
}
};
Python usage:
c = Counter(0) # Only public field in __init__
c.count # OK: 0
c.increment() # OK: uses _step internally
c._step # AttributeError: private field not exposed
Constructors¶
By default, the constructor accepts all struct fields as arguments. For custom initialization, define __new__:
pub fn __new__(initial: i64, step: ?i64) Counter {
return .{ .count = initial, .step = step orelse 1 };
}
Optional parameters (?T) become keyword arguments with None as default.
__new__ supports error union and optional return types for validation:
// Error union — Zig errors become Python exceptions automatically
pub fn __new__(capacity: i64) !Ring {
if (capacity <= 0) return error.InvalidCapacity;
return .{ .capacity = capacity };
}
// Optional — raise a specific exception, then return null
pub fn __new__(capacity: i64) ?Ring {
if (capacity <= 0) return pyoz.raiseValueError("capacity must be positive");
return .{ .capacity = capacity };
}
Methods¶
PyOZ auto-detects method types based on the first parameter:
| First Parameter | Method Type | Python Usage |
|---|---|---|
*Self or *const Self |
Instance method | obj.method() |
comptime cls: type |
Class method | Class.method() |
| Anything else | Static method | Class.method() |
Use *const Self for methods that don't modify the instance.
Docstrings¶
Add documentation using special constants:
| Constant | Purpose |
|---|---|
pub const __doc__ |
Class docstring |
pub const fieldname__doc__ |
Field docstring |
pub const methodname__doc__ |
Method docstring |
All must be [*:0]const u8 type.
Magic Methods¶
PyOZ supports Python's special methods. Define them as regular Zig functions with matching names.
All magic methods support three return conventions: plain T (always succeeds), !T (error union — Zig errors automatically become Python exceptions), and ?T (optional — raise an exception with pyoz.raiseValueError() etc., then return null). See Error Handling in Magic Methods for examples.
Operators¶
| Category | Methods |
|---|---|
| Arithmetic | __add__, __sub__, __mul__, __truediv__, __floordiv__, __mod__, __pow__, __matmul__ |
| Unary | __neg__, __pos__, __abs__, __invert__ |
| Comparison | __eq__, __ne__, __lt__, __le__, __gt__, __ge__ |
| Bitwise | __and__, __or__, __xor__, __lshift__, __rshift__ |
| In-place | __iadd__, __isub__, __imul__, etc. |
| Reflected | __radd__, __rsub__, __rmul__, etc. |
Signature pattern: Binary operators take (self: *const T, other: *const T) and return T. In-place operators take (self: *T, other: *const T) and return void. Reflected operators take (self: *const T, other: *pyoz.PyObject) for handling Python scalars.
String Representation¶
| Method | Purpose |
|---|---|
__repr__ |
Developer representation (shown in REPL) |
__str__ |
User-friendly string (str(obj)) |
__hash__ |
Hash value for use in sets/dicts |
__hash__ and __eq__ interaction: Following Python semantics, if you define __eq__ without __hash__, PyOZ automatically makes the class unhashable — calling hash() raises TypeError, and instances cannot be used in sets or as dict keys. To make an equality-comparable class hashable, define both __eq__ and __hash__:
const Point = struct {
x: i64,
y: i64,
pub fn __eq__(self: *const Point, other: *const Point) bool {
return self.x == other.x and self.y == other.y;
}
pub fn __hash__(self: *const Point) i64 {
return self.x *% 31 +% self.y;
}
};
__hash__ supports plain i64, !i64 (error union), and ?i64 (optional — return null to raise TypeError).
Both __repr__ and __str__ support two signatures:
// Buffered (recommended) — PyOZ provides a 4096-byte buffer you can write into.
// The returned slice must point into `buf`. This avoids use-after-free when
// formatting with std.fmt.bufPrint.
pub fn __repr__(self: *const MyStruct, buf: []u8) []const u8 {
return std.fmt.bufPrint(buf, "MyStruct(x={d})", .{self.x}) catch "MyStruct(?)";
}
// Literal-only — safe ONLY when returning a string literal or other
// static/comptime-known data. Returning a slice into a local stack buffer
// is undefined behavior.
pub fn __repr__(self: *const MyStruct) []const u8 {
_ = self;
return "MyStruct(...)";
}
Type Conversion¶
| Method | Purpose |
|---|---|
__bool__ |
Boolean evaluation (if obj:) |
__int__ |
int(obj) |
__float__ |
float(obj) |
__index__ |
Used in slicing, range(), etc. |
__complex__ |
complex(obj) |
Callable Objects¶
Define __call__ to make instances callable:
Python: adder = Adder(10); adder(5) # 15
Protocols¶
Sequence Protocol¶
Make your class behave like a list:
| Method | Python Syntax |
|---|---|
__len__ |
len(obj) |
__getitem__(index: i64) !T |
obj[i] |
__setitem__(index: i64, value: T) !void |
obj[i] = value |
__delitem__(index: i64) !void |
del obj[i] |
__contains__(value: T) bool |
value in obj |
Return errors (e.g., error.IndexOutOfBounds) to raise Python exceptions. Negative indices are passed as-is; handle them in your implementation.
Iterator Protocol¶
| Method | Purpose |
|---|---|
__iter__(self: *T) *T |
Return iterator (usually self) |
__next__(self: *T) ?T |
Return next item or null for StopIteration |
__reversed__(self: *T) *T |
Return reversed iterator |
Store iteration state in instance fields.
Context Manager Protocol¶
| Method | Purpose |
|---|---|
__enter__(self: *T) *T |
Enter with block, return context |
__exit__(self: *T) bool |
Exit block; return true to suppress exceptions |
Descriptor Protocol¶
For custom attribute behavior on other classes:
| Method | Purpose |
|---|---|
__get__(self, obj: ?*PyObject) T |
Attribute access |
__set__(self, obj: ?*PyObject, value: T) |
Attribute assignment |
__delete__(self, obj: ?*PyObject) |
Attribute deletion |
Dynamic Attributes¶
| Method | Purpose |
|---|---|
__getattr__(name: []const u8) !T |
Called when attribute not found |
__setattr__(name: []const u8, value: *PyObject) |
Called for all attribute assignments |
__delattr__(name: []const u8) !void |
Called when deleting attribute |
Generic Type Syntax (__class_getitem__)¶
Enable MyClass[int] subscript syntax (PEP 560) by declaring a single constant:
Python:
This is useful for type annotations and generic patterns:
Works in ABI3 mode. On Python 3.8, falls back to returning the class itself.
Class Configuration¶
Frozen (Immutable) Classes¶
Prevents attribute modification. Frozen classes should implement __hash__ and __eq__.
Feature Flags¶
.dict = true- Enables dynamic attribute storage (obj.custom = "value").weakref = true- Enables weak references
Class Attributes¶
Prefix constants with classattr_ to make them class-level:
Python: Circle.PI # 3.14159
Freelist (Object Pooling)¶
For classes that are frequently created and destroyed, enable a freelist to reuse deallocated objects instead of going through the allocator:
const Token = struct {
kind: i64,
start: i64,
end: i64,
pub const __freelist__: usize = 32; // pool up to 32 objects
};
When an object is garbage-collected, it's pushed onto the freelist instead of being freed. The next Token(...) call reuses a pooled object, skipping allocation. Objects are fully re-initialized on reuse.
Only applies to simple types (no __dict__, no weakrefs). The freelist is a fixed-size static array — once full, excess objects are freed normally.
Inheritance from Built-in Types¶
Extend Python built-in types:
Use pyoz.object(self) to get the underlying Python object for calling Python API functions.
For dict subclasses, implement __missing__ to handle missing keys.
Inheritance Between PyOZ Classes¶
One PyOZ class can inherit from another using pyoz.base(Parent). The child struct declares __base__ and embeds the parent as _parent (must be the first field):
const Animal = struct {
name: []const u8,
age: i64,
pub fn speak(self: *const Animal) []const u8 {
_ = self;
return "...";
}
};
const Dog = struct {
pub const __base__ = pyoz.base(Animal);
_parent: Animal, // Must be first field, embeds parent data
breed: []const u8, // Child's own field
pub fn fetch(self: *const Dog) []const u8 {
_ = self;
return "fetching!";
}
};
pub const Module = pyoz.module(.{
.name = "animals",
.classes = &.{
pyoz.class("Animal", Animal), // Parent must come first
pyoz.class("Dog", Dog),
},
});
Python usage:
d = Dog("Rex", 3, "Labrador") # parent fields first, then child fields
d.name # "Rex" — inherited property
d.speak() # "..." — inherited method
d.breed # "Labrador" — own property
d.fetch() # "fetching!" — own method
isinstance(d, Animal) # True
Dog.__mro__ # [Dog, Animal, object]
Key rules:
- The parent class must be listed before the child in the
classesarray (comptime error otherwise) - The child's first field must be
_parent: ParentType(comptime validated) - Parent methods and properties are inherited via Python's MRO — no duplication needed
- The child's
__init__accepts a flattened argument list: parent public fields first, then child public fields isinstance()works correctly —Doginstances passisinstance(d, Animal)- Functions accepting
*const Animalwill also acceptDoginstances (viaPyObject_TypeCheck) - Stubs generate
class Dog(Animal):with the correct flattened constructor signature
GC Support¶
For classes holding Python object references, implement garbage collection hooks to allow Python's cyclic garbage collector to detect and break reference cycles:
| Method | Signature | Purpose |
|---|---|---|
__traverse__ |
fn(self: *T, visitor: pyoz.GCVisitor) c_int |
Report held references |
__clear__ |
fn(self: *T) void |
Release references to break cycles |
The GCVisitor is passed by value (not by pointer). Call visitor.call() for each ?*PyObject field and check its return value:
const Observer = struct {
name: [64]u8,
name_len: usize,
_callback: ?*pyoz.PyObject, // Held Python reference
_target: ?*pyoz.PyObject, // Another held reference
pub fn __traverse__(self: *Observer, visitor: pyoz.GCVisitor) c_int {
// Visit each Python object reference. Return immediately if non-zero.
var ret = visitor.call(self._callback);
if (ret != 0) return ret;
ret = visitor.call(self._target);
if (ret != 0) return ret;
return 0;
}
pub fn __clear__(self: *Observer) void {
// Release references to break cycles
if (self._callback) |cb| pyoz.py.Py_DecRef(cb);
self._callback = null;
if (self._target) |t| pyoz.py.Py_DecRef(t);
self._target = null;
}
};
Only implement GC hooks for classes that store ?*PyObject fields. Classes with only Zig-native fields (integers, floats, arrays, etc.) don't need GC support.
Custom Cleanup (__del__)¶
Define __del__ to run custom cleanup when Python garbage-collects your object. This is called during tp_dealloc, before the object is freed.
const Resource = struct {
handle: i64,
_freed: bool,
pub fn __new__(handle: i64) Resource {
return .{ .handle = handle, ._freed = false };
}
pub fn __del__(self: *Resource) void {
// Free C memory, close file handles, release resources, etc.
self._freed = true;
self.handle = -1;
}
pub fn is_valid(self: *const Resource) bool {
return self.handle >= 0 and !self._freed;
}
};
Python:
Signature: pub fn __del__(self: *Self) void
__del__ is called before weakref cleanup, __dict__ cleanup, and object deallocation, so all fields and state are still accessible. Works in both normal and ABI3 modes. Types that don't define __del__ have zero overhead — the check is resolved at compile time.
Use this for C interop structs that allocate memory, open file descriptors, or hold external resources that need explicit cleanup.
Method Chaining¶
Methods returning *Self or *const Self enable chaining. PyOZ automatically handles Python reference counting:
Python: b.add(1).add(2).add(3)
Cross-Class References¶
When a module defines multiple classes, methods on one class can accept or return instances of another class in the same module. PyOZ handles this automatically — no special syntax needed.
const Point = struct {
x: f64,
y: f64,
pub fn magnitude(self: *const Point) f64 {
return @sqrt(self.x * self.x + self.y * self.y);
}
};
const Line = struct {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
/// Returns a Point — cross-class return
pub fn start_point(self: *const Line) Point {
return .{ .x = self.x1, .y = self.y1 };
}
/// Accepts two Points — cross-class arguments
pub fn from_points(p1: *const Point, p2: *const Point) Line {
return .{ .x1 = p1.x, .y1 = p1.y, .x2 = p2.x, .y2 = p2.y };
}
};
pub const MyModule = pyoz.module(.{
.name = "geometry",
.classes = &.{
pyoz.class("Point", Point),
pyoz.class("Line", Line),
},
});
Python:
import geometry
p1 = geometry.Point(1.0, 2.0)
p2 = geometry.Point(4.0, 6.0)
line = geometry.Line.from_points(p1, p2) # accepts Point instances
start = line.start_point() # returns a Point instance
print(start.magnitude()) # full Point API works
Cyclic references work too — class A methods can accept/return class B and vice versa, as long as both are registered in the same module.
Manual Conversion with Module.toPy()¶
The examples above use direct Zig return types (Point, Line) which PyOZ converts automatically. When you need to build raw Python objects manually (e.g., a Python list of class instances), use the module-level converter:
/// Returns a Python list of Point objects
pub fn vertices(self: *const Polygon) ?*pyoz.PyObject {
const list = pyoz.py.PyList_New(0) orelse return null;
for (self.points) |pt| {
// Module.toPy knows about Point — wraps it as a Python Point object
const obj = Module.toPy(Point, pt) orelse {
pyoz.py.Py_DecRef(list);
return null;
};
_ = pyoz.py.PyList_Append(list, obj);
pyoz.py.Py_DecRef(obj);
}
return list;
}
Note:
pyoz.Conversions.toPy()does not know about registered classes and will returnnullfor class types. Always useModule.toPy()when converting class instances manually.
Strong Object References (Ref(T))¶
When one PyOZ class needs to hold a reference to another, use pyoz.Ref(T) to prevent use-after-free. Without Ref(T), if Python garbage-collects the referenced object while another object still points to it, the pointer becomes dangling.
Ref(T) wraps a ?*PyObject with automatic Py_IncRef on set() and Py_DecRef on clear() and object deallocation.
const Owner = struct {
value: i64,
};
const Child = struct {
_owner: pyoz.Ref(Owner), // Strong reference — keeps Owner alive
tag: i64,
pub fn get_owner_value(self: *const Child) ?i64 {
const owner = self._owner.get(MyModule.registered_classes) orelse return null;
return owner.value;
}
pub fn has_owner(self: *const Child) bool {
return self._owner.object() != null;
}
};
Setting a Ref¶
To set a Ref, you need the *PyObject of the target. Use Module.selfObject(T, ptr) to recover it from a *const T data pointer:
fn make_child(owner: *const Owner, tag: i64) Child {
var child = Child{ .tag = tag, ._owner = .{} };
child._owner.set(MyModule.selfObject(Owner, owner));
return child;
}
Ref API¶
| Method | Description |
|---|---|
ref.set(py_obj) |
Store reference (INCREFs new, DECREFs old) |
ref.get(class_infos) |
Get ?*const T to referenced data |
ref.getMut(class_infos) |
Get ?*T to referenced data |
ref.object() |
Get raw ?*PyObject (borrowed) |
ref.clear() |
Release reference (DECREF + set null) |
Automatic Behavior¶
- Excluded from Python:
Reffields are automatically excluded from Python properties,__init__parameters, stub generation, and auto-doc signatures — whether the field name starts with_or not - Deallocation: References are released in
tp_deallocbefore the object is freed - Freelist-safe: References are cleared before freelist push, and
std.mem.zeroeson pop prevents double-free
Stub Customization¶
PyOZ auto-generates .pyi type stubs from your Zig code. Most types are inferred automatically, but three opt-in conventions let you fine-tune the output:
Docstrings¶
Class and method docstrings are propagated to stubs:
const Node = struct {
pub const __doc__: [*:0]const u8 = "A node in the parse tree.";
pub const rule__doc__: [*:0]const u8 = "Return the grammar rule name.";
pub fn rule(self: *const Node) []const u8 { ... }
};
Generated stub:
class Node:
"""A node in the parse tree."""
def rule(self) -> str:
"""Return the grammar rule name."""
...
Return Type Override (Signature)¶
When a method returns ?T only to signal errors (not to return None), or returns ?*pyoz.PyObject for a complex type, the inferred stub annotation won't match the actual Python API. Use pyoz.Signature(T, "stub_string") as the return type to override it:
const Node = struct {
pub fn children(self: *const Node) pyoz.Signature(?*pyoz.PyObject, "list[Node]") {
const list = buildChildList(self) orelse {
_ = pyoz.raiseRuntimeError("failed to build children");
return .{ .value = null };
};
return .{ .value = list };
}
pub fn find(self: *const Node, name: []const u8) pyoz.Signature(?Node, "Node") {
// null signals an error, not a None return
return .{ .value = self.doFind(name) orelse {
_ = pyoz.raiseKeyError("not found");
return .{ .value = null };
}};
}
};
Generated stub:
Signature works the same way for instance methods, static methods, and class methods. See Return Type Override for full details.
Parameter Names (__params__)¶
Zig's @typeInfo does not expose function parameter names, so stubs and help() default to arg0, arg1, .... For .from module-level functions, pyoz.withSource() extracts real parameter names automatically. For class methods, use the __params__ convention to override:
const Node = struct {
pub const find__params__: []const u8 = "rule_name";
pub const child__params__: []const u8 = "index";
pub fn find(self: *const Node, name: []const u8) ?*pyoz.PyObject { ... }
pub fn child(self: *const Node, idx: i64) ?Node { ... }
};
Generated stub:
Names are comma-separated (excluding self). If fewer names are provided than parameters, remaining ones fall back to argN.
Next Steps¶
- Properties - Custom getters/setters
- Types - Type conversion reference
- Errors - Exception handling