pyvenv

package module
v1.0.3 Latest Latest
Warning

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

Go to latest
Published: May 28, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

pyvenv

Run a Python virtual environment as an in-process worker for a Go program.

pyvenv spawns one long-lived Python subprocess per Venv, talks to it over length-prefixed JSON frames on stdin/stdout, and exposes typed Call/Await into named Python functions. The Go side stays in charge of all concerns (logging, tracing, retries, downstream calls); Python is a pure compute kernel. Call returns immediately with a callID; Await(ctx, callID) blocks until the call completes. A ctx-expired Await does not consume the in-flight call — a later Await on the same callID re-enters the wait, so Python work can outlive any single caller's ctx (e.g. a workflow task's per-step time budget).

The package is framework-agnostic: its only dependency is the Go standard library. The intended consumer is any Go program that wants to call into Python libraries (PyTorch, pandas, scikit-learn, sentence-transformers, etc.) without reimplementing subprocess management.

Install

go get github.com/microbus-io/pyvenv

Requires python3 (and the standard library venv module) on the host's PATH at runtime, or an explicit PythonInterpreter config value.

Usage

package main

import (
    "context"
    "embed"
    "fmt"

    "github.com/microbus-io/pyvenv"
)

//go:embed *.py
var pyFiles embed.FS

func main() {
    ctx := context.Background()

    helpers, _ := pyFiles.ReadFile("helpers.py")
    service, _ := pyFiles.ReadFile("service.py")

    v, err := pyvenv.New(pyvenv.Config{
        Sources:      []string{string(helpers), string(service)},
        Requirements: []string{"sentence-transformers"},
        MaxWorkers:   2,
    })
    if err != nil {
        panic(err)
    }
    defer v.Close(ctx)

    if err := v.Start(ctx); err != nil {
        panic(err)
    }

    var out struct {
        Vector []float64 `json:"vector"`
    }
    // Synchronous convenience: Call + Await on the same goroutine.
    err = v.CallAndAwait(ctx, "embed", map[string]any{"text": "hello world"}, &out)
    if err != nil {
        panic(err)
    }
    fmt.Println(out.Vector)

    // Or split for callers that need to outlive their ctx (e.g. workflow tasks):
    callID, _ := v.Call(ctx, "embed", map[string]any{"text": "another"})
    // ... store callID somewhere durable, return early, come back later ...
    _ = v.Await(ctx, callID, &out)
}

A matching service.py:

from sentence_transformers import SentenceTransformer

MODEL = SentenceTransformer("all-MiniLM-L6-v2")

def embed(args):
    vec = MODEL.encode(args["text"]).tolist()
    return {"vector": vec}

Lifecycle

Phase What happens
New Validate config; no subprocess yet.
Start Create venv on disk (if absent) via python3 -m venv. Run pip install if Requirements changed since last successful install. Spawn worker.py under the venv's Python. Load Sources via one define frame. Block until the worker emits its initial ready frame.
Call Marshal args to JSON, send a call frame, return a callID. Does not block on Python execution.
Await Block until the call identified by callID completes or ctx expires. On success, unmarshal the result and consume the entry — subsequent Awaits on the same callID return ErrUnknown. On ctx expiry, do not consume.
Result Non-blocking peek. Returns (false, nil) while the call is in flight, (true, nil) on success with the result populated, (true, <python error>) on Python failure, (false, ErrUnknown) when the callID is unknown.
CallAndAwait Convenience: Call followed by Await on the same goroutine. Synchronous shape.
Close Kill the subprocess, wake parked Awaits with ErrClosed, remove BaseDir on disk. Idempotent.

Start is safe to call again after StateDied. The on-disk venv is reused, pip install is skipped if requirements are unchanged, and a fresh worker is spawned.

Liveness

v, _ := pyvenv.New(pyvenv.Config{
    // ...
    LivenessCallback: func(state pyvenv.State, err error) {
        switch state {
        case pyvenv.StateReady:
            // Worker is up. Activate dependent features.
        case pyvenv.StateDied:
            // Subprocess crashed. Recover (e.g. v.Start(ctx) again).
        }
    },
})

