python

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 18 Imported by: 0

README

go-python

CI Go Reference

CPython in pure Go — embed and run Python anywhere Go runs. No cgo, no native Python install, one static binary.

go-python runs CPython compiled to WebAssembly and transpiled to Go by goccy/pythonwasm2go. It gives Go programs a real CPython runtime with an embedded standard library, without cgo, without a native Python installation, and without shipping a wasm runtime at execution time.

The package is built for embedding Python safely. A host application can create isolated interpreters, decide exactly what each interpreter can see or touch, and run agent-generated Python with explicit controls over filesystem, environment, stdio, networking, subprocesses, memory, and cancellation.

Why this library

gpython is a pure-Go Python VM, but it is a partial reimplementation / port of Python 3.4 and does not include many CPython C-backed modules. That is useful for some embedding use cases, but it is not the same thing as running CPython.

go-python takes a different path: CPython is built to WebAssembly, then the wasm is transpiled into ordinary Go source. The result keeps the deployment advantages of a Go dependency while running the CPython runtime and a bundled CPython standard library.

This matters most when Python code is produced or selected by an AI agent. The host process can execute familiar Python while still enforcing a Go-side policy before the guest reaches host resources.

Features

  • Pure Go, no cgo, no wasm runtime. CPython is compiled to WebAssembly and then transpiled to Go by goccy/pythonwasm2go. Applications import a Go module and build with the Go toolchain.
  • Real CPython with an embedded standard library. stdlib.zip contains a trimmed CPython Lib/ tree and is embedded in the module. A zero-value Config automatically extracts it for local execution, while NewStdlibMemFS serves it from an in-memory filesystem for fully isolated embeddings.
  • Auto-generated end-to-end. The checked-in bridge (python.go) and embedded standard library (stdlib.zip) are pulled from goccy/python-wasm. Updating the upstream release refreshes the generated CPython binding without hand-writing the runtime surface.
  • End-to-end provenance. Upstream-sourced artifacts are protected by GitHub artifact attestations. make verify checks the signed release provenance for both python.go and stdlib.zip, and CI runs the same verification before tests.
  • Isolated multi-interpreter API. Each Interpreter owns its own wasm module, linear memory, WASI host, and CPython runtime. Interpreters can run concurrently with independent globals and filesystem state.
  • Agent-oriented sandbox controls. The host can control:
    • filesystem scope with PreopenDir;
    • private filesystem backends with FS, including in-memory MemFS;
    • per-path read/write decisions with FSAccess;
    • guest environment variables with Env (host os.Environ is not leaked);
    • guest stdin, stdout, and stderr with explicit io.Reader / io.Writer values;
    • hostname resolution with Resolve;
    • outbound connect destinations with Dial;
    • socket accept/recv/send operations with NetAccess;
    • host subprocess execution with Exec;
    • wasm linear-memory growth with MaxMemoryBytes;
    • long-running Python code with Interrupt / PrepareInterrupt.
  • Python command front-end. cmd/python and package cli provide a Python-like command entry point for python -c <command> and python <file.py> [args...].
  • WASI-friendly build. Because this is pure Go, embedding applications can also cross-compile to GOOS=wasip1 GOARCH=wasm. See docs/wasip1.md.

Status

The checked-in upstream artifacts currently track goccy/python-wasm v0.1.5 (vendored as goccy/pythonwasm2go v0.2.0), which builds CPython 3.14.6.

The main API supports expression / statement evaluation, persistent globals per interpreter, standard-library imports, stdout/stderr capture, isolated interpreter instances, in-memory filesystems, filesystem/network/subprocess policy hooks, memory caps, and host-triggered KeyboardInterrupt.

The command front-end currently supports -c and script-file execution. Interactive REPL mode and python -m are not implemented yet.

Installation

go get github.com/goccy/go-python

go-python requires Go 1.25 or newer.

Synopsis

Evaluate Python
package main

import (
	"fmt"

	python "github.com/goccy/go-python"
)

func main() {
	interp, err := python.NewInterpreter(python.Config{})
	if err != nil {
		panic(err)
	}
	defer interp.Close()

	res, err := interp.Eval(`
import json, math
print(json.dumps({"sqrt2": round(math.sqrt(2), 6)}))
`)
	if err != nil {
		panic(err)
	}
	if !res.Ok {
		panic(res.Error)
	}
	fmt.Print(res.Stdout)
}
Run agent-generated code in a tighter sandbox

The zero-value Config is convenient for local execution, but it is not a deny-all sandbox: the host filesystem is visible unless scoped, and networking or subprocess execution must be denied with hooks when running untrusted code. For agent-generated code, pass an explicit policy.

