@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.
- package/CHANGELOG.md +8 -0
- package/index.js +6 -6
- package/index.mjs +56 -53
- package/lib/python-env.d.ts +14 -4
- package/package.json +2 -2
- package/python/apigen_logical.py +628 -0
- package/python/apigen_python/__init__.py +1 -0
- package/python/apigen_python/errors.py +99 -0
- package/python/apigen_python/extractor.py +578 -0
- package/python/apigen_python/flask_server.py +748 -0
- package/python/apigen_python/gateway_adapter.py +260 -0
- package/python/apigen_python/grpc_server.py +954 -0
- package/python/apigen_python/plugin_echo.py +100 -0
- package/python/apigen_python/runtime.py +228 -0
- package/python/apigen_python/validator.py +135 -0
- package/python/conformance_vectors.json +865 -0
- package/python/pyproject.toml +59 -0
- package/python/run_tests.py +2118 -0
|
@@ -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()
|