LivenessCallback fires only on async transitions:

  • StateReady after a successful Start.
  • StateDied when the subprocess exits unexpectedly (not when Close killed it).

Start failures surface as the synchronous error from Start — no callback for the never-ready case.

Wire protocol

pyvenv and worker.py exchange length-prefixed JSON frames. A 4-byte big-endian unsigned length prefixes each JSON body.

Go → Python:

{"type": "define", "opID": "<id>", "code": "<python source>"}
{"type": "call",   "callID": "<id>", "func": "<name>", "args": <any JSON>}
{"type": "ping"}

Python → Go:

{"type": "ready"}
{"type": "op_done",   "opID":   "<id>", "ok": true}
{"type": "op_done",   "opID":   "<id>", "ok": false, "errorType": "...", "errorMessage": "..."}
{"type": "call_done", "callID": "<id>", "ok": true,  "result": <any JSON>}
{"type": "call_done", "callID": "<id>", "ok": false, "errorType": "...", "errorMessage": "..."}
{"type": "pong"}

Frames are exchanged over worker.py's stdin/stdout. Stderr is captured into a per-stream ring buffer accessible via TailStdErr.

Result cache

A completed call's result is held in an in-memory cache keyed by callID until either a successful Await / Result consumes it, or Config.ResultCacheTTL elapses (default 15 minutes; set negative to disable eviction). A consumed result frees the entry immediately; the TTL is the safety net for callers that never come back to claim their result.

Single-delivery semantics: only one successful Await (or Result) per callID. Concurrent waiters would have one win the delivery and the other receive ErrUnknown. The framework-side workflow primitives don't normally create concurrent Awaits — retries within a flow are sequential and Fork creates a fresh task that issues its own Call — so this is a documented library-level constraint rather than a workflow-author concern.

Concurrency

One Python process holds one GIL, but the GIL is released during native calls (PyTorch, NumPy, pandas) and during I/O. Concurrent Calls into the same Venv run on threads in a ThreadPoolExecutor sized by Config.MaxWorkers.

Two Calls with the same function name and different args run on separate worker threads in parallel, assuming the Python code is thread-safe. The caller is responsible for ensuring that.

For pure-Python CPU-bound work that won't release the GIL, the right answer is one Venv per worker, not multiple workers within one Venv. Run several Venv instances in your Go program if you need process-level parallelism.

Output buffers

Each Venv has rolling stdout and stderr buffers on disk under BaseDir/meta/. TailStdOut() and TailStdErr() return up to 2 * OutputBufferKB bytes of recent output. Pull-based: callers only pay for output they actually fetch.

Output from concurrent Calls interleaves in the buffer. That is expected — per-call tagging would require per-call buffers, which scales worse.

License

Apache 2.0.

Documentation

Overview

Package pyvenv runs a Python virtual environment as an in-process worker for a Go program. The Go side spawns one long-lived Python subprocess per Venv, talks to it over length-prefixed JSON frames on stdin/stdout, and exposes typed Call/Await into named Python functions.

The module is framework-agnostic and has no third-party Go dependencies. The intended consumer is any Go program that wants to call into Python libraries (PyTorch, pandas, scikit-learn, etc.) without reimplementing subprocess management, framing, and result tracking.

Call/Await are split so that a long-running Python computation can outlive the caller's context. Call enqueues a function invocation and returns a callID without waiting; the Python work runs in the worker until completion. The returned callID is the handle for Await(ctx, callID, &result), which blocks until the worker finishes or ctx expires. ctx expiry on Await does not cancel or consume the in-flight call — a subsequent Await(callID) by the same caller (or a retry of a workflow task that persisted the callID) re-enters the wait. A successful Await consumes the result; a subsequent Await on the same callID returns ErrUnknown.

