Skip to content

Commit 36c319c

Browse files
committed
Support Python callbacks in cffi backend
1 parent 93188fb commit 36c319c

10 files changed

Lines changed: 421 additions & 13 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ jobs:
137137
run: go build -v ./...
138138

139139
# The skipped tests use features the cffi backend does not support yet:
140-
# Python callback arguments (TestBindFuncs), and complex numbers as a
141-
# slice element rather than a plain function arg/return (TestBuiltinSlices;
142-
# see genFuncComplexCFFI in gen_func.go for the arg/return case, which works).
140+
# Python callbacks that take an interface{} or return a value
141+
# (TestBindFuncs; see cffi_callback.go for the ones that work), and complex
142+
# numbers as a slice element rather than a plain function arg/return
143+
# (TestBuiltinSlices; see genFuncComplexCFFI in gen_func.go for the case that works).
143144
- name: Test
144145
run: go test -v -skip '^(TestBindFuncs|TestBuiltinSlices)$' ./...

‎SUPPORT_MATRIX.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ don't modify manually.
66
Feature |py3
77
--- | ---
88
_examples/arrays | yes
9+
_examples/callbacks | yes
910
_examples/cgo | yes
1011
_examples/consts | yes
1112
_examples/cstrings | yes

‎_examples/callbacks/callbacks.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2026 The go-python Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package callbacks has Go functions that take Python callables, and call
6+
// them before returning.
7+
package callbacks
8+
9+
import (
10+
"fmt"
11+
"sync"
12+
)
13+
14+
// Each calls fun for i in 0..n-1, with a label made from i.
15+
func Each(n int, fun func(i int, label string)) {
16+
for i := 0; i < n; i++ {
17+
fun(i, fmt.Sprintf("item-%d", i))
18+
}
19+
}
20+
21+
// Mixed calls fun with a bool, a float and an unsigned integer.
22+
func Mixed(fun func(on bool, x float64, u uint8)) {
23+
fun(true, 1.5, 200)
24+
fun(false, -2.25, 7)
25+
}
26+
27+
// Twice calls fun, which takes no arguments, two times.
28+
func Twice(fun func()) {
29+
fun()
30+
fun()
31+
}
32+
33+
// Counter counts how many times it has been visited.
34+
type Counter struct {
35+
N int
36+
}
37+
38+
// Visit calls fun with the counter itself, which arrives as a handle.
39+
func (c *Counter) Visit(times int, fun func(c *Counter, n int)) {
40+
for i := 0; i < times; i++ {
41+
c.N++
42+
fun(c, c.N)
43+
}
44+
}
45+
46+
// InGoroutine calls fun from another goroutine, and waits for it.
47+
func InGoroutine(fun func(i int)) {
48+
var wg sync.WaitGroup
49+
wg.Add(1)
50+
go func() {
51+
defer wg.Done()
52+
fun(7)
53+
}()
54+
wg.Wait()
55+
}

‎_examples/callbacks/test.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright 2026 The go-python Authors. All rights reserved.
2+
# Use of this source code is governed by a BSD-style
3+
# license that can be found in the LICENSE file.
4+
5+
from __future__ import print_function
6+
7+
import io
8+
import sys
9+
10+
import callbacks
11+
12+
print("--- Each: int and string arguments")
13+
callbacks.Each(3, lambda i, label: print("each:", i, label))
14+
15+
print("--- Mixed: bool, float and uint8 arguments")
16+
# bool() because the pybindgen backend passes a bool as 1 or 0
17+
callbacks.Mixed(lambda on, x, u: print("mixed:", bool(on), x, u))
18+
19+
print("--- Twice: no arguments")
20+
calls = []
21+
callbacks.Twice(lambda: calls.append(1))
22+
print("twice:", len(calls))
23+
24+
print("--- Counter.Visit: a Go struct arrives as a handle")
25+
c = callbacks.Counter()
26+
27+
def visit(handle, n):
28+
seen = callbacks.Counter(handle=handle)
29+
print("visit:", n, seen.N)
30+
31+
c.Visit(2, visit)
32+
print("counter:", c.N)
33+
34+
print("--- a bound method")
35+
36+
class Box(object):
37+
def __init__(self):
38+
self.items = []
39+
40+
def add(self, i, label):
41+
self.items.append((i, label))
42+
43+
box = Box()
44+
callbacks.Each(2, box.add)
45+
print("box:", box.items)
46+
47+
print("--- called from another goroutine")
48+
callbacks.InGoroutine(lambda i: print("goroutine:", i))
49+
50+
print("--- an exception in a callback is reported, and Go carries on")
51+
seen = []
52+
53+
def boom(i, label):
54+
seen.append(i)
55+
raise ValueError("boom %d" % i)
56+
57+
stderr, sys.stderr = sys.stderr, io.StringIO()
58+
try:
59+
callbacks.Each(3, boom)
60+
reported = sys.stderr.getvalue()
61+
finally:
62+
sys.stderr = stderr
63+
print("calls:", len(seen), "reported:", reported.count("ValueError: boom"))
64+
65+
print("OK")

‎bind/cffi.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ func (g *pyGen) goSetError(kind, msg string) string {
3838
}
3939

