@adhd/apigen-python-env 0.1.5 → 0.2.1

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.
@@ -0,0 +1,260 @@
1
+ """
2
+ apigen_python.gateway_adapter — IPC server side for the TS gateway (SPEC §13.1 / §14.4).
3
+
4
+ Wire protocol: stdio JSON-RPC (line-delimited JSON).
5
+
6
+ Each request is one JSON line on stdin; each response is one JSON line on stdout.
7
+ The TS side's Python HostAdapter connects to this over a subprocess pipe.
8
+
9
+ Request shape:
10
+ {"id": <str>, "method": "invoke"|"health"|"ready", "params": {...}}
11
+
12
+ Response shape (success):
13
+ {"id": <str>, "result": <any>}
14
+
15
+ Response shape (error):
16
+ {"id": <str>, "error": {"code": <str>, "message": <str>}}
17
+
18
+ Methods:
19
+ invoke → params: {operation: <Operation dict>, data: {}, envelope: {}, transport: str}
20
+ result: <function return value>
21
+ health → result: {"status": "ok", "host": "python"}
22
+ ready → result: {"ready": true}
23
+
24
+ Readiness signal:
25
+ On startup, before the read loop begins, the adapter writes a single line:
26
+ {"ready": true}
27
+ This is the §13.1 readiness signal the TS HostAdapter waits for.
28
+
29
+ Usage:
30
+ python -m apigen_python.gateway_adapter [--module <path>] [--namespace <ns>]
31
+
32
+ The adapter loads the specified module (or the echo plugin by default),
33
+ extracts its operations, and starts serving.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import json
40
+ import sys
41
+ import traceback
42
+ from typing import Any
43
+
44
+ from .errors import ApiError
45
+ from .plugin_echo import ECHO_OPERATIONS, ECHO_REGISTRY
46
+ from .runtime import HostRequest, Runtime
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # JSON-RPC handler
51
+ # ---------------------------------------------------------------------------
52
+
53
+ async def _handle_request(
54
+ runtime: Runtime,
55
+ req: dict[str, Any],
56
+ ) -> dict[str, Any]:
57
+ """Dispatch one JSON-RPC request and return the response dict."""
58
+ req_id = req.get("id", "")
59
+ method = req.get("method", "")
60
+ params = req.get("params", {})
61
+
62
+ try:
63
+ if method == "invoke":
64
+ op = params.get("operation", {})
65
+ data = params.get("data", {})
66
+ envelope = params.get("envelope", {})
67
+ transport = params.get("transport", "http")
68
+ host_req = HostRequest(
69
+ operation=op,
70
+ data=data,
71
+ envelope=envelope,
72
+ transport=transport,
73
+ )
74
+ result = await runtime.invoke(host_req)
75
+ return {"id": req_id, "result": result}
76
+
77
+ elif method == "health":
78
+ return {"id": req_id, "result": {"status": "ok", "host": "python"}}
79
+
80
+ elif method == "ready":
81
+ return {"id": req_id, "result": {"ready": True}}
82
+
83
+ else:
84
+ return {
85
+ "id": req_id,
86
+ "error": {"code": "not_found", "message": f"unknown method: {method}"},
87
+ }
88
+
89
+ except ApiError as exc:
90
+ return {
91
+ "id": req_id,
92
+ "error": exc.to_json(),
93
+ }
94
+ except Exception as exc:
95
+ return {
96
+ "id": req_id,
97
+ "error": {"code": "internal", "message": str(exc)},
98
+ }
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Stdio server loop
103
+ # ---------------------------------------------------------------------------
104
+
105
+ async def _serve_stdio(runtime: Runtime) -> None:
106
+ """Read JSON-RPC requests from stdin, write responses to stdout (line-delimited).
107
+
108
+ Startup: emit the readiness signal first so the TS HostAdapter can unblock.
109
+ """
110
+ # §13.1 readiness signal — TS side polls ready() which reads this.
111
+ sys.stdout.write(json.dumps({"ready": True}) + "\n")
112
+ sys.stdout.flush()
113
+
114
+ loop = asyncio.get_event_loop()
115
+ reader = asyncio.StreamReader()
116
+ protocol = asyncio.StreamReaderProtocol(reader)
117
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
118
+
119
+ while True:
120
+ try:
121
+ line = await reader.readline()
122
+ except Exception:
123
+ break
124
+ if not line:
125
+ break
126
+ line_str = line.decode("utf-8").strip()
127
+ if not line_str:
128
+ continue
129
+ try:
130
+ req = json.loads(line_str)
131
+ except json.JSONDecodeError as exc:
132
+ resp = {"id": None, "error": {"code": "invalid_argument", "message": f"JSON parse error: {exc}"}}
133
+ sys.stdout.write(json.dumps(resp) + "\n")
134
+ sys.stdout.flush()
135
+ continue
136
+
137
+ resp = await _handle_request(runtime, req)
138
+ sys.stdout.write(json.dumps(resp) + "\n")
139
+ sys.stdout.flush()
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Synchronous server (for testing without asyncio)
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def serve_stdio_sync(runtime: Runtime) -> None:
147
+ """Blocking stdio server loop (sync version for subprocess spawning).
148
+
149
+ Emits the §13.1 readiness signal then processes requests line by line.
150
+ """
151
+ sys.stdout.write(json.dumps({"ready": True}) + "\n")
152
+ sys.stdout.flush()
153
+
154
+ for raw_line in sys.stdin:
155
+ line = raw_line.strip()
156
+ if not line:
157
+ continue
158
+ try:
159
+ req = json.loads(line)
160
+ except json.JSONDecodeError as exc:
161
+ resp: dict[str, Any] = {
162
+ "id": None,
163
+ "error": {"code": "invalid_argument", "message": f"JSON parse error: {exc}"},
164
+ }
165
+ sys.stdout.write(json.dumps(resp) + "\n")
166
+ sys.stdout.flush()
167
+ continue
168
+
169
+ # Dispatch synchronously.
170
+ req_id = req.get("id", "")
171
+ method = req.get("method", "")
172
+ params = req.get("params", {})
173
+ try:
174
+ if method == "invoke":
175
+ op = params.get("operation", {})
176
+ data = params.get("data", {})
177
+ envelope = params.get("envelope", {})
178
+ transport = params.get("transport", "http")
179
+ host_req = HostRequest(
180
+ operation=op,
181
+ data=data,
182
+ envelope=envelope,
183
+ transport=transport,
184
+ )
185
+ result = runtime.invoke_sync(host_req)
186
+ resp = {"id": req_id, "result": result}
187
+
188
+ elif method == "health":
189
+ resp = {"id": req_id, "result": {"status": "ok", "host": "python"}}
190
+
191
+ elif method == "ready":
192
+ resp = {"id": req_id, "result": {"ready": True}}
193
+
194
+ else:
195
+ resp = {
196
+ "id": req_id,
197
+ "error": {"code": "not_found", "message": f"unknown method: {method}"},
198
+ }
199
+
200
+ except ApiError as exc:
201
+ resp = {"id": req_id, "error": exc.to_json()}
202
+ except Exception as exc:
203
+ resp = {"id": req_id, "error": {"code": "internal", "message": str(exc)}}
204
+
205
+ sys.stdout.write(json.dumps(resp) + "\n")
206
+ sys.stdout.flush()
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # CLI entry point
211
+ # ---------------------------------------------------------------------------
212
+
213
+ def _main() -> None:
214
+ import argparse
215
+
216
+ parser = argparse.ArgumentParser(
217
+ description=(
218
+ "apigen Python gateway adapter — stdio JSON-RPC server.\n\n"
219
+ "Wire protocol: line-delimited JSON on stdin/stdout.\n"
220
+ "Request: {id, method, params}\n"
221
+ "Response: {id, result} or {id, error: {code, message}}\n"
222
+ "Startup: emits {ready: true} before the read loop."
223
+ )
224
+ )
225
+ parser.add_argument(
226
+ "--module", default=None,
227
+ help="Path to a Python module to extract and serve (default: echo plugin)"
228
+ )
229
+ parser.add_argument(
230
+ "--namespace", default=None,
231
+ help="Namespace for module extraction"
232
+ )
233
+ args = parser.parse_args()
234
+
235
+ if args.module:
236
+ from .extractor import extract_module
237
+ import importlib.util
238
+ from pathlib import Path
239
+
240
+ ops = extract_module(args.module, namespace=args.namespace)
241
+ spec = importlib.util.spec_from_file_location("_apigen_adapter_module_", args.module)
242
+ mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
243
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
244
+
245
+ registry: dict[str, Any] = {}
246
+ for op in ops:
247
+ # Best-effort: map op id to the fn by the last path segment raw name.
248
+ fn_name = op["path"][-1]["raw"]
249
+ fn = getattr(mod, fn_name, None)
250
+ if callable(fn):
251
+ registry[op["id"]] = fn
252
+ else:
253
+ registry = ECHO_REGISTRY
254
+
255
+ runtime = Runtime(registry)
256
+ serve_stdio_sync(runtime)
257
+
258
+
259
+ if __name__ == "__main__":
260
+ _main()