Typical lifecycle:

  1. Embed Python sources at compile time: //go:embed *.py var pyFiles embed.FS

  2. Build a Venv and Start it: v, err := pyvenv.New(pyvenv.Config{ Sources: ReadAll(pyFiles), Requirements: []string{"pandas==2.2.0"}, MaxWorkers: 4, }) err = v.Start(ctx)

  3. Call into Python and Await the result: callID, err := v.Call(ctx, "embed", map[string]any{"text": "hello"}) var out struct{ Vector []float64 } err = v.Await(ctx, callID, &out)

    Or use the synchronous convenience helper: err = v.CallAndAwait(ctx, "embed", map[string]any{"text": "hello"}, &out)

  4. Tear down: v.Close(ctx)

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotReady is returned when Call is invoked before Start has succeeded.
	ErrNotReady = errors.New("pyvenv: not started")
	// ErrClosed is returned when Call, Await, or Start is invoked after Close.
	ErrClosed = errors.New("pyvenv: closed")
	// ErrDied is returned when the Python subprocess exited unexpectedly.
	ErrDied = errors.New("pyvenv: python subprocess exited unexpectedly")
	// ErrUnknown is returned by Await or Result when the callID is not known to the Venv —
	// either because it was never issued, the result was already consumed by a prior Await,
	// or the result aged out of the cache.
	ErrUnknown = errors.New("pyvenv: unknown callID")
)

Sentinel errors returned from Call, Await, Result, Start, and helpers.

Functions

This section is empty.

Types

type Config

type Config struct {
	// PythonInterpreter is the bootstrap interpreter used once at venv creation
	// (`<interp> -m venv ...`). After bootstrap the venv has its own pinned interpreter.
	// Default: "python3" resolved via $PATH.
	PythonInterpreter string

	// Sources is the list of Python source files to load into the worker, in order.
	// The module concatenates them and sends one `define` frame; the caller decides
	// ordering (typical convention: helpers first, then a main service.py last so it
	// can reference earlier names).
	Sources []string

	// Requirements is the list of pip packages to install at Start time. Empty list
	// skips the pip install pass. Identical to the lines of a requirements.txt; commas
	// and version specifiers in entries are passed through to pip as-is.
	Requirements []string

	// MaxWorkers is the size of the Python ThreadPoolExecutor that serves concurrent
	// Call requests. Default 1. Use higher values only when the Python code is
	// thread-safe and benefits from concurrent dispatch.
	MaxWorkers int

	// BaseDir is the on-disk directory holding the venv tree (`<dir>/venv/`) and the
	// worker output buffers (`<dir>/meta/`). Default: a temp directory created by the
	// module on first Start.
	BaseDir string

	// OutputBufferKB sizes each of the two ring files per stream. Each Venv has
	// 2 * stream-count files (stdout-a, stdout-b, stderr-a, stderr-b). Default 256.
	OutputBufferKB int

	// ResultCacheTTL caps how long a completed-but-not-yet-Awaited call result lingers
	// before being evicted. Default 15 minutes. Set to a negative value to disable
	// eviction entirely (results stay until consumed, the Venv dies, or it is Closed).
	ResultCacheTTL time.Duration

	// Logger receives diagnostic messages. nil → logs to stderr via [log.Default].
	Logger Logger

	// LivenessCallback observes async lifecycle transitions. nil disables the hook.
	LivenessCallback LivenessCallback
}

Config configures a Venv. Zero values are sensible defaults.

type LivenessCallback

type LivenessCallback func(state State, err error)

LivenessCallback is invoked on async lifecycle transitions. The module invokes the callback from a goroutine it owns; do not block. A single Venv may invoke its callback multiple times across its lifetime (Ready then Died then Ready after a successful Start retry).

type Logger

type Logger interface {
	LogInfo(ctx context.Context, msg string, kv ...any)
	LogError(ctx context.Context, msg string, kv ...any)
}