4040
// same argument positions as goPreamble: 1 = name of package, 2 = cmdstr,
41-
// 4 = GoHandle, 5 = CGoHandle, 6 = all imports, 7 = mainstr, 10 = gopy version.
41+
// 4 = GoHandle, 5 = CGoHandle, 6 = all imports, 7 = mainstr, 8 = C trampolines for
42+
// callbacks (see cffi_callback.go), 10 = gopy version.
4243
const goPreambleCFFI = `/*
4344
cgo stubs for package %[1]s, for use with cffi.
4445
File is generated by gopy version %[10]s. Do not edit.
@@ -53,6 +54,7 @@ package main
5354
#if !defined(__STDC_VERSION__) || (__STDC_VERSION__ < 202311L)
5455
typedef uint8_t bool;
5556
#endif
57+
%[8]s
5658
*/
5759
import "C"
5860
import (
@@ -148,6 +150,32 @@ func GopyFreeString(s *C.char) {
148150
C.free(unsafe.Pointer(s))
149151
}
150152
153+
// gopyCallbackScope guards a python callback passed to Go, which only exists
154+
// while the python call it was passed to is running (see cffi_callback.go):
155+
// close waits for callbacks that are running, and then refuses new ones.
156+
type gopyCallbackScope struct {
157+
mu sync.RWMutex
158+
done bool
159+
}
160+
161+
func (s *gopyCallbackScope) enter() bool {
162+
s.mu.RLock()
163+
if s.done {
164+
s.mu.RUnlock()
165+
println("gopy: callback called after the python call it was passed to returned")
166+
return false
167+
}
168+
return true
169+
}
170+
171+
func (s *gopyCallbackScope) leave() { s.mu.RUnlock() }
172+
173+
func (s *gopyCallbackScope) close() {
174+
s.mu.Lock()
175+
s.done = true
176+
s.mu.Unlock()
177+
}
178+
151179
// boolGoToPy converts a Go bool to python-compatible C.char
152180
func boolGoToPy(b bool) C.char {
153181
if b {

‎bind/cffi_build.py‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,12 @@ def wrapper(name, ret, params, exported):
130130
" raise NotImplementedError('%s is not available with the cffi backend')\n" % (name, name)
131131
)
132132
args = []
133+
setup = []
133134
for i, (ctype, pname) in enumerate(params):
134-
if ctype == "char*":
135+
if ctype.startswith("callback:"):
136+
setup.append(callback_setup(pname, ctype, i + 1))
137+
args.append("_ffi.cast('void*', _cb_%s)" % pname)
138+
elif ctype == "char*":
135139
args.append("_enc(%s, %d)" % (pname, i + 1))
136140
elif ctype == "bool":
137141
args.append("(1 if %s else 0)" % pname)
@@ -141,7 +145,7 @@ def wrapper(name, ret, params, exported):
141145
args.append("_index(%s)" % pname)
142146
else:
143147
args.append(pname)
144-
body = ["def %s(%s):" % (name, sig), " _r = _lib.%s(%s)" % (name, ", ".join(args))]
148+
body = ["def %s(%s):" % (name, sig)] + setup + [" _r = _lib.%s(%s)" % (name, ", ".join(args))]
145149
if ret == "char*":
146150
body.append(" _r = _dec(_r)")
147151
elif ret == "bool":
@@ -154,6 +158,34 @@ def wrapper(name, ret, params, exported):
154158
return "\n".join(body) + "\n"
155159

156160

161+
def callback_setup(pname, ctype, argn):
162+
"""Returns the python source that wraps the callable pname in an
163+
ffi.callback, for the C signature in ctype ("callback:void(int64_t,char*)",
164+
see cffi_callback.go). The wrapper passes it on to Go as _cb_<pname>,
165+
which is kept referenced by this local variable until the Go call
166+
returns: cffi frees a callback as soon as nothing refers to it.
167+
"""
168+
cargs = ctype[len("callback:void("):-1]
169+
ctypes = cargs.split(",") if cargs else []
170+
names = ["a%d" % i for i in range(len(ctypes))]
171+
conv = []
172+
for n, t in zip(names, ctypes):
173+
if t == "char*":
174+
conv.append('_ffi.string(%s).decode("utf-8")' % n)
175+
elif t == "bool":
176+
conv.append("bool(%s)" % n)
177+
else:
178+
conv.append(n)
179+
cdecl = "void(%s)" % ", ".join("unsigned char" if t == "bool" else t for t in ctypes)
180+
return "\n".join([
181+
" if not callable(%s):" % pname,
182+
" raise TypeError('argument %d must be callable, not %%s' %% type(%s).__name__)" % (argn, pname),
183+
" def _cbfn_%s(%s):" % (pname, ", ".join(names)),
184+
" %s(%s)" % (pname, ", ".join(conv)),
185+
" _cb_%s = _ffi.callback(%r, _cbfn_%s)" % (pname, cdecl, pname),
186+
])
187+
188+
157189
def complex_wrapper(name, nargs, exported):
158190
"""A plain function whose every argument and return value is
159191
complex64/128 (see genFuncComplexCFFI, gen_func.go): each argument

0 commit comments

Comments
 (0)