Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/requirements-cffi.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Python packages for the GOPY_BACKEND=cffi job in workflows/ci.yml.
# pybindgen is deliberately absent: the cffi backend must not need it.
cffi
# used by the memory-leak checks on Windows, where the resource module is missing
psutil
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,49 @@ jobs:
- name: Upload-Coverage
if: matrix.platform == 'ubuntu-latest'
uses: codecov/codecov-action@v4

# Builds and tests the opt-in cffi backend (GOPY_BACKEND=cffi). Runs beside
# the main matrix, on one Go version, and stops at the first failure.
cffi:
name: cffi backend (${{ matrix.platform }}, Python ${{ matrix.python-version }})
strategy:
fail-fast: true
matrix:
platform: [ubuntu-latest, windows-latest, macos-15]
python-version: ['3.11', '3.12']
runs-on: ${{ matrix.platform }}
env:
GOPY_BACKEND: cffi
# print the python stack if the process crashes, e.g. at exit
PYTHONFAULTHANDLER: 1
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: .github/requirements-cffi.txt

- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.25.x
cache: true

- name: Install packages
run: |
python -m pip install -r .github/requirements-cffi.txt
go install golang.org/x/tools/cmd/goimports@v0.29.0

- name: Build
run: go build -v ./...

# The skipped tests use features the cffi backend does not support yet:
# Python callback arguments (TestBindFuncs), and complex numbers as a
# slice element rather than a plain function arg/return (TestBuiltinSlices;
# see genFuncComplexCFFI in gen_func.go for the arg/return case, which works).
- name: Test
run: go test -v -skip '^(TestBindFuncs|TestBuiltinSlices)$' ./...
65 changes: 65 additions & 0 deletions bind/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 The go-python Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package bind

import (
"fmt"
"os"
"strings"
)

// BackendEnvVar is the environment variable that selects which tool is used
// to bind the generated cgo shim to CPython.
const BackendEnvVar = "GOPY_BACKEND"

// Backend names a CPython binding tool.
type Backend string

const (
BackendPyBindGen Backend = "pybindgen" // default
BackendCFFI Backend = "cffi"
BackendPyBind11 Backend = "pybind11"
BackendNanobind Backend = "nanobind"
BackendCAPI Backend = "capi"
BackendCGO Backend = "cgo"
)

// backends lists every known backend and whether gopy can generate it yet.
var backends = []struct {
name Backend
implemented bool
}{
{BackendPyBindGen, true},
{BackendCFFI, true},
{BackendPyBind11, false},
{BackendNanobind, false},
{BackendCAPI, false},
{BackendCGO, false},
}

// BackendFromEnv returns the backend selected by GOPY_BACKEND.
// An unset or empty variable selects pybindgen.
func BackendFromEnv() (Backend, error) {
return parseBackend(os.Getenv(BackendEnvVar))
}

func parseBackend(v string) (Backend, error) {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" {
return BackendPyBindGen, nil
}
names := make([]string, len(backends))
for i, b := range backends {
names[i] = string(b.name)
if string(b.name) != v {
continue
}
if !b.implemented {
return "", fmt.Errorf("gopy: %s=%q is not implemented yet", BackendEnvVar, v)
}
return b.name, nil
}
return "", fmt.Errorf("gopy: unknown %s=%q (valid values: %s)", BackendEnvVar, v, strings.Join(names, ", "))
}
47 changes: 47 additions & 0 deletions bind/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 The go-python Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package bind

import (
"strings"
"testing"
)

