python-plugin 0.1.0__tar.gz

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.
Files changed (69) hide show
  1. python_plugin-0.1.0/.github/workflows/publish.yml +57 -0
  2. python_plugin-0.1.0/.gitignore +12 -0
  3. python_plugin-0.1.0/LICENSE +21 -0
  4. python_plugin-0.1.0/PKG-INFO +254 -0
  5. python_plugin-0.1.0/README.md +217 -0
  6. python_plugin-0.1.0/examples/greeter/README.md +81 -0
  7. python_plugin-0.1.0/examples/greeter/__init__.py +0 -0
  8. python_plugin-0.1.0/examples/greeter/generated/__init__.py +0 -0
  9. python_plugin-0.1.0/examples/greeter/generated/greeter_grpc.py +56 -0
  10. python_plugin-0.1.0/examples/greeter/generated/greeter_pb2.py +44 -0
  11. python_plugin-0.1.0/examples/greeter/host.py +55 -0
  12. python_plugin-0.1.0/examples/greeter/plugin.py +31 -0
  13. python_plugin-0.1.0/examples/greeter/proto/greeter.proto +25 -0
  14. python_plugin-0.1.0/examples/greeter/shared.py +52 -0
  15. python_plugin-0.1.0/fixtures/example_kv/__init__.py +0 -0
  16. python_plugin-0.1.0/fixtures/example_kv/generated/__init__.py +0 -0
  17. python_plugin-0.1.0/fixtures/example_kv/generated/callback_grpc.py +40 -0
  18. python_plugin-0.1.0/fixtures/example_kv/generated/callback_pb2.py +40 -0
  19. python_plugin-0.1.0/fixtures/example_kv/plugin_main.py +23 -0
  20. python_plugin-0.1.0/fixtures/example_kv/plugin_main_for_go_host.py +28 -0
  21. python_plugin-0.1.0/fixtures/example_kv/plugin_main_stubborn.py +30 -0
  22. python_plugin-0.1.0/fixtures/example_kv/plugin_main_tcp.py +24 -0
  23. python_plugin-0.1.0/fixtures/example_kv/plugin_main_versioned.py +31 -0
  24. python_plugin-0.1.0/fixtures/example_kv/plugin_main_zombie.py +33 -0
  25. python_plugin-0.1.0/fixtures/example_kv/proto/callback.proto +11 -0
  26. python_plugin-0.1.0/fixtures/example_kv/proto/kv.proto +17 -0
  27. python_plugin-0.1.0/pyproject.toml +61 -0
  28. python_plugin-0.1.0/scripts/gen_protos.py +68 -0
  29. python_plugin-0.1.0/src/pyplugin/__init__.py +54 -0
  30. python_plugin-0.1.0/src/pyplugin/_generated/__init__.py +0 -0
  31. python_plugin-0.1.0/src/pyplugin/_generated/grpc_broker_grpc.py +40 -0
  32. python_plugin-0.1.0/src/pyplugin/_generated/grpc_broker_pb2.py +41 -0
  33. python_plugin-0.1.0/src/pyplugin/_generated/grpc_controller_grpc.py +40 -0
  34. python_plugin-0.1.0/src/pyplugin/_generated/grpc_controller_pb2.py +39 -0
  35. python_plugin-0.1.0/src/pyplugin/_generated/grpc_stdio_grpc.py +41 -0
  36. python_plugin-0.1.0/src/pyplugin/_generated/grpc_stdio_pb2.py +42 -0
  37. python_plugin-0.1.0/src/pyplugin/broker.py +225 -0
  38. python_plugin-0.1.0/src/pyplugin/client.py +399 -0
  39. python_plugin-0.1.0/src/pyplugin/controller.py +22 -0
  40. python_plugin-0.1.0/src/pyplugin/cookie.py +43 -0
  41. python_plugin-0.1.0/src/pyplugin/errors.py +39 -0
  42. python_plugin-0.1.0/src/pyplugin/handshake.py +121 -0
  43. python_plugin-0.1.0/src/pyplugin/health.py +38 -0
  44. python_plugin-0.1.0/src/pyplugin/logging_bridge.py +70 -0
  45. python_plugin-0.1.0/src/pyplugin/mtls.py +169 -0
  46. python_plugin-0.1.0/src/pyplugin/plugin.py +38 -0
  47. python_plugin-0.1.0/src/pyplugin/process.py +66 -0
  48. python_plugin-0.1.0/src/pyplugin/proto/grpc_broker.proto +21 -0
  49. python_plugin-0.1.0/src/pyplugin/proto/grpc_controller.proto +12 -0
  50. python_plugin-0.1.0/src/pyplugin/proto/grpc_stdio.proto +22 -0
  51. python_plugin-0.1.0/src/pyplugin/reattach.py +27 -0
  52. python_plugin-0.1.0/src/pyplugin/server.py +204 -0
  53. python_plugin-0.1.0/src/pyplugin/stdio.py +36 -0
  54. python_plugin-0.1.0/src/pyplugin/transport.py +103 -0
  55. python_plugin-0.1.0/tests/conftest.py +9 -0
  56. python_plugin-0.1.0/tests/interop/__init__.py +0 -0
  57. python_plugin-0.1.0/tests/interop/generated/__init__.py +0 -0
  58. python_plugin-0.1.0/tests/interop/test_go_host_drives_python_plugin.py +42 -0
  59. python_plugin-0.1.0/tests/interop/test_python_host_drives_go_plugin.py +84 -0
  60. python_plugin-0.1.0/tests/test_broker.py +61 -0
  61. python_plugin-0.1.0/tests/test_cookie.py +34 -0
  62. python_plugin-0.1.0/tests/test_handshake.py +92 -0
  63. python_plugin-0.1.0/tests/test_logging_bridge.py +58 -0
  64. python_plugin-0.1.0/tests/test_mtls.py +56 -0
  65. python_plugin-0.1.0/tests/test_reattach.py +54 -0
  66. python_plugin-0.1.0/tests/test_shutdown.py +67 -0
  67. python_plugin-0.1.0/tests/test_smoke.py +62 -0
  68. python_plugin-0.1.0/tests/test_smoke_tcp.py +42 -0
  69. python_plugin-0.1.0/tests/test_versioning.py +58 -0
