A Python port of go-plugin,
byte-for-byte wire-compatible with the original — including AutoMTLS
with ECDSA P-521 ephemeral certs.
A Python host can launch a Go plugin built with go-plugin, and a Go host
built against go-plugin can launch a Python plugin built with pyplugin.
Both directions, with or without AutoMTLS. Verified against the
upstream examples/grpc/plugin-go-grpc binary in the test suite.
pip install python-pluginThe PyPI distribution name is python-plugin; the Python import name is
pyplugin (from pyplugin import Client, serve, ...).
go-plugin generates ECDSA P-521 ephemeral certificates for AutoMTLS.
grpcio is built on BoringSSL, which deliberately omits P-521 from its
TLS signature algorithm list — there's no way to configure it back in,
and we want exact wire-compat. grpclib is a pure-Python gRPC library
on top of Python's ssl module (OpenSSL), which supports P-521 freely.
That makes interop with stock go-plugin work out of the box.
The cost: the public API is async. Plugin servicers are async def;
host code uses async with Client(...) as c: await c.start() etc.
A complete runnable example lives in examples/greeter/ —
clone the repo, pip install -e '.[dev]', then:
python examples/greeter/host.py "ada" # insecure
AUTO_MTLS=1 python examples/greeter/host.py "ada" # P-521 mTLS# my_plugin.py
from pyplugin import HandshakeConfig, Plugin, ServeConfig, serve
from grpclib.client import Channel
# stubs generated by grpclib's protoc plugin (see scripts/gen_protos.py):
import myservice_grpc, myservice_pb2
class MyServicer(myservice_grpc.MyServiceBase):
async def Greet(self, stream):
request = await stream.recv_message()
await stream.send_message(myservice_pb2.GreetResponse(message=f"hello {request.name}"))
class MyPlugin(Plugin):
def servicers(self, broker):
return [MyServicer()]
def stub(self, broker, channel: Channel):
return myservice_grpc.MyServiceStub(channel)
if __name__ == "__main__":
serve(ServeConfig(
handshake_config=HandshakeConfig(
protocol_version=1,
magic_cookie_key="MYPLUGIN_COOKIE",
magic_cookie_value="hello",
),
plugins={"my": MyPlugin()},
))import asyncio, sys
from pyplugin import Client, ClientConfig, HandshakeConfig
async def main():
async with Client(ClientConfig(
handshake_config=HandshakeConfig(1, "MYPLUGIN_COOKIE", "hello"),
plugins={"my": MyPlugin()},
cmd=[sys.executable, "my_plugin.py"],
auto_mtls=True, # P-521 mTLS, fully wire-compatible with go-plugin
)) as client:
stub = client.dispense("my")
resp = await stub.Greet(myservice_pb2.GreetRequest(name="world"))
print(resp.message)
asyncio.run(main())| Feature | Status |
|---|---|
| stdout handshake protocol (6/7 segments, base64.RawStdEncoding cert) | ✅ |
| magic cookie validation | ✅ |
| gRPC transport: unix sockets (POSIX) and TCP loopback | ✅ |
| AutoMTLS with ECDSA P-521 / SHA-512 (matches go-plugin) | ✅ |
GRPCController.Shutdown graceful exit |
✅ |
| Kill ladder: Shutdown → SIGTERM → SIGKILL | ✅ |
| stderr forwarding with hclog parser (JSON + pretty) | ✅ |
GRPCBroker bidirectional sub-channels (Accept/Dial) |
✅ |
GRPCStdio post-handshake stdout/stderr stream |
✅ |
ReattachConfig (host re-connects to running plugin) |
✅ |
VersionedPlugins negotiation |
✅ |
gRPC reflection + health (service name plugin) |
✅ |
PLUGIN_MULTIPLEX_GRPC (broker over single socket) |
❌ deferred (advertised as not supported) |
The test suite includes 4 real interop tests against upstream go-plugin:
tests/interop/test_python_host_drives_go_plugin.py
test_python_host_drives_go_plugin_no_mtls ✓
test_python_host_drives_go_plugin_with_p521_automtls ✓
tests/interop/test_go_host_drives_python_plugin.py
test_go_host_drives_python_plugin_no_mtls ✓
test_go_host_drives_python_plugin_with_p521_automtls ✓
These run only when the binaries are present; the README of
tests/interop/ describes how to build them. Out of the box you can
reproduce the matrix locally:
# Build go-plugin's example KV plugin (Go)
git clone --depth=1 https://github.com/hashicorp/go-plugin /tmp/gp
(cd /tmp/gp/examples/grpc && go build -o /tmp/plugin-go-grpc ./plugin-go-grpc)
PYPLUGIN_GO_PLUGIN_KV=/tmp/plugin-go-grpc pytest tests/interop/test_python_host_drives_go_plugin.pyFor the Go-host-drives-Python-plugin direction, see the small Go host
template at the end of this README — drop it into tests/interop/go-host/
with a replace directive in go.mod pointing at the local go-plugin
clone, go build, then point PYPLUGIN_GO_HOST_BIN at the binary.
src/pyplugin/
handshake.py # stdout protocol line format/parse
cookie.py # magic-cookie validation
mtls.py # ephemeral P-521 cert generation + ssl.SSLContext builders
transport.py # unix / tcp listener helpers
server.py # serve(ServeConfig) — sync entry, internal asyncio loop
client.py # Client / ClientConfig — async host launcher
process.py # cross-platform subprocess termination
reattach.py # ReattachConfig
controller.py # GRPCController.Shutdown servicer (grpclib async)
broker.py # GRPCBroker bidirectional multiplexer (grpclib async)
stdio.py # GRPCStdio post-handshake stream (grpclib async)
health.py # static grpc.health.v1 servicer (returns SERVING for "plugin")
plugin.py # Plugin ABC, PluginSet, VersionedPlugins
logging_bridge.py # hclog (JSON + pretty) line parser
errors.py # exception hierarchy
proto/ # vendored .proto files from go-plugin (verbatim)
_generated/ # checked-in grpclib stubs
fixtures/example_kv/ # example KV plugin used by smoke tests
tests/ # 40 unit + Python↔Python tests
tests/interop/ # 4 real-go-plugin interop tests
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/python scripts/gen_protos.py # regenerate stubs
.venv/bin/python -m pytest # run testsUse this with go.mod's replace github.com/hashicorp/go-plugin => /path/to/clone:
package main
import (
"fmt"; "io"; "log"; "os"; "os/exec"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
"github.com/hashicorp/go-plugin/examples/grpc/shared"
)
func main() {
log.SetOutput(io.Discard)
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: shared.Handshake,
Plugins: map[string]plugin.Plugin{shared.PluginGRPC: &shared.KVGRPCPlugin{}},
Cmd: exec.Command(os.Args[1], os.Args[2]),
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
AutoMTLS: os.Getenv("AUTO_MTLS") == "1",
Logger: hclog.New(&hclog.LoggerOptions{Output: io.Discard, Level: hclog.Off}),
})
defer client.Kill()
rpc, err := client.Client(); if err != nil { panic(err) }
raw, err := rpc.Dispense(shared.PluginGRPC); if err != nil { panic(err) }
kv := raw.(shared.KV)
if err := kv.Put(os.Args[4], []byte(os.Args[5])); err != nil { panic(err) }
v, err := kv.Get(os.Args[4]); if err != nil { panic(err) }
fmt.Print(string(v))
}MIT. The vendored .proto files in src/pyplugin/proto/ retain their
upstream MPL-2.0 headers from
hashicorp/go-plugin; MPL-2.0 is
file-level and is compatible with MIT for the rest of the project.