fsys, err := python.NewStdlibMemFS()
if err != nil {
	panic(err)
}

interp, err := python.NewInterpreter(python.Config{
	FS:             fsys, // private in-memory filesystem; StdlibDir defaults to "/"
	Env:            []string{"APP_MODE=agent"},
	MaxMemoryBytes: 64 << 20,
	NetAccess:      func(op string) bool { return false },
	Resolve:        func(host string) bool { return false },
	Dial:           func(network, ip string, port int) bool { return false },
	Exec:           func(path string, argv []string) bool { return false },
})
if err != nil {
	panic(err)
}
defer interp.Close()

res, err := interp.Eval("sum(range(101))")
if err != nil {
	panic(err)
}
if !res.Ok {
	panic(res.Error)
}
fmt.Println(res.Repr) // 5050
Interrupt a long-running evaluation
interrupt, err := interp.PrepareInterrupt()
if err != nil {
	panic(err)
}

done := make(chan python.EvalResult, 1)
go func() {
	res, _ := interp.Eval("while True:\n    pass")
	done <- res
}()

time.AfterFunc(time.Second, interrupt.Fire)
res := <-done // res.Ok == false, res.Error contains KeyboardInterrupt
Use the command front-end
go run ./cmd/python -c 'import sys; print(sys.version)'
go run ./cmd/python ./script.py arg1 arg2

Verifying provenance

The generated bridge and embedded standard library are release artifacts from goccy/python-wasm. You can verify that the local files came from the trusted upstream release workflow without a GitHub access token:

make verify

The target:

  1. computes the SHA-256 digest of python.go and stdlib.zip;
  2. fetches the public attestation bundle for each digest from the GitHub attestations API;
  3. runs gh attestation verify --bundle with --signer-workflow goccy/python-wasm/.github/workflows/release.yml.

CI runs make verify before go test ./..., so pull requests must preserve the signed upstream artifacts.

Resource footprint

The runtime cost is different from a small hand-written interpreter. This package embeds a CPython standard library archive and depends on a wasm2go-transpiled CPython engine, so cold builds and interpreter startup are heavier than a typical Go dependency. In exchange, applications get a cgo-free, cross-compilable CPython runtime whose host access is mediated through Go.

For local measurements, run:

go test ./...
cd bench
go test -bench . -benchmem ./...
go test -run TestMemoryFootprint -v ./...

The bench/ module runs identical Python source on go-python (real CPython 3.14.6) and on gpython (a pure-Go Python 3.4 VM), so the two are measured on equal footing (benchstat sec/op, darwin/amd64, n=10):

workload go-python gpython
fib(28) (recursive calls) 122.8 ms 217.8 ms
sum(range(200000)) (loop) 54.1 ms 17.1 ms
interpreter startup 13.2 ms 16 µs