@@ -0,0 +1,57 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build sdist + wheel
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - name: Install build
18
+ run: python -m pip install --upgrade build
19
+ - name: Build distributions
20
+ run: python -m build
21
+ - name: Upload dist artifact
22
+ uses: actions/upload-artifact@v4
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish-testpypi:
28
+ name: Publish to TestPyPI
29
+ needs: build
30
+ if: contains(github.ref_name, 'rc') || contains(github.ref_name, 'a') || contains(github.ref_name, 'b')
31
+ runs-on: ubuntu-latest
32
+ environment: testpypi
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+ - uses: pypa/gh-action-pypi-publish@release/v1
41
+ with:
42
+ repository-url: https://test.pypi.org/legacy/
43
+
44
+ publish-pypi:
45
+ name: Publish to PyPI
46
+ needs: build
47
+ if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') }}
48
+ runs-on: ubuntu-latest
49
+ environment: pypi
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v4
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.egg-info/
9
+ build/
10
+ dist/
11
+ .DS_Store
12
+ kv_*
@@ -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.
@@ -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,217 @@
1
+ # pyplugin
2
+
3
+ A Python port of [`go-plugin`](https://github.com/hashicorp/go-plugin),
4
+ **byte-for-byte wire-compatible** with the original — including AutoMTLS
5
+ with ECDSA P-521 ephemeral certs.
6
+
7
+ A Python host can launch a Go plugin built with go-plugin, and a Go host
8
+ built against go-plugin can launch a Python plugin built with pyplugin.
9
+ **Both directions, with or without AutoMTLS.** Verified against the
10
+ upstream `examples/grpc/plugin-go-grpc` binary in the test suite.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install python-plugin
16
+ ```
17
+
18
+ The PyPI distribution name is `python-plugin`; the Python import name is
19
+ `pyplugin` (`from pyplugin import Client, serve, ...`).
20
+
21
+ ## Why grpclib (and async)
22
+
23
+ go-plugin generates **ECDSA P-521** ephemeral certificates for AutoMTLS.
24
+ `grpcio` is built on BoringSSL, which deliberately omits P-521 from its
25
+ TLS signature algorithm list — there's no way to configure it back in,
26
+ and we want exact wire-compat. `grpclib` is a pure-Python gRPC library
27
+ on top of Python's `ssl` module (OpenSSL), which supports P-521 freely.
28
+ That makes interop with stock go-plugin work out of the box.
29
+
30
+ The cost: the public API is **async**. Plugin servicers are `async def`;
31
+ host code uses `async with Client(...) as c: await c.start()` etc.
32
+
33
+ ## Quick start
34
+
35
+ A complete runnable example lives in [`examples/greeter/`](examples/greeter/) —
36
+ clone the repo, `pip install -e '.[dev]'`, then:
37
+
38
+ ```bash
39
+ python examples/greeter/host.py "ada" # insecure
40
+ AUTO_MTLS=1 python examples/greeter/host.py "ada" # P-521 mTLS
41
+ ```
42
+
43
+ ### Plugin (async)
44
+
45
+ ```python
46
+ # my_plugin.py
47
+ from pyplugin import HandshakeConfig, Plugin, ServeConfig, serve
48
+ from grpclib.client import Channel
49
+
50
+ # stubs generated by grpclib's protoc plugin (see scripts/gen_protos.py):
51
+ import myservice_grpc, myservice_pb2
52
+
53
+ class MyServicer(myservice_grpc.MyServiceBase):
54
+ async def Greet(self, stream):
55
+ request = await stream.recv_message()
56
+ await stream.send_message(myservice_pb2.GreetResponse(message=f"hello {request.name}"))
57
+
58
+ class MyPlugin(Plugin):
59
+ def servicers(self, broker):
60
+ return [MyServicer()]
61
+ def stub(self, broker, channel: Channel):
62
+ return myservice_grpc.MyServiceStub(channel)
63
+
64
+ if __name__ == "__main__":
65
+ serve(ServeConfig(
66
+ handshake_config=HandshakeConfig(
67
+ protocol_version=1,
68
+ magic_cookie_key="MYPLUGIN_COOKIE",
69
+ magic_cookie_value="hello",
70
+ ),
71
+ plugins={"my": MyPlugin()},
72
+ ))
73
+ ```
74
+
75
+ ### Host (async)
76
+
77
+ ```python
78
+ import asyncio, sys
79
+ from pyplugin import Client, ClientConfig, HandshakeConfig
80
+
81
+ async def main():
82
+ async with Client(ClientConfig(
83
+ handshake_config=HandshakeConfig(1, "MYPLUGIN_COOKIE", "hello"),
84
+ plugins={"my": MyPlugin()},
85
+ cmd=[sys.executable, "my_plugin.py"],
86
+ auto_mtls=True, # P-521 mTLS, fully wire-compatible with go-plugin
87
+ )) as client:
88
+ stub = client.dispense("my")
89
+ resp = await stub.Greet(myservice_pb2.GreetRequest(name="world"))
90
+ print(resp.message)
91
+
92
+ asyncio.run(main())
93
+ ```
94
+
95
+ ## What's implemented
96
+
97
+ | Feature | Status |
98
+ | --- | --- |
99
+ | stdout handshake protocol (6/7 segments, base64.RawStdEncoding cert) | ✅ |
100
+ | magic cookie validation | ✅ |
101
+ | gRPC transport: unix sockets (POSIX) and TCP loopback | ✅ |
102
+ | AutoMTLS with **ECDSA P-521 / SHA-512** (matches go-plugin) | ✅ |
103
+ | `GRPCController.Shutdown` graceful exit | ✅ |
104
+ | Kill ladder: Shutdown → SIGTERM → SIGKILL | ✅ |
105
+ | stderr forwarding with hclog parser (JSON + pretty) | ✅ |
106
+ | `GRPCBroker` bidirectional sub-channels (Accept/Dial) | ✅ |
107
+ | `GRPCStdio` post-handshake stdout/stderr stream | ✅ |
108
+ | `ReattachConfig` (host re-connects to running plugin) | ✅ |
109
+ | `VersionedPlugins` negotiation | ✅ |
110
+ | gRPC reflection + health (service name `plugin`) | ✅ |
111
+ | `PLUGIN_MULTIPLEX_GRPC` (broker over single socket) | ❌ deferred (advertised as not supported) |
112
+
113
+ ## Verified Python ↔ Go interop
114
+
115
+ The test suite includes 4 real interop tests against upstream go-plugin:
116
+
117
+ ```
118
+ tests/interop/test_python_host_drives_go_plugin.py
119
+ test_python_host_drives_go_plugin_no_mtls ✓
120
+ test_python_host_drives_go_plugin_with_p521_automtls ✓
121
+
122
+ tests/interop/test_go_host_drives_python_plugin.py
123
+ test_go_host_drives_python_plugin_no_mtls ✓
124
+ test_go_host_drives_python_plugin_with_p521_automtls ✓
125
+ ```
126
+
127
+ These run only when the binaries are present; the README of
128
+ `tests/interop/` describes how to build them. Out of the box you can
129
+ reproduce the matrix locally:
130
+
131
+ ```bash
132
+ # Build go-plugin's example KV plugin (Go)
133
+ git clone --depth=1 https://github.com/hashicorp/go-plugin /tmp/gp
134
+ (cd /tmp/gp/examples/grpc && go build -o /tmp/plugin-go-grpc ./plugin-go-grpc)
135
+ PYPLUGIN_GO_PLUGIN_KV=/tmp/plugin-go-grpc pytest tests/interop/test_python_host_drives_go_plugin.py
136
+ ```
137
+
138
+ For the Go-host-drives-Python-plugin direction, see the small Go host
139
+ template at the end of this README — drop it into `tests/interop/go-host/`
140
+ with a `replace` directive in `go.mod` pointing at the local go-plugin
141
+ clone, `go build`, then point `PYPLUGIN_GO_HOST_BIN` at the binary.
142
+
143
+ ## Layout
144
+
145
+ ```
146
+ src/pyplugin/
147
+ handshake.py # stdout protocol line format/parse
148
+ cookie.py # magic-cookie validation
149
+ mtls.py # ephemeral P-521 cert generation + ssl.SSLContext builders
150
+ transport.py # unix / tcp listener helpers
151
+ server.py # serve(ServeConfig) — sync entry, internal asyncio loop
152
+ client.py # Client / ClientConfig — async host launcher
153
+ process.py # cross-platform subprocess termination
154
+ reattach.py # ReattachConfig
155
+ controller.py # GRPCController.Shutdown servicer (grpclib async)
156
+ broker.py # GRPCBroker bidirectional multiplexer (grpclib async)
157
+ stdio.py # GRPCStdio post-handshake stream (grpclib async)
158
+ health.py # static grpc.health.v1 servicer (returns SERVING for "plugin")
159
+ plugin.py # Plugin ABC, PluginSet, VersionedPlugins
160
+ logging_bridge.py # hclog (JSON + pretty) line parser
161
+ errors.py # exception hierarchy
162
+ proto/ # vendored .proto files from go-plugin (verbatim)
163
+ _generated/ # checked-in grpclib stubs
164
+ fixtures/example_kv/ # example KV plugin used by smoke tests
165
+ tests/ # 40 unit + Python↔Python tests
166
+ tests/interop/ # 4 real-go-plugin interop tests
167
+ ```
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ python3 -m venv .venv
173
+ .venv/bin/pip install -e '.[dev]'
174
+ .venv/bin/python scripts/gen_protos.py # regenerate stubs
175
+ .venv/bin/python -m pytest # run tests
176
+ ```
177
+
178
+ ## Go host template
179
+
180
+ Use this with `go.mod`'s `replace github.com/hashicorp/go-plugin => /path/to/clone`:
181
+
182
+ ```go
183
+ package main
184
+
185
+ import (
186
+ "fmt"; "io"; "log"; "os"; "os/exec"
187
+ hclog "github.com/hashicorp/go-hclog"
188
+ "github.com/hashicorp/go-plugin"
189
+ "github.com/hashicorp/go-plugin/examples/grpc/shared"
190
+ )
191
+
192
+ func main() {
193
+ log.SetOutput(io.Discard)
194
+ client := plugin.NewClient(&plugin.ClientConfig{
195
+ HandshakeConfig: shared.Handshake,
196
+ Plugins: map[string]plugin.Plugin{shared.PluginGRPC: &shared.KVGRPCPlugin{}},
197
+ Cmd: exec.Command(os.Args[1], os.Args[2]),
198
+ AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
199
+ AutoMTLS: os.Getenv("AUTO_MTLS") == "1",
200
+ Logger: hclog.New(&hclog.LoggerOptions{Output: io.Discard, Level: hclog.Off}),
201
+ })
202
+ defer client.Kill()
203
+ rpc, err := client.Client(); if err != nil { panic(err) }
204
+ raw, err := rpc.Dispense(shared.PluginGRPC); if err != nil { panic(err) }
205
+ kv := raw.(shared.KV)
206
+ if err := kv.Put(os.Args[4], []byte(os.Args[5])); err != nil { panic(err) }
207
+ v, err := kv.Get(os.Args[4]); if err != nil { panic(err) }
208
+ fmt.Print(string(v))
209
+ }
210
+ ```
211
+
212
+ ## License
213
+
214
+ MIT. The vendored `.proto` files in `src/pyplugin/proto/` retain their
215
+ upstream MPL-2.0 headers from
216
+ [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin); MPL-2.0 is
217
+ file-level and is compatible with MIT for the rest of the project.
@@ -0,0 +1,81 @@
1
+ # Greeter — minimal pyplugin example
2
+
3
+ A standalone, runnable example showing the smallest end-to-end shape of a
4
+ pyplugin host + plugin.
5
+
6
+ ```
7
+ greeter/
8
+ ├── proto/greeter.proto # service definition (Greet, Count)
9
+ ├── generated/ # checked-in grpclib stubs
10
+ ├── shared.py # HandshakeConfig + Plugin glue (used by both sides)
11
+ ├── plugin.py # plugin process entry point
12
+ ├── host.py # host that launches plugin.py and calls it
13
+ └── README.md # this file
14
+ ```
15
+
16
+ ## Run it
17
+
18
+ From the repo root:
19
+
20
+ ```bash
21
+ # install pyplugin in dev mode if you haven't
22
+ .venv/bin/pip install -e '.[dev]'
23
+
24
+ # launch the host — it spawns the plugin subprocess and makes two RPCs
25
+ .venv/bin/python examples/greeter/host.py "ada"
26
+ ```
27
+
28
+ Expected output:
29
+
30
+ ```
31
+ launching plugin (auto_mtls=False)...
32
+ Greet: 'hello, ada!'
33
+ Count('the quick brown fox jumps over the lazy dog'): letters=35, words=9
34
+ ```
35
+
36
+ ## With AutoMTLS (ECDSA P-521)
37
+
38
+ ```bash
39
+ AUTO_MTLS=1 .venv/bin/python examples/greeter/host.py "ada"
40
+ ```
41
+
42
+ The host generates an ephemeral P-521 cert, hands its public key to the plugin
43
+ via `PLUGIN_CLIENT_CERT`, the plugin returns its own P-521 cert in handshake
44
+ field 6 (raw-DER, base64-RawStdEncoding'd), and the gRPC channel runs over
45
+ mTLS — exactly matching go-plugin's wire format.
46
+
47
+ ## Regenerating the stubs
48
+
49
+ If you change `proto/greeter.proto`, regenerate:
50
+
51
+ ```bash
52
+ .venv/bin/python -m grpc_tools.protoc -Iexamples/greeter/proto \
53
+ --python_out=examples/greeter/generated \
54
+ --grpclib_python_out=examples/greeter/generated \
55
+ --plugin=protoc-gen-grpclib_python=$(pwd)/.venv/bin/protoc-gen-grpclib_python \
56
+ examples/greeter/proto/greeter.proto
57
+
58
+ # Fix the generated absolute import to a relative one
59
+ sed -i.bak 's/^import \(greeter_pb2\)/from . import \1/' \
60
+ examples/greeter/generated/greeter_grpc.py
61
+ rm examples/greeter/generated/*.bak
62
+ ```
63
+
64
+ ## What this example shows
65
+
66
+ 1. **The Plugin glue pattern** — `GreeterPlugin` lives in `shared.py` and is
67
+ imported by both `host.py` and `plugin.py`. Its `servicers()` method
68
+ returns the grpclib servicer instances on the plugin side; its `stub()`
69
+ method builds a typed client on the host side. This mirrors go-plugin's
70
+ `GRPCPlugin` interface.
71
+ 2. **HandshakeConfig** — both sides agree on the magic cookie key + value
72
+ and a protocol version. If the user accidentally runs `plugin.py`
73
+ directly, they get the friendly "this is a plugin, not a CLI" message
74
+ and exit code 1.
75
+ 3. **Async API** — servicers are `async def`, the host uses
76
+ `async with Client(...)` and awaits stub methods. This is required
77
+ because pyplugin runs on grpclib (pure-Python on top of `ssl`/OpenSSL,
78
+ which supports the P-521 cert format that go-plugin uses).
79
+ 4. **AutoMTLS toggle** — flipping `auto_mtls=True` is the only difference
80
+ between insecure and full ECDSA-P-521 mTLS. Wire-compatible with a Go
81
+ host that sets `AutoMTLS: true`.
File without changes