Logger is the destination for diagnostic logs (subprocess lifecycle, pip output, worker crashes). The minimal surface lets callers wrap any sink (slog, log, a framework's own logger) with a small adapter.

type State

type State int

State identifies an async lifecycle transition observed by a LivenessCallback.

const (
	// StateReady fires when [Venv.Start] has successfully spawned the worker and the worker
	// has emitted its initial ready frame.
	StateReady State = iota
	// StateDied fires when the subprocess exits unexpectedly. Subsequent [Venv.Call] returns
	// [ErrDied] until [Venv.Start] is called again or [Venv.Close] terminates the Venv.
	StateDied
)

type Venv

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

Venv owns one long-lived Python subprocess and its on-disk virtualenv. Safe for concurrent Call from multiple goroutines.

func New

func New(cfg Config) (*Venv, error)

New constructs a Venv. The Python subprocess is not spawned; the caller must call Venv.Start.

func (*Venv) Await

func (v *Venv) Await(ctx context.Context, callID string, result any) error

Await blocks until the call identified by callID completes or ctx expires. On successful delivery the result is unmarshaled into *result and the entry is consumed — subsequent Awaits on the same callID return ErrUnknown. On Python failure, Await returns an error whose message includes the Python exception type and message and the entry is consumed. On ctx expiry, Await returns ctx.Err() without consuming the entry; a later Await may re-enter the wait.

Returns ErrUnknown when callID is not known to this Venv (never issued, already consumed, or evicted by the result cache's TTL).

Pass result = nil when the caller does not care about the unmarshaled value.

func (*Venv) Call

func (v *Venv) Call(ctx context.Context, funcName string, args any) (callID string, err error)

Call asynchronously invokes a named Python function with the given JSON-marshaled args. Returns a callID that the caller passes to Venv.Await or Venv.Result to retrieve the result. Call does not block on Python execution; it validates state, registers the call, writes one frame, and returns.

The Python work runs to completion in the worker regardless of whether the caller's ctx expires before Await returns. A subsequent Await(callID) — including one from a separate goroutine or after a workflow-task retry that persisted the callID — re-enters the wait and receives the result once Python finishes.

May be called from multiple goroutines concurrently. Each in-flight Call consumes one slot in the Python Config.MaxWorkers thread pool; additional calls queue inside the worker.

func (*Venv) CallAndAwait

func (v *Venv) CallAndAwait(ctx context.Context, funcName string, args, result any) error

CallAndAwait is the synchronous convenience shape: Call followed by Await on the same goroutine. Use it when the caller is happy to block for the duration of the Python work and does not need to hand the callID across a context boundary (e.g. a workflow task retry, or a separate progress-tracking goroutine).

func (*Venv) Close

func (v *Venv) Close(ctx context.Context) error

Close kills the subprocess, closes the ring writers, removes the on-disk venv directory, and marks the Venv terminal. In-flight Await returns ErrClosed. Idempotent.

func (*Venv) Ready

func (v *Venv) Ready() bool

Ready reports whether the worker is up and accepting Call.

func (*Venv) Result

func (v *Venv) Result(ctx context.Context, callID string, result any) (ready bool, err error)

Result is the non-blocking sibling of Venv.Await. If a terminal result is available it is consumed and unmarshaled into *result (or returned via err when Python raised). If the call is still running, returns ready=false, err=nil without consuming. If callID is unknown, returns ready=false, err=[ErrUnknown].

As with Await, a successful delivery (ready=true) consumes the entry; subsequent Result or Await on the same callID returns ErrUnknown.

func (*Venv) Start

func (v *Venv) Start(ctx context.Context) error

Start creates the on-disk venv (skipping the create step if one is already present at BaseDir/venv), runs `pip install` if Config.Requirements is non-empty, spawns the worker, loads the embedded sources, and waits for the worker to emit its initial ready frame. Returns when the worker is ready or ctx expires. Safe to retry after StateDied: the on-disk venv is reused, pip install is skipped if the install marker is current, and a fresh worker is spawned.

func (*Venv) TailStdErr

func (v *Venv) TailStdErr() []byte

TailStdErr returns the most recent stderr from the worker.

func (*Venv) TailStdOut

func (v *Venv) TailStdOut() []byte

TailStdOut returns the most recent stdout from the worker, up to 2 * OutputBufferKB bytes. Pull-based; cheap. Returns nil before Start and after Close.

Jump to

Keyboard shortcuts

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