The two make different trade-offs: go-python runs a real CPython engine, so it is faster on call-heavy code and keeps per-call allocation tiny (≈0.5 KB/op vs gpython's hundreds of MB for fib), while gpython's lightweight VM starts up and runs tight loops faster.

License

  • The Go source code of this repository is licensed under MIT. That covers everything written or generated here — interpreter.go, the generated bridge python.go, the CLI, the tests and the benchmarks.
  • stdlib.zip is not MIT: it is a repackaged subset of the CPython 3.14.6 standard library — a derivative work of CPython — and keeps CPython's own license, the Python Software Foundation License Agreement (LICENSE-PSF), vendored verbatim. CPython is Copyright (c) 2001-present Python Software Foundation; All Rights Reserved.
  • The pythonwasm2go dependency (the transpiled interpreter) is likewise distributed under CPython's license in its own repository.
Using go-python in your own project
  • As a library dependency (source distribution): your repository contains no CPython-derived bytes — only an import path and a go.mod entry. License your own code however you like (MIT, proprietary, ...); no CPython license text needs to accompany it. Your users receive go-python and pythonwasm2go from their own origins, under their own licenses.
  • Shipping a compiled binary: the binary embeds the transpiled interpreter and stdlib.zip. The PSF License Agreement is permissive and expressly allows this — it grants the right to "reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python ... in any derivative version" (§2), including in commercial and closed-source products, with no copyleft. Your own code keeps its own license and does not inherit CPython's. The only condition is attribution: retain CPython's copyright notice and PSF's license — shipping LICENSE-PSF (or a third-party-notices entry pointing at it) alongside your binary satisfies it. So redistribution, including inside a proprietary product, is fine.
  • Python code you run on the interpreter, and its output, remain yours.

This summary is not legal advice; the license texts govern.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ExtractStdlib

func ExtractStdlib() (string, error)

ExtractStdlib unpacks the embedded standard library into a temporary directory (once per process) and returns its path. The result is cached, so repeated calls — e.g. one per Instance — are cheap. The returned directory is the value to pass as Config.StdlibDir.

func Init

func Init() error

Init initializes the global module. Must be called before any API use. Safe to call multiple times (uses sync.Once).

func PyAsyncExcAddr

func PyAsyncExcAddr(h uint64) (uint32, error)

func PyClose

func PyClose(h uint64) error

Destroy the interpreter and finalize the runtime.

func PyEval

func PyEval(h uint64, src string) (string, error)

Evaluate `src` and return the result as a JSON object string:

{"ok":

<bool >,"repr": <string >,"stdout": <string >,"stderr": <string >,

"error":

<string >}

If `src` parses as a single expression, "repr" holds repr() of its value (empty for statements / None). "stdout"/"stderr" hold anything written to sys.stdout/sys.stderr during execution. On an uncaught exception, "ok" is false and "error" holds the formatted traceback. Globals persist across calls on the same handle (REPL-like).

A single JSON string return is used because the bridge generator surfaces only one response value to Go; bundling the outputs keeps one round-trip and one atomic result. The Go wrapper unmarshals it.

func PyEvalBreakerAddr

func PyEvalBreakerAddr(h uint64) (uint32, error)

---- Interruption support ------------------------------------------------

Mirrors PyThreadState_SetAsyncExc() done as plain memory writes, so a host watchdog goroutine can raise KeyboardInterrupt in a running interpreter WITHOUT executing any wasm/C code on that instance (which would corrupt the shared linear-memory C stack). To interrupt, the host performs:

*(uint32_t *)py_async_exc_addr(h)   = py_keyboard_interrupt_obj(h);
atomic_or((uint32_t *)py_eval_breaker_addr(h), 8); // _PY_ASYNC_EXCEPTION_BIT

CPython checks eval_breaker on every bytecode backward edge (3.9+), so it raises KeyboardInterrupt at the next loop iteration — including a pure `while True: pass`. PyExc_KeyboardInterrupt is immortal in 3.14, so storing it needs no refcount bookkeeping. Addresses are 32-bit linear-memory offsets (wasm32). The async-exception bit is the constant 8 (1u < < 3).

func PyKeyboardInterruptObj

func PyKeyboardInterruptObj(h uint64) (uint32, error)

func PyNew

func PyNew(stdlibDir string) (uint64, error)

Initialize the CPython runtime (isolated config) and return an opaque interpreter handle (0 on failure). Call once per wasm instance.

`stdlib_dir` is the directory holding the Python standard library (the `Lib/` tree: encodings/, io.py, codecs.py, ...). It becomes the sole module search path. The isolated config ignores PYTHONPATH, so the host MUST pass this (the dir is reached through the runtime's WASI filesystem mount). Pass NULL/empty to fall back to CPython's default path discovery (usually fails in the sandbox — provide the path).

Types

type CallbackHandler

type CallbackHandler interface {
	HandleCallback(methodID int32, req []byte) ([]byte, error)
}

CallbackHandler is implemented by Go types that need to be called from C++. The type is always defined (the Module struct references it); the registration/dispatch machinery is only emitted when the wasm imports wasmify.callback_invoke.

type Config

type Config struct {
	// StdlibDir is the directory holding the Python standard library (the
	// Lib/ tree). It becomes the sole module search path. Required for any
	// non-trivial import (encodings is needed even for startup).
	StdlibDir string
	// PreopenDir scopes the guest filesystem root ("/") to this host
	// directory. Empty leaves the host "/" visible (no scoping).
	PreopenDir string
	// Env is the environment the guest sees. nil means an empty
	// environment — the host process os.Environ() is NOT leaked.
	Env []string
	// FSAccess, when non-nil, is a per-open/create/unlink whitelist. It
	// receives the guest path (relative to the preopen) and whether the
	// access is a write; returning false denies it (the guest sees a
	// PermissionError / OSError EACCES).
	FSAccess func(path string, write bool) bool
	// NetAccess, when non-nil, gates the socket accept/recv/send surface.
	// op is "accept"/"recv"/"send"; returning false denies it.
	NetAccess func(op string) bool
	// Dial, when non-nil, is the OUTBOUND-connection whitelist. It is called
	// with ("tcp", dotted-quad IP, port) before each connect; returning false
	// denies the connection (the guest sees a connect error). When nil, all
	// outbound connections are allowed.
	Dial func(network, ip string, port int) bool
	// Resolve, when non-nil, is the name-resolution whitelist. It is called
	// with the host being resolved before each lookup; returning false denies
	// it (the guest sees a name-resolution error). This is where a hostname
	// policy such as "block example.com" is enforced. When nil, all lookups
	// are allowed.
	Resolve func(host string) bool
	// Stdin, when non-nil, backs the guest's fd 0 (sys.stdin / input()).
	// Defaults to an empty stream (the host process stdin is NOT used).
	Stdin io.Reader
	// Stdout, when non-nil, receives the guest's fd 1 writes (os-level
	// stdout, e.g. os.write(1, ...)). Note: Python print() output is also
	// captured into EvalResult.Stdout by the bridge; Stdout here is the
	// raw fd sink, used for streaming and by the CLI front-end.
	Stdout io.Writer
	// Stderr, when non-nil, receives the guest's fd 2 writes.
	Stderr io.Writer
	// MaxMemoryBytes, when > 0, caps this interpreter's wasm linear memory.
	// A guest allocation that would grow memory past this limit fails
	// (memory.grow returns -1 -> the C allocator sees ENOMEM -> Python raises
	// MemoryError) instead of growing the host process unbounded. Rounded
	// down to a multiple of the 64 KiB wasm page size; values below the
	// module's initial memory are ignored.
	MaxMemoryBytes int
	// MemoryReserveBytes, when > 0, is the initial linear-memory slice
	// capacity reserved for this interpreter. CPython's wasm linear memory
	// grows during boot; if the initial slice has no spare capacity the first
	// grow reallocates and copies the whole linear memory, which makes the
	// (otherwise untouched, zero) C-stack region resident and inflates RSS.
	// Reserving capacity up front makes those grows zero-copy reslices, so a
	// freshly-booted interpreter's resident memory drops dramatically. The
	// reservation is virtual address space, not resident memory — untouched
	// pages stay non-resident — so a generous value is cheap. It is clamped up
	// to the module's minimum memory size. When 0, a default headroom is used
	// (NewWithWASI), which already covers a normal boot.
	MemoryReserveBytes int
	// Exec, when non-nil, is the subprocess whitelist. It is called before
	// every process spawn (subprocess.run/Popen via posix_spawn) with the
	// executable path and full argv; returning false denies the spawn (the
	// guest sees a PermissionError). Spawning runs a HOST binary, so a sandbox
	// that allows subprocess should set this to a strict allow-list. When nil,
	// all spawns are permitted (subject to host-subprocess being built in).
	Exec func(path string, argv []string) bool
	// FS, when non-nil, is the filesystem backend this interpreter sees as its
	// entire guest filesystem ("/"). Every file operation (open/read/write/
	// stat/mkdir/readdir/...) is routed to it, so giving two interpreters
	// separate FS values isolates them completely — one cannot see another's
	// files. Use NewStdlibMemFS() for an in-memory FS pre-loaded with the
	// standard library. When FS is set, PreopenDir is ignored and StdlibDir
	// defaults to "/" (the FS root). When nil, the default os-backed filesystem
	// (optionally scoped by PreopenDir) is used.
	FS FS
}

Config configures a new Interpreter's sandbox. The zero value is a usable (but unsandboxed-filesystem) interpreter: no host env is leaked, but the preopen defaults to the host "/" until PreopenDir is set.

type EvalResult

type EvalResult struct {
	Ok     bool   `json:"ok"`
	Repr   string `json:"repr"`
	Stdout string `json:"stdout"`
	Stderr string `json:"stderr"`
	Error  string `json:"error"`
}

EvalResult is the decoded form of the JSON document py_eval returns.

type FS

type FS = base.FS

FS is the read/write filesystem backend an Interpreter is given via Config.FS. It is a write-capable superset of io/fs.FS.

type File

type File = base.File

File is an open file returned by FS.OpenFile.

type Interpreter

type Interpreter struct {
	// contains filtered or unexported fields
}

Interpreter is one isolated CPython interpreter.

func NewInterpreter

func NewInterpreter(cfg Config) (inst *Interpreter, err error)

NewInterpreter builds a fresh wasm instance, applies the sandbox config, initializes the CPython runtime, and returns a ready interpreter.

func (*Interpreter) Close

func (i *Interpreter) Close() error

Close finalizes the interpreter. The Interpreter must not be used afterward.

func (*Interpreter) Eval

func (i *Interpreter) Eval(src string) (EvalResult, error)

Eval compiles and runs src in this interpreter's persistent globals and returns the structured result. A Python-level exception is reported via EvalResult.Ok=false / .Error, not as a Go error; a Go error indicates a host/transport failure (a wasm trap, encoding problem, ...).

func (*Interpreter) Interrupt

func (i *Interpreter) Interrupt() error

Interrupt raises KeyboardInterrupt in a running evaluation WITHOUT executing any wasm/C code on this instance. It writes the async-exception object and sets the eval-breaker bit directly in linear memory, mirroring PyThreadState_SetAsyncExc. Safe to call from another goroutine while Eval is running a long/infinite loop — CPython checks the eval-breaker on every bytecode back-edge and raises at the next iteration.

The three addresses are fetched here via normal calls, so callers that want to interrupt a loop must either call Interrupt from a separate goroutine (this call cannot acquire the per-instance lock while Eval holds it — see PrepareInterrupt) or pre-fetch them with PrepareInterrupt before starting the loop.

func (*Interpreter) PrepareInterrupt

func (i *Interpreter) PrepareInterrupt() (*Interrupter, error)

PrepareInterrupt resolves the interrupt addresses up front and STAGES the async-exception object: the KeyboardInterrupt class pointer is written into the thread's async-exc slot here, before the loop starts. From then on the value is linear-memory CONTENT — every memory.grow (including ones that relocate the backing array) copies it along — so Fire only has to flip the eval-breaker bit, and the object is globally visible long before the bit can be (no cross-CPU store-ordering concern on arm64).

type Interrupter

type Interrupter struct {
	// contains filtered or unexported fields
}

Interrupter holds the pre-resolved state needed to raise KeyboardInterrupt. Resolve it with PrepareInterrupt BEFORE starting the loop you intend to interrupt (resolving needs the instance lock, which a running Eval holds), then call Fire from a watchdog goroutine.

func (*Interrupter) Fire

func (ip *Interrupter) Fire()

Fire raises the interrupt: it sets _PY_ASYNC_EXCEPTION_BIT in the eval-breaker word (the exception object itself was already staged by PrepareInterrupt). It does not take the instance lock — that is the point: it runs concurrently with a busy Eval.

The write happens inside base.AccessMemory, which holds the same lock the runtime's memory.grow takes to mutate the memory slice header or relocate its backing array. That makes delivery deterministic: for the duration of the write the memory can neither be resliced nor relocated, so the bit lands in the array the guest observes — no snapshots, no retries, no polling. The only wait is bounded by an in-flight grow.

The breaker word itself is read-modify-written by the guest on its own goroutine with plain single-word accesses — that unsynchronised word is the eval-breaker protocol CPython defines, and the guest re-asserts its own bits on every pass, so the |= below is exactly as strong as CPython's native cross-thread signalling.

type MemFS

type MemFS = base.MemFS

MemFS is an in-memory read/write FS. Separate MemFS values are fully isolated from one another.

func NewMemFS

func NewMemFS() *MemFS

NewMemFS returns an empty in-memory filesystem.

func NewStdlibMemFS

func NewStdlibMemFS() (*MemFS, error)

NewStdlibMemFS returns an in-memory filesystem pre-loaded with the embedded Python standard library at the root, ready to back an Interpreter:

fs := python.NewStdlibMemFS()
interp, _ := python.NewInterpreter(python.Config{FS: fs}) // StdlibDir = "/"

Each call returns an independent FS, so interpreters built from separate NewStdlibMemFS() values share no filesystem state.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module is the bridge handle. It wraps the wasm2go-transpiled module (*base.Module) and serialises every entry on m.mu.

Reentrant callbacks — a host-import callback whose handler needs to make further calls back into the transpiled module — are supported by releasing m.mu around the user handler in handleCallback.

Safety rests on a single structural property: every path that touches transpiled module state goes through m.invoke, and m.invoke holds m.mu for the entire duration of its call. The release window in handleCallback simply lets another m.invoke grab the mutex and run as a fully nested top-level call — same goroutine via the user handler, different goroutine via an unrelated caller, it doesn't matter. Either way the nested call enters, balances its own state changes, and exits before the outer call resumes, so the outer call never observes a mid-flight inner call.

Directories

Path Synopsis
Package cli implements a python-compatible command front-end on top of the go-python interpreter.
Package cli implements a python-compatible command front-end on top of the go-python interpreter.
cmd
python command
Command python is a thin wrapper around the cli package: a standalone `python` executable backed by the go-python (CPython-via-wasm2go) interpreter.
Command python is a thin wrapper around the cli package: a standalone `python` executable backed by the go-python (CPython-via-wasm2go) interpreter.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL