python-plugin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyplugin/transport.py ADDED
@@ -0,0 +1,103 @@
1
+ """Listener helpers — unix socket on POSIX, TCP loopback elsewhere or when forced.
2
+
3
+ Mirrors go-plugin's ``serverListener_unix`` / ``serverListener_tcp``.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import socket
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+
13
+ from .handshake import NETWORK_TCP, NETWORK_UNIX
14
+
15
+ ENV_MIN_PORT = "PLUGIN_MIN_PORT"
16
+ ENV_MAX_PORT = "PLUGIN_MAX_PORT"
17
+ ENV_UNIX_SOCKET_DIR = "PLUGIN_UNIX_SOCKET_DIR"
18
+ ENV_UNIX_SOCKET_GROUP = "PLUGIN_UNIX_SOCKET_GROUP"
19
+
20
+
21
+ @dataclass
22
+ class Listener:
23
+ """A bound listener ready for ``grpc.Server.add_*_port``."""
24
+ network: str # "unix" or "tcp"
25
+ address: str # "/tmp/plugin123" or "127.0.0.1:port"
26
+ grpc_target: str # what to pass to grpc: "unix:/tmp/sock" or "127.0.0.1:port"
27
+ cleanup_path: str | None = None # filesystem path to remove on close, if any
28
+
29
+
30
+ def _is_windows() -> bool:
31
+ return sys.platform.startswith("win")
32
+
33
+
34
+ def open_listener(*, force_tcp: bool = False) -> Listener:
35
+ """Open a plugin-side listener following go-plugin's defaults.
36
+
37
+ On Windows or when ``force_tcp``, picks an unused TCP port in the range
38
+ given by ``PLUGIN_MIN_PORT``/``PLUGIN_MAX_PORT``. Otherwise creates a
39
+ unique-named unix socket file (under ``PLUGIN_UNIX_SOCKET_DIR`` if set).
40
+ """
41
+ if force_tcp or _is_windows():
42
+ return _open_tcp()
43
+ return _open_unix()
44
+
45
+
46
+ def _open_unix() -> Listener:
47
+ socket_dir = os.environ.get(ENV_UNIX_SOCKET_DIR) or None
48
+ # Mirror go-plugin's serverListener_unix: claim a unique tempfile name,
49
+ # remove the file, hand the (now non-existent) path to grpc which will
50
+ # bind the unix socket itself.
51
+ fd, path = tempfile.mkstemp(prefix="plugin", dir=socket_dir)
52
+ os.close(fd)
53
+ os.remove(path)
54
+ return Listener(
55
+ network=NETWORK_UNIX,
56
+ address=path,
57
+ grpc_target=f"unix:{path}",
58
+ cleanup_path=path,
59
+ )
60
+
61
+
62
+ def _open_tcp() -> Listener:
63
+ min_port = int(os.environ.get(ENV_MIN_PORT) or 0)
64
+ max_port = int(os.environ.get(ENV_MAX_PORT) or 0)
65
+
66
+ if min_port == 0 and max_port == 0:
67
+ # Pick a free port via OS.
68
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
69
+ s.bind(("127.0.0.1", 0))
70
+ port = s.getsockname()[1]
71
+ s.close()
72
+ return Listener(
73
+ network=NETWORK_TCP,
74
+ address=f"127.0.0.1:{port}",
75
+ grpc_target=f"127.0.0.1:{port}",
76
+ )
77
+
78
+ if min_port > max_port:
79
+ raise OSError(f"PLUGIN_MIN_PORT={min_port} > PLUGIN_MAX_PORT={max_port}")
80
+
81
+ for port in range(min_port, max_port + 1):
82
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
83
+ try:
84
+ s.bind(("127.0.0.1", port))
85
+ s.close()
86
+ return Listener(
87
+ network=NETWORK_TCP,
88
+ address=f"127.0.0.1:{port}",
89
+ grpc_target=f"127.0.0.1:{port}",
90
+ )
91
+ except OSError:
92
+ s.close()
93
+ continue
94
+ raise OSError("couldn't bind plugin TCP listener in configured port range")
95
+
96
+
97
+ def grpc_dial_target(network: str, address: str) -> str:
98
+ """Translate a (network, address) pair from a handshake into a grpc.Dial target."""
99
+ if network == NETWORK_UNIX:
100
+ return f"unix:{address}"
101
+ if network == NETWORK_TCP:
102
+ return address
103
+ raise ValueError(f"unknown network type: {network!r}")
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-plugin
3
+ Version: 0.1.0
4
+ Summary: Wire-compatible Python port of go-plugin (gRPC subprocess plugins with AutoMTLS)
5
+ Project-URL: Homepage, https://github.com/mlund01/py-plugin
6
+ Project-URL: Repository, https://github.com/mlund01/py-plugin
7
+ Project-URL: Issues, https://github.com/mlund01/py-plugin/issues
8
+ Author: Max Lund
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: go-plugin,grpc,ipc,mtls,plugin,rpc
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Libraries
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: cryptography>=41
29
+ Requires-Dist: grpclib[protobuf]>=0.4.7
30
+ Requires-Dist: protobuf>=4.25
31
+ Provides-Extra: dev
32
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
34
+ Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
35
+ Requires-Dist: pytest>=7; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # pyplugin
39
+
40
+ A Python port of [`go-plugin`](https://github.com/hashicorp/go-plugin),
41
+ **byte-for-byte wire-compatible** with the original — including AutoMTLS
42
+ with ECDSA P-521 ephemeral certs.
43
+
44
+ A Python host can launch a Go plugin built with go-plugin, and a Go host
45
+ built against go-plugin can launch a Python plugin built with pyplugin.
46
+ **Both directions, with or without AutoMTLS.** Verified against the
47
+ upstream `examples/grpc/plugin-go-grpc` binary in the test suite.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install python-plugin
53
+ ```
54
+
55
+ The PyPI distribution name is `python-plugin`; the Python import name is
56
+ `pyplugin` (`from pyplugin import Client, serve, ...`).
57
+
58
+ ## Why grpclib (and async)
59
+
60
+ go-plugin generates **ECDSA P-521** ephemeral certificates for AutoMTLS.
61
+ `grpcio` is built on BoringSSL, which deliberately omits P-521 from its
62
+ TLS signature algorithm list — there's no way to configure it back in,
63
+ and we want exact wire-compat. `grpclib` is a pure-Python gRPC library
64
+ on top of Python's `ssl` module (OpenSSL), which supports P-521 freely.
65
+ That makes interop with stock go-plugin work out of the box.
66
+
67
+ The cost: the public API is **async**. Plugin servicers are `async def`;
68
+ host code uses `async with Client(...) as c: await c.start()` etc.
69
+
70
+ ## Quick start
71
+
72
+ A complete runnable example lives in [`examples/greeter/`](examples/greeter/) —
73
+ clone the repo, `pip install -e '.[dev]'`, then:
74
+
75
+ ```bash
76
+ python examples/greeter/host.py "ada" # insecure
77
+ AUTO_MTLS=1 python examples/greeter/host.py "ada" # P-521 mTLS
78
+ ```
79
+
80
+ ### Plugin (async)
81
+
82
+ ```python
83
+ # my_plugin.py
84
+ from pyplugin import HandshakeConfig, Plugin, ServeConfig, serve
85
+ from grpclib.client import Channel
86
+
87
+ # stubs generated by grpclib's protoc plugin (see scripts/gen_protos.py):
88
+ import myservice_grpc, myservice_pb2
89
+
90
+ class MyServicer(myservice_grpc.MyServiceBase):
91
+ async def Greet(self, stream):
92
+ request = await stream.recv_message()
93
+ await stream.send_message(myservice_pb2.GreetResponse(message=f"hello {request.name}"))
94
+
95
+ class MyPlugin(Plugin):
96
+ def servicers(self, broker):
97
+ return [MyServicer()]
98
+ def stub(self, broker, channel: Channel):
99
+ return myservice_grpc.MyServiceStub(channel)
100
+
101
+ if __name__ == "__main__":
102
+ serve(ServeConfig(
103
+ handshake_config=HandshakeConfig(
104
+ protocol_version=1,
105
+ magic_cookie_key="MYPLUGIN_COOKIE",
106
+ magic_cookie_value="hello",
107
+ ),
108
+ plugins={"my": MyPlugin()},
109
+ ))
110
+ ```
111
+
112
+ ### Host (async)
113
+
114
+ ```python
115
+ import asyncio, sys
116
+ from pyplugin import Client, ClientConfig, HandshakeConfig
117
+
118
+ async def main():
119
+ async with Client(ClientConfig(
120
+ handshake_config=HandshakeConfig(1, "MYPLUGIN_COOKIE", "hello"),
121
+ plugins={"my": MyPlugin()},
122
+ cmd=[sys.executable, "my_plugin.py"],
123
+ auto_mtls=True, # P-521 mTLS, fully wire-compatible with go-plugin
124
+ )) as client:
125
+ stub = client.dispense("my")
126
+ resp = await stub.Greet(myservice_pb2.GreetRequest(name="world"))
127
+ print(resp.message)
128
+
129
+ asyncio.run(main())
130
+ ```
131
+
132
+ ## What's implemented
133
+
134
+ | Feature | Status |
135
+ | --- | --- |
136
+ | stdout handshake protocol (6/7 segments, base64.RawStdEncoding cert) | ✅ |
137
+ | magic cookie validation | ✅ |
138
+ | gRPC transport: unix sockets (POSIX) and TCP loopback | ✅ |
139
+ | AutoMTLS with **ECDSA P-521 / SHA-512** (matches go-plugin) | ✅ |
140
+ | `GRPCController.Shutdown` graceful exit | ✅ |
141
+ | Kill ladder: Shutdown → SIGTERM → SIGKILL | ✅ |
142
+ | stderr forwarding with hclog parser (JSON + pretty) | ✅ |
143
+ | `GRPCBroker` bidirectional sub-channels (Accept/Dial) | ✅ |
144
+ | `GRPCStdio` post-handshake stdout/stderr stream | ✅ |
145
+ | `ReattachConfig` (host re-connects to running plugin) | ✅ |
146
+ | `VersionedPlugins` negotiation | ✅ |
147
+ | gRPC reflection + health (service name `plugin`) | ✅ |
148
+ | `PLUGIN_MULTIPLEX_GRPC` (broker over single socket) | ❌ deferred (advertised as not supported) |
149
+
150
+ ## Verified Python ↔ Go interop
151
+
152
+ The test suite includes 4 real interop tests against upstream go-plugin:
153
+
154
+ ```
155
+ tests/interop/test_python_host_drives_go_plugin.py
156
+ test_python_host_drives_go_plugin_no_mtls ✓
157
+ test_python_host_drives_go_plugin_with_p521_automtls ✓
158
+
159
+ tests/interop/test_go_host_drives_python_plugin.py
160
+ test_go_host_drives_python_plugin_no_mtls ✓
161
+ test_go_host_drives_python_plugin_with_p521_automtls ✓
162
+ ```
163
+
164
+ These run only when the binaries are present; the README of
165
+ `tests/interop/` describes how to build them. Out of the box you can
166
+ reproduce the matrix locally:
167
+
168
+ ```bash
169
+ # Build go-plugin's example KV plugin (Go)
170
+ git clone --depth=1 https://github.com/hashicorp/go-plugin /tmp/gp
171
+ (cd /tmp/gp/examples/grpc && go build -o /tmp/plugin-go-grpc ./plugin-go-grpc)
172
+ PYPLUGIN_GO_PLUGIN_KV=/tmp/plugin-go-grpc pytest tests/interop/test_python_host_drives_go_plugin.py
173
+ ```
174
+
175
+ For the Go-host-drives-Python-plugin direction, see the small Go host
176
+ template at the end of this README — drop it into `tests/interop/go-host/`
177
+ with a `replace` directive in `go.mod` pointing at the local go-plugin
178
+ clone, `go build`, then point `PYPLUGIN_GO_HOST_BIN` at the binary.
179
+
180
+ ## Layout
181
+
182
+ ```
183
+ src/pyplugin/
184
+ handshake.py # stdout protocol line format/parse
185
+ cookie.py # magic-cookie validation
186
+ mtls.py # ephemeral P-521 cert generation + ssl.SSLContext builders
187
+ transport.py # unix / tcp listener helpers
188
+ server.py # serve(ServeConfig) — sync entry, internal asyncio loop
189
+ client.py # Client / ClientConfig — async host launcher
190
+ process.py # cross-platform subprocess termination
191
+ reattach.py # ReattachConfig
192
+ controller.py # GRPCController.Shutdown servicer (grpclib async)
193
+ broker.py # GRPCBroker bidirectional multiplexer (grpclib async)
194
+ stdio.py # GRPCStdio post-handshake stream (grpclib async)
195
+ health.py # static grpc.health.v1 servicer (returns SERVING for "plugin")
196
+ plugin.py # Plugin ABC, PluginSet, VersionedPlugins
197
+ logging_bridge.py # hclog (JSON + pretty) line parser
198
+ errors.py # exception hierarchy
199
+ proto/ # vendored .proto files from go-plugin (verbatim)
200
+ _generated/ # checked-in grpclib stubs
201
+ fixtures/example_kv/ # example KV plugin used by smoke tests
202
+ tests/ # 40 unit + Python↔Python tests
203
+ tests/interop/ # 4 real-go-plugin interop tests
204
+ ```
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ python3 -m venv .venv
210
+ .venv/bin/pip install -e '.[dev]'
211
+ .venv/bin/python scripts/gen_protos.py # regenerate stubs
212
+ .venv/bin/python -m pytest # run tests
213
+ ```
214
+
215
+ ## Go host template
216
+
217
+ Use this with `go.mod`'s `replace github.com/hashicorp/go-plugin => /path/to/clone`:
218
+
219
+ ```go
220
+ package main
221
+
222
+ import (
223
+ "fmt"; "io"; "log"; "os"; "os/exec"
224
+ hclog "github.com/hashicorp/go-hclog"
225
+ "github.com/hashicorp/go-plugin"
226
+ "github.com/hashicorp/go-plugin/examples/grpc/shared"
227
+ )
228
+
229
+ func main() {
230
+ log.SetOutput(io.Discard)
231
+ client := plugin.NewClient(&plugin.ClientConfig{
232
+ HandshakeConfig: shared.Handshake,
233
+ Plugins: map[string]plugin.Plugin{shared.PluginGRPC: &shared.KVGRPCPlugin{}},
234
+ Cmd: exec.Command(os.Args[1], os.Args[2]),
235
+ AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
236
+ AutoMTLS: os.Getenv("AUTO_MTLS") == "1",
237
+ Logger: hclog.New(&hclog.LoggerOptions{Output: io.Discard, Level: hclog.Off}),
238
+ })
239
+ defer client.Kill()
240
+ rpc, err := client.Client(); if err != nil { panic(err) }
241
+ raw, err := rpc.Dispense(shared.PluginGRPC); if err != nil { panic(err) }
242
+ kv := raw.(shared.KV)
243
+ if err := kv.Put(os.Args[4], []byte(os.Args[5])); err != nil { panic(err) }
244
+ v, err := kv.Get(os.Args[4]); if err != nil { panic(err) }
245
+ fmt.Print(string(v))
246
+ }
247
+ ```
248
+
249
+ ## License
250
+
251
+ MIT. The vendored `.proto` files in `src/pyplugin/proto/` retain their
252
+ upstream MPL-2.0 headers from
253
+ [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin); MPL-2.0 is
254
+ file-level and is compatible with MIT for the rest of the project.
@@ -0,0 +1,30 @@
1
+ pyplugin/__init__.py,sha256=J1sgTlsqJCngvI2FeYD4H3uYSk79COGsQBOHhrMcfHs,1204
2
+ pyplugin/broker.py,sha256=z3mSzP1FyiDiNNZsC9iNFrEQQmU-_GVnPT6I5wfLgyI,7989
3
+ pyplugin/client.py,sha256=Fx4iq0Czqytoelj6tkUgj3mGUdRGqt8ZrVqJow_kIuI,15278
4
+ pyplugin/controller.py,sha256=MBkAl_9eMc694-21hUuiQTrCbkJUmNJiuZY-Dr_Db5k,757
5
+ pyplugin/cookie.py,sha256=kDta4YhHaK1rfiv_-iPyZWvCLjMmrn3UHlc2qwFrQFk,1407
6
+ pyplugin/errors.py,sha256=2tF-bITFFt-lDWs866qYd6XvhprIVsrOuwdWJRFsXow,1115
7
+ pyplugin/handshake.py,sha256=g2yYjxap_vLkHW1y3hQjSoTft8Ep6PkECKGNdqduIVI,3821
8
+ pyplugin/health.py,sha256=XqAzdMt5aMjXryV1qzxZNH9t9FEqhrJekgpLa5X0sls,1653
9
+ pyplugin/logging_bridge.py,sha256=Icxjw3iGAJ03IgazsVUiPIDjUY_fZR7WIzTDQAH81no,2247
10
+ pyplugin/mtls.py,sha256=5gYFr-iQUshY09UrGiHg0TphK9UmbBqjWh2vr35swdg,6483
11
+ pyplugin/plugin.py,sha256=UbPP_S0dfqD1rrqS2a0Wjo0S9ngMA6nDg-QwQHXtr0I,1199
12
+ pyplugin/process.py,sha256=vomevPuBmTOZrgnb4QnmmQPe2qUOrSxmMHb27Iy1bno,1764
13
+ pyplugin/reattach.py,sha256=ExmkGvigeG421Lo6DzcX5qM-3GK0h3Yg8XtZpX9LCHE,1049
14
+ pyplugin/server.py,sha256=gmgWC6_YiK4WW9U7pE5nPUvvgeQ1-Yg7iND2_vvw3Mg,7404
15
+ pyplugin/stdio.py,sha256=TsxBnhcHCm14oCPVpj7eHsJPcHDrq9l93fJ82SqR2Yo,1264
16
+ pyplugin/transport.py,sha256=j7E_O44fMwYE6G1rAh25V8ZPz_bylS5hThOBkc3C1wU,3370
17
+ pyplugin/_generated/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ pyplugin/_generated/grpc_broker_grpc.py,sha256=sj0dWUXQFyfCBVY5lXm7f2IRDb_WOeDXBOCHSEfVroo,1127
19
+ pyplugin/_generated/grpc_broker_pb2.py,sha256=Yc5cfRt20Hjm6vCCxNgVizshGq5_kFAu4Pj98BeLiNQ,2027
20
+ pyplugin/_generated/grpc_controller_grpc.py,sha256=frLWKtv-zFh3K380N_TNGDR8zq8lG5oh-z1RwL-akto,1138
21
+ pyplugin/_generated/grpc_controller_pb2.py,sha256=A8qRX9pcb8WcFLjYScXV46z-9KtZxbvt8uJ8EgR8AK4,1589
22
+ pyplugin/_generated/grpc_stdio_grpc.py,sha256=YhdaTvyPtXVMvjMgbhy78Zlz7vKF9M6rinXR08Rnqhc,1173
23
+ pyplugin/_generated/grpc_stdio_pb2.py,sha256=igVl4onDwxkD0XgUKR_vY8lAWD_h15qmu2K9CN5luT4,2025
24
+ pyplugin/proto/grpc_broker.proto,sha256=VJn8FNFAgnnaTj8Zj_Tt0-gp_83gEHpjAeBXHmjCpX0,516
25
+ pyplugin/proto/grpc_controller.proto,sha256=Vw2Om0jlGbfPu0uuxZb_vG1mcL87ZQAAOGC-wbdNRVQ,304
26
+ pyplugin/proto/grpc_stdio.proto,sha256=u2CW22euvSA7BGeLN4YbQm2cMyurY1BiMjBexneWzoA,477
27
+ python_plugin-0.1.0.dist-info/METADATA,sha256=43EHSne8d3JaUCfuniCNao5EJwbwrKj6QU18G6U9BSo,9597
28
+ python_plugin-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
29
+ python_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=fILwPEdelkIjeEzc0o8FY3rqqQNQW_bKBYoD0gX6zmY,1065
30
+ python_plugin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Lund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.