func TestParseBackend(t *testing.T) {
for _, tc := range []struct {
in string
want Backend
errPart string
}{
{in: "", want: BackendPyBindGen},
{in: "pybindgen", want: BackendPyBindGen},
{in: " PyBindGen ", want: BackendPyBindGen},
{in: "cffi", want: BackendCFFI},
{in: "pybind11", errPart: "not implemented yet"},
{in: "bogus", errPart: "unknown GOPY_BACKEND"},
} {
got, err := parseBackend(tc.in)
if tc.errPart != "" {
if err == nil || !strings.Contains(err.Error(), tc.errPart) {
t.Errorf("parseBackend(%q): got err=%v, want error containing %q", tc.in, err, tc.errPart)
}
continue
}
if err != nil || got != tc.want {
t.Errorf("parseBackend(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
}
}
}

func TestBackendFromEnv(t *testing.T) {
t.Setenv(BackendEnvVar, "")
if got, err := BackendFromEnv(); err != nil || got != BackendPyBindGen {
t.Errorf("unset: got %q, %v", got, err)
}
t.Setenv(BackendEnvVar, "bogus")
if _, err := BackendFromEnv(); err == nil {
t.Error("bogus value: want error")
}
}
2 changes: 2 additions & 0 deletions bind/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type BindCfg struct {
// gopy version string embedded in this binary, stamped into generated
// file headers so output can be traced back to the release that produced it
Version string
// tool used to bind the cgo shim to CPython, see BackendFromEnv
Backend Backend
}

// ErrorList is a list of errors
Expand Down
171 changes: 171 additions & 0 deletions bind/cffi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2026 The go-python Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package bind

import (
_ "embed"
"strings"
)

// The cffi backend (GOPY_BACKEND=cffi) generates the same cgo shim as the
// default backend, minus every call into the CPython C API: the shim is a
// plain C shared library that Python loads with cffi. Errors are recorded
// for the Python side to raise, instead of being set with PyErr_SetString.

//go:embed cffi_build.py
var cffiBuildPy string

func (g *pyGen) isCFFI() bool {
return g.cfg.Backend == BackendCFFI
}

// cffiBuildPreamble returns the start of build.py: the cffi recorder.
func (g *pyGen) cffiBuildPreamble() string {
return strings.NewReplacer(
"@NAME@", g.cfg.Name,
"@CMD@", g.cfg.Cmd,
"@VERSION@", g.cfg.Version,
"@LIBEXT@", g.libext,
).Replace(cffiBuildPy)
}

// goSetError returns Go code that records an error for Python to raise.
// kind is the name of a Python builtin exception, msg a Go string expression.
func (g *pyGen) goSetError(kind, msg string) string {
return "gopySetError(\"" + kind + "\", " + msg + ")\n"
}

// same argument positions as goPreamble: 1 = name of package, 2 = cmdstr,
// 4 = GoHandle, 5 = CGoHandle, 6 = all imports, 7 = mainstr, 10 = gopy version.
const goPreambleCFFI = `/*
cgo stubs for package %[1]s, for use with cffi.
File is generated by gopy version %[10]s. Do not edit.
%[2]s
*/

package main

/*
#include <stdint.h>
#include <stdlib.h>
#if !defined(__STDC_VERSION__) || (__STDC_VERSION__ < 202311L)
typedef uint8_t bool;
#endif
*/
import "C"
import (
"runtime"
"sync"
"unsafe"
"github.com/go-python/gopy/gopyh" // handler
%[6]s
)

func main() {
%[7]s
}

//export GoPyInit
func GoPyInit() {
%[7]s
}

// type for the handle -- int64 for speed (can switch to string)
type GoHandle %[4]s
type CGoHandle %[5]s

// DecRef decrements the reference count for the specified handle
// and deletes it it goes to zero.
//export DecRef
func DecRef(handle CGoHandle) {
gopyh.DecRef(gopyh.CGoHandle(handle))
}

// IncRef increments the reference count for the specified handle.
//export IncRef
func IncRef(handle CGoHandle) {
gopyh.IncRef(gopyh.CGoHandle(handle))
}

// NumHandles returns the number of handles currently in use.
//export NumHandles
func NumHandles() int {
return gopyh.NumHandles()
}

// RequestGC runs Go's garbage collector on a dedicated goroutine, and waits.
//export RequestGC
func RequestGC() {
done := make(chan struct{})
_gcReq <- done
<-done
}

var _gcReq = make(chan chan struct{})

func init() {
go func() {
for done := range _gcReq {
runtime.GC()
close(done)
}
}()
}

// The error of the last call, for the python side to raise. There is one
// slot for the whole process, so calls from several python threads at once
// can be given each other's errors.
var (
gopyErrMu sync.Mutex
gopyErrMsg string
gopyHasErr bool
)

func gopySetError(kind, msg string) {
gopyErrMu.Lock()
gopyErrMsg, gopyHasErr = kind+":"+msg, true
gopyErrMu.Unlock()
}

// GopyTakeError returns "Kind:message" for the error recorded by the last
// call and clears it, or NULL if there is none. Free with GopyFreeString.
//export GopyTakeError
func GopyTakeError() *C.char {
gopyErrMu.Lock()
defer gopyErrMu.Unlock()
if !gopyHasErr {
return nil
}
gopyHasErr = false
return C.CString(gopyErrMsg)
}

// GopyFreeString frees a string returned by this library.
//export GopyFreeString
func GopyFreeString(s *C.char) {
C.free(unsafe.Pointer(s))
}

// boolGoToPy converts a Go bool to python-compatible C.char
func boolGoToPy(b bool) C.char {
if b {
return 1
}
return 0
}

// boolPyToGo converts a python-compatible C.Char to Go bool
func boolPyToGo(b C.char) bool {
return b != 0
}

// errorGoToPy converts a Go error to python-compatible C.CString
func errorGoToPy(e error) *C.char {
if e != nil {
return C.CString(e.Error())
}
return C.CString("")
}
`
Loading
Loading