@adhd/apigen-python-env 0.2.0 → 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,748 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apigen_python.flask_server — stdlib HTTP server for the Python apigen target (SPEC §14).
|
|
3
|
+
|
|
4
|
+
Runs each exported Python function as a real HTTP endpoint, mirroring the
|
|
5
|
+
TypeScript ``api-fastify`` plugin's route shape, validation contract, and
|
|
6
|
+
logical-type wire encoding.
|
|
7
|
+
|
|
8
|
+
Usage (apigen-serve-core py-flask-serve-split — two-phase extract/serve split):
|
|
9
|
+
python3 -m apigen_python.flask_server \\
|
|
10
|
+
--module <path.py> \\
|
|
11
|
+
--namespace <ns> \\
|
|
12
|
+
--port <p> \\
|
|
13
|
+
--plan-file <plan.json>
|
|
14
|
+
|
|
15
|
+
This server no longer self-extracts or derives its own route/verb table.
|
|
16
|
+
`--plan-file` is REQUIRED and points to a JSON file of the shape
|
|
17
|
+
`{"operations": [...Operation dicts, exactly what `apigen_python.extractor
|
|
18
|
+
--emit-json` emits...], "routes": {"<opId>": {"route": "<path>", "verb":
|
|
19
|
+
"GET"|"POST"}}}`, produced by the TS `py-flask` plugin's two-phase spawn:
|
|
20
|
+
(1) spawn `apigen_python.extractor --emit-json` in a short-lived process
|
|
21
|
+
to get `Operation[]`, (2) call the REAL `@adhd/apigen-engine-naming`
|
|
22
|
+
`project(op).http` on each op — the SAME canonical projector every other
|
|
23
|
+
transport (fastify/express/mcp/cli) uses — (3) spawn THIS server with the
|
|
24
|
+
result. See `packages/apigen/apigen-plugin-py-flask/src/lib/plugin.ts`.
|
|
25
|
+
|
|
26
|
+
Route contract (BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001 — byte-identical to
|
|
27
|
+
`@adhd/apigen-engine-naming`'s `project(op).http`, the SAME derivation used by the
|
|
28
|
+
`openapi` mount plugin):
|
|
29
|
+
POST <route> body: {"data": {<param>: <value>, …}}
|
|
30
|
+
GET /_meta/health → {"status": "ok", "host": "<ns>"}
|
|
31
|
+
|
|
32
|
+
<route>/<verb> are computed ONCE, TS-side, by the REAL `project()` and
|
|
33
|
+
injected via `--plan-file` (see "Usage" above) — this module no longer
|
|
34
|
+
contains a Python port of that algorithm. Previously (pre apigen-serve-
|
|
35
|
+
core py-flask-serve-split) `_route_for_op()`/`_http_verb()`/
|
|
36
|
+
`_is_primitive_only_input_schema()` reimplemented `project()`'s formula
|
|
37
|
+
byte-for-byte against the Segment `words` the extractor produces, because
|
|
38
|
+
Python cannot import the TS package and there was no IPC channel to carry
|
|
39
|
+
TS-computed values across the process boundary. That channel now exists
|
|
40
|
+
(the two-phase extractor `--emit-json` → `project()` → `--plan-file`
|
|
41
|
+
pipeline), so the Python re-derivation was deleted rather than kept as a
|
|
42
|
+
redundant fallback — one algorithm, one place, byte-identical by
|
|
43
|
+
construction instead of by manual synchronization.
|
|
44
|
+
|
|
45
|
+
Validation:
|
|
46
|
+
Input is validated BEFORE dispatch. Malformed input → HTTP 400
|
|
47
|
+
{"code": "invalid_argument", "message": "…"}.
|
|
48
|
+
|
|
49
|
+
Logical types:
|
|
50
|
+
Parameters whose schema carries ``format: "decimal"`` are decoded from their
|
|
51
|
+
decimal-string wire form into ``decimal.Decimal`` before dispatch. Return
|
|
52
|
+
values are encoded back to their wire forms (datetime → RFC 3339 string,
|
|
53
|
+
Decimal → decimal string, etc.) using ``apigen_logical.encode_value``.
|
|
54
|
+
|
|
55
|
+
Envelope:
|
|
56
|
+
``x-adhd-<field>`` request headers are read and forwarded to the function's
|
|
57
|
+
``ctx`` parameter (if present) as a dict, matching the §9.1 binding table.
|
|
58
|
+
|
|
59
|
+
Implementation note:
|
|
60
|
+
Flask is not required. This module uses Python's stdlib
|
|
61
|
+
``http.server.BaseHTTPRequestHandler`` with a ``ThreadingHTTPServer`` so
|
|
62
|
+
each request runs in its own thread (for sync fns) or via asyncio
|
|
63
|
+
(for async fns). Flask is listed as an optional dependency in
|
|
64
|
+
``pyproject.toml``; when installed it is NOT used here (the stdlib
|
|
65
|
+
implementation is production-ready for the apigen use-case).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import argparse
|
|
71
|
+
import importlib.util
|
|
72
|
+
import json
|
|
73
|
+
import sys
|
|
74
|
+
import threading
|
|
75
|
+
import urllib.parse
|
|
76
|
+
from datetime import datetime
|
|
77
|
+
from decimal import Decimal
|
|
78
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
from typing import Any
|
|
81
|
+
from uuid import UUID
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Bootstrap: make the package importable when run as __main__ from the
|
|
85
|
+
# repo root (python3 -m apigen_python.flask_server requires the package on
|
|
86
|
+
# sys.path, which is handled by -m; but for PYTHONPATH-based invocations we
|
|
87
|
+
# also accept the parent directory).
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
_THIS_DIR = Path(__file__).resolve().parent.parent
|
|
91
|
+
if str(_THIS_DIR) not in sys.path:
|
|
92
|
+
sys.path.insert(0, str(_THIS_DIR))
|
|
93
|
+
|
|
94
|
+
from apigen_python.errors import ApiError, HTTP_STATUS # noqa: E402
|
|
95
|
+
from apigen_python.runtime import HostRequest, Runtime # noqa: E402
|
|
96
|
+
from apigen_python.validator import validate, ValidationError # noqa: E402
|
|
97
|
+
|
|
98
|
+
# apigen_logical lives one level up from the package (repo layout):
|
|
99
|
+
# packages/apigen/python/apigen_logical.py
|
|
100
|
+
try:
|
|
101
|
+
import apigen_logical # type: ignore[import]
|
|
102
|
+
_HAS_LOGICAL = True
|
|
103
|
+
except ImportError:
|
|
104
|
+
_HAS_LOGICAL = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Logical-type decode helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _decode_with_schema(value: Any, schema: dict[str, Any]) -> Any:
|
|
112
|
+
"""Schema-driven decode of a single wire value using apigen_logical.
|
|
113
|
+
|
|
114
|
+
Falls back to identity when apigen_logical is not on sys.path.
|
|
115
|
+
"""
|
|
116
|
+
if not _HAS_LOGICAL or not schema:
|
|
117
|
+
return value
|
|
118
|
+
return apigen_logical.decode(value, schema)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _encode_result(value: Any) -> Any:
|
|
122
|
+
"""Encode a native Python return value to its canonical wire form.
|
|
123
|
+
|
|
124
|
+
Uses apigen_logical.encode_value when available, otherwise handles the
|
|
125
|
+
most common types (datetime, Decimal, UUID, bytes) directly.
|
|
126
|
+
"""
|
|
127
|
+
if _HAS_LOGICAL:
|
|
128
|
+
return apigen_logical.encode_value(value)
|
|
129
|
+
# Fallback: handle the common cases so the server is usable without
|
|
130
|
+
# apigen_logical on the path.
|
|
131
|
+
if isinstance(value, datetime):
|
|
132
|
+
from datetime import timezone
|
|
133
|
+
if value.tzinfo is not None:
|
|
134
|
+
value = value.astimezone(timezone.utc)
|
|
135
|
+
else:
|
|
136
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
137
|
+
base = value.strftime("%Y-%m-%dT%H:%M:%S")
|
|
138
|
+
ms = value.microsecond // 1000
|
|
139
|
+
return f"{base}.{ms:03d}Z"
|
|
140
|
+
if isinstance(value, Decimal):
|
|
141
|
+
return str(value)
|
|
142
|
+
if isinstance(value, UUID):
|
|
143
|
+
return str(value).lower()
|
|
144
|
+
if isinstance(value, (bytes, bytearray)):
|
|
145
|
+
import base64
|
|
146
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
return {k: _encode_result(v) for k, v in value.items()}
|
|
149
|
+
if isinstance(value, list):
|
|
150
|
+
return [_encode_result(v) for v in value]
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Schema-driven parameter decode (apply logical types to each input param)
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _decode_params(data: dict[str, Any], input_schema: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
"""Apply schema-driven decode to each parameter in the incoming data dict.
|
|
160
|
+
|
|
161
|
+
Only runs for schemas that carry ``format`` annotations (date-time,
|
|
162
|
+
decimal, etc.). Plain string/int/bool params pass through unchanged.
|
|
163
|
+
"""
|
|
164
|
+
if not _HAS_LOGICAL or not input_schema:
|
|
165
|
+
return data
|
|
166
|
+
props: dict[str, Any] = input_schema.get("properties", {})
|
|
167
|
+
if not props:
|
|
168
|
+
return data
|
|
169
|
+
decoded: dict[str, Any] = {}
|
|
170
|
+
for key, val in data.items():
|
|
171
|
+
prop_schema = props.get(key, {})
|
|
172
|
+
decoded[key] = apigen_logical.decode(val, prop_schema)
|
|
173
|
+
return decoded
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Envelope extraction from request headers (§9.1)
|
|
178
|
+
#
|
|
179
|
+
# Two complementary strategies are combined:
|
|
180
|
+
#
|
|
181
|
+
# 1. Schema-driven (mirrors api-fastify): for each non-'data' field declared
|
|
182
|
+
# in the input schema's top-level properties, read the header named by
|
|
183
|
+
# the §9.1 binding (x-<pluginId>-<field>). This is the TS-side contract.
|
|
184
|
+
#
|
|
185
|
+
# 2. Direct x-adhd-* header scan: in Python, the 'ctx' first-parameter is
|
|
186
|
+
# stripped from the input schema by the extractor (§4 inv:ctx-name-only),
|
|
187
|
+
# so there is no schema field to match against. Instead we read ALL
|
|
188
|
+
# x-adhd-<field> headers and include them in the envelope. The runtime's
|
|
189
|
+
# _build_kwargs() already knows to pass the entire envelope dict to the
|
|
190
|
+
# function's 'ctx' parameter.
|
|
191
|
+
#
|
|
192
|
+
# The two strategies are merged (schema-driven wins on key conflicts).
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
_X_ADHD_PREFIX = "x-adhd-"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _extract_envelope(
|
|
199
|
+
input_schema: dict[str, Any],
|
|
200
|
+
headers: dict[str, str],
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""Extract envelope values from request headers per §9.1.
|
|
203
|
+
|
|
204
|
+
Combines schema-driven extraction (for TS-style envelope fields declared
|
|
205
|
+
in the schema) with a direct scan of all ``x-adhd-*`` headers (for
|
|
206
|
+
Python ``ctx`` parameters whose schema entry is stripped by the extractor).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
input_schema: The operation's top-level input schema (with 'properties').
|
|
210
|
+
headers: The request headers (lowercased keys -> value strings).
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Dict of envelope field name -> header value for all matched fields.
|
|
214
|
+
"""
|
|
215
|
+
envelope: dict[str, Any] = {}
|
|
216
|
+
|
|
217
|
+
# Strategy 1: collect ALL x-adhd-* headers directly.
|
|
218
|
+
# This ensures `ctx` functions receive headers even when no schema field
|
|
219
|
+
# declares them (the extractor strips 'ctx' from input properties).
|
|
220
|
+
for key, value in headers.items():
|
|
221
|
+
if key.startswith(_X_ADHD_PREFIX):
|
|
222
|
+
field = key[len(_X_ADHD_PREFIX):]
|
|
223
|
+
if field:
|
|
224
|
+
envelope[field] = value
|
|
225
|
+
|
|
226
|
+
# Strategy 2: schema-driven extraction for non-'data' declared properties
|
|
227
|
+
# (mirrors api-fastify's extractEnvelopeFromHeaders). May add fields for
|
|
228
|
+
# envelope fields declared by the schema under a non-'adhd' pluginId.
|
|
229
|
+
props: dict[str, Any] = input_schema.get("properties", {})
|
|
230
|
+
meta: dict[str, str] = input_schema.get("x-apigen-envelope", {})
|
|
231
|
+
for field in props:
|
|
232
|
+
if field == "data":
|
|
233
|
+
continue
|
|
234
|
+
plugin_id = meta.get(field, "adhd")
|
|
235
|
+
if plugin_id == "adhd":
|
|
236
|
+
# Already covered by Strategy 1 above.
|
|
237
|
+
continue
|
|
238
|
+
header_name = f"x-{plugin_id}-{field}"
|
|
239
|
+
value = headers.get(header_name)
|
|
240
|
+
if value is not None:
|
|
241
|
+
envelope[field] = value
|
|
242
|
+
|
|
243
|
+
return envelope
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# JSON serialisation helper — handles non-standard types gracefully
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _json_dumps(obj: Any) -> str:
|
|
251
|
+
"""Serialise obj to JSON, using ApigenEncoder if available."""
|
|
252
|
+
if _HAS_LOGICAL:
|
|
253
|
+
return json.dumps(obj, cls=apigen_logical.ApigenEncoder)
|
|
254
|
+
return json.dumps(obj)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Server state — built once on startup, shared across request threads
|
|
259
|
+
#
|
|
260
|
+
# apigen-serve-core py-flask-serve-split: route + verb are no longer derived
|
|
261
|
+
# here. `_route_for_op()` / `_http_verb()` / `_is_primitive_only_input_schema()`
|
|
262
|
+
# (formerly here, a hand-maintained Python port of `@adhd/apigen-engine-naming`'s
|
|
263
|
+
# `project(op).http`) are DELETED — see the module docstring's "Route
|
|
264
|
+
# contract" section. `routes` is the TS-computed `{opId: {route, verb}}` map
|
|
265
|
+
# injected via `--plan-file`.
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
class _RouteEntry:
|
|
269
|
+
"""One resolved route: the operation it dispatches to + its TS-computed verb."""
|
|
270
|
+
|
|
271
|
+
__slots__ = ("op", "verb")
|
|
272
|
+
|
|
273
|
+
def __init__(self, op: dict[str, Any], verb: str) -> None:
|
|
274
|
+
self.op = op
|
|
275
|
+
self.verb = verb
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _ServerState:
|
|
279
|
+
"""Immutable server configuration shared across all request threads."""
|
|
280
|
+
|
|
281
|
+
__slots__ = ("namespace", "runtime", "operations", "route_map")
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
namespace: str,
|
|
286
|
+
runtime: Runtime,
|
|
287
|
+
operations: list[dict[str, Any]],
|
|
288
|
+
routes: dict[str, dict[str, str]],
|
|
289
|
+
) -> None:
|
|
290
|
+
self.namespace = namespace
|
|
291
|
+
self.runtime = runtime
|
|
292
|
+
self.operations = operations
|
|
293
|
+
# Map canonical HTTP route → _RouteEntry(op, verb), for O(1) request
|
|
294
|
+
# dispatch. `routes[op["id"]]` supplies BOTH the route and the verb,
|
|
295
|
+
# computed TS-side by the REAL `@adhd/apigen-engine-naming` `project()`
|
|
296
|
+
# (BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001) — not re-derived here.
|
|
297
|
+
# The operation's `input` dict IS the inner param schema directly (no
|
|
298
|
+
# data-wrapper in the extractor's output — that wrapper is a TS-side
|
|
299
|
+
# composition artifact; the Python runtime receives bare params), so
|
|
300
|
+
# no separate schema map is needed alongside this.
|
|
301
|
+
self.route_map: dict[str, _RouteEntry] = {}
|
|
302
|
+
for op in operations:
|
|
303
|
+
entry = routes.get(op["id"])
|
|
304
|
+
if entry is None:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"apigen-py-flask: --plan-file is missing a routes entry "
|
|
307
|
+
f"for operation id {op['id']!r} — the injected plan must "
|
|
308
|
+
f"cover every operation extract_module() (phase 1) "
|
|
309
|
+
f"produced, 1:1."
|
|
310
|
+
)
|
|
311
|
+
self.route_map[entry["route"]] = _RouteEntry(op=op, verb=entry["verb"])
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# HTTP request handler
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
class _ApigenHandler(BaseHTTPRequestHandler):
|
|
319
|
+
"""HTTP handler wired to a _ServerState via the server's `state` attribute."""
|
|
320
|
+
|
|
321
|
+
# Suppress the default request log lines; we print our own.
|
|
322
|
+
def log_message(self, _fmt: str, *_args: Any) -> None: # noqa: ANN001
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
def log_error(self, _fmt: str, *_args: Any) -> None: # noqa: ANN001
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Route: GET /_meta/health
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def _handle_health(self) -> None:
|
|
333
|
+
ns = self.server.state.namespace # type: ignore[attr-defined]
|
|
334
|
+
body = _json_dumps({"status": "ok", "host": ns}).encode()
|
|
335
|
+
self._send_json(200, body)
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
# Route: GET <route> (safe / primitive-only-input ops — query-string params)
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _handle_safe_get(self, op: dict[str, Any]) -> None:
|
|
342
|
+
# Parse query-string → data dict
|
|
343
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
344
|
+
qs = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
|
345
|
+
# Flatten single-value lists (query-string repeats are unusual for APIs)
|
|
346
|
+
data: dict[str, Any] = {k: v[0] if len(v) == 1 else v for k, v in qs.items()}
|
|
347
|
+
|
|
348
|
+
self._dispatch(op, data, envelope_from_headers=True)
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
# Route: POST <route>
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _handle_post(self, op: dict[str, Any]) -> None:
|
|
355
|
+
# Read body
|
|
356
|
+
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
|
357
|
+
body_bytes = self.rfile.read(content_length) if content_length > 0 else b"{}"
|
|
358
|
+
try:
|
|
359
|
+
body = json.loads(body_bytes or b"{}")
|
|
360
|
+
except json.JSONDecodeError as exc:
|
|
361
|
+
self._send_error(400, "invalid_argument", f"JSON parse error: {exc}")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
if not isinstance(body, dict):
|
|
365
|
+
self._send_error(400, "invalid_argument", "request body must be a JSON object")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
data = body.get("data", {})
|
|
369
|
+
if not isinstance(data, dict):
|
|
370
|
+
self._send_error(400, "invalid_argument", '"data" must be a JSON object')
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
self._dispatch(op, data, envelope_from_headers=True)
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
# Dispatch (shared between GET/POST paths)
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def _dispatch(
|
|
380
|
+
self,
|
|
381
|
+
op: dict[str, Any],
|
|
382
|
+
data: dict[str, Any],
|
|
383
|
+
*,
|
|
384
|
+
envelope_from_headers: bool,
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Validate input → decode logical types → invoke → encode result → respond."""
|
|
387
|
+
state: _ServerState = self.server.state # type: ignore[attr-defined]
|
|
388
|
+
|
|
389
|
+
# The operation's `input` IS the inner param schema (no data-wrapper —
|
|
390
|
+
# see _ServerState.route_map's doc comment).
|
|
391
|
+
input_schema: dict[str, Any] = op.get("input", {})
|
|
392
|
+
|
|
393
|
+
# §9.1 envelope from headers
|
|
394
|
+
envelope: dict[str, Any] = {}
|
|
395
|
+
if envelope_from_headers:
|
|
396
|
+
headers_lc = {k.lower(): v for k, v in self.headers.items()}
|
|
397
|
+
envelope = _extract_envelope(input_schema, headers_lc)
|
|
398
|
+
|
|
399
|
+
# SPEC §6: validate BEFORE dispatch (malformed → 400 before fn is called)
|
|
400
|
+
if input_schema:
|
|
401
|
+
try:
|
|
402
|
+
validate(input_schema, data)
|
|
403
|
+
except ValidationError as exc:
|
|
404
|
+
self._send_error(400, "invalid_argument", f"input validation failed: {exc}")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
# Schema-driven decode for logical types (Decimal, datetime, etc.)
|
|
408
|
+
decoded_data = _decode_params(data, input_schema)
|
|
409
|
+
|
|
410
|
+
# Build the HostRequest for the runtime.
|
|
411
|
+
# pre_validated=True tells the runtime not to re-validate the data
|
|
412
|
+
# (which now contains decoded native values like Decimal/datetime) against
|
|
413
|
+
# the wire-schema — we already validated the raw wire data above.
|
|
414
|
+
host_req = HostRequest(
|
|
415
|
+
operation=op,
|
|
416
|
+
data=decoded_data,
|
|
417
|
+
envelope=envelope,
|
|
418
|
+
transport="http",
|
|
419
|
+
pre_validated=True,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
result = state.runtime.invoke_sync(host_req)
|
|
424
|
+
except ApiError as exc:
|
|
425
|
+
status = HTTP_STATUS.get(exc.code, 500)
|
|
426
|
+
self._send_error(status, exc.code, exc.message)
|
|
427
|
+
return
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
self._send_error(500, "internal", f"dispatch error: {exc}")
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
# Encode the result to its canonical wire form
|
|
433
|
+
wire_result = _encode_result(result)
|
|
434
|
+
|
|
435
|
+
body = _json_dumps(wire_result).encode()
|
|
436
|
+
self._send_json(200, body)
|
|
437
|
+
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
# Routing
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def do_GET(self) -> None: # noqa: N802
|
|
443
|
+
"""Handle GET requests: health endpoint + GET-eligible operations.
|
|
444
|
+
|
|
445
|
+
Routing is a direct lookup of the full request path against
|
|
446
|
+
`state.route_map` (BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001) — the
|
|
447
|
+
canonical, kebab-cased, multi-segment route computed by
|
|
448
|
+
`_route_for_op()`, NOT a `/<ns>/<lastSegmentRaw>` prefix strip. This is
|
|
449
|
+
what makes a multi-segment op (namespace + file segment + export
|
|
450
|
+
segment, e.g. `/testapi/test-api/echo-str`) resolve at all — the old
|
|
451
|
+
prefix-strip approach silently dropped every segment but the last.
|
|
452
|
+
"""
|
|
453
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
454
|
+
path_only = parsed.path
|
|
455
|
+
|
|
456
|
+
if path_only == "/_meta/health":
|
|
457
|
+
self._handle_health()
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
state: _ServerState = self.server.state # type: ignore[attr-defined]
|
|
461
|
+
entry = state.route_map.get(path_only)
|
|
462
|
+
if entry is not None:
|
|
463
|
+
if entry.verb == "GET":
|
|
464
|
+
self._handle_safe_get(entry.op)
|
|
465
|
+
return
|
|
466
|
+
# Known route but wrong verb (POST-only operation, per the
|
|
467
|
+
# TS-computed --plan-file verb — see _ServerState's doc comment).
|
|
468
|
+
self._send_error(405, "invalid_argument", f"use POST for {path_only}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
self._send_error(404, "not_found", f"no route for GET {path_only}")
|
|
472
|
+
|
|
473
|
+
def do_POST(self) -> None: # noqa: N802
|
|
474
|
+
"""Handle POST requests: function dispatch via the canonical route map
|
|
475
|
+
(see `do_GET`'s doc comment for why this is a full-path lookup, not a
|
|
476
|
+
prefix strip)."""
|
|
477
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
478
|
+
path_only = parsed.path
|
|
479
|
+
|
|
480
|
+
state: _ServerState = self.server.state # type: ignore[attr-defined]
|
|
481
|
+
entry = state.route_map.get(path_only)
|
|
482
|
+
if entry is not None:
|
|
483
|
+
self._handle_post(entry.op)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
self._send_error(404, "not_found", f"no route for POST {path_only}")
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Response helpers
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def _send_json(self, status: int, body: bytes) -> None:
|
|
493
|
+
self.send_response(status)
|
|
494
|
+
self.send_header("Content-Type", "application/json")
|
|
495
|
+
self.send_header("Content-Length", str(len(body)))
|
|
496
|
+
self.end_headers()
|
|
497
|
+
self.wfile.write(body)
|
|
498
|
+
|
|
499
|
+
def _send_error(self, status: int, code: str, message: str) -> None:
|
|
500
|
+
body = _json_dumps({"code": code, "message": message}).encode()
|
|
501
|
+
self._send_json(status, body)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
# Server lifecycle
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
class ApigenFlaskServer:
|
|
509
|
+
"""Thin wrapper around ThreadingHTTPServer for lifecycle management.
|
|
510
|
+
|
|
511
|
+
Named 'Flask' in the public API for consistency with the deliverable spec,
|
|
512
|
+
but implemented with stdlib ``http.server`` (Flask is an optional dep).
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(
|
|
516
|
+
self,
|
|
517
|
+
module_path: str,
|
|
518
|
+
namespace: str,
|
|
519
|
+
plan: dict[str, Any],
|
|
520
|
+
host: str = "127.0.0.1",
|
|
521
|
+
port: int = 8000,
|
|
522
|
+
) -> None:
|
|
523
|
+
self._module_path = module_path
|
|
524
|
+
self._namespace = namespace
|
|
525
|
+
self._plan = plan
|
|
526
|
+
self._host = host
|
|
527
|
+
self._port = port
|
|
528
|
+
self._httpd: ThreadingHTTPServer | None = None
|
|
529
|
+
self._thread: threading.Thread | None = None
|
|
530
|
+
|
|
531
|
+
def _build_state(self) -> _ServerState:
|
|
532
|
+
"""Build the server state from the INJECTED `--plan-file` — this
|
|
533
|
+
server no longer self-extracts (apigen-serve-core py-flask-serve-
|
|
534
|
+
split). `self._plan["operations"]` supplies the full Operation[]
|
|
535
|
+
(schema-bearing dicts, produced by `apigen_python.extractor
|
|
536
|
+
--emit-json` in a separate, short-lived process — see plugin.ts's
|
|
537
|
+
two-phase spawn) and `self._plan["routes"]` supplies the per-op
|
|
538
|
+
canonical route/verb, computed by the REAL `@adhd/apigen-engine-naming`
|
|
539
|
+
`project()` — never re-derived here.
|
|
540
|
+
|
|
541
|
+
The module is still loaded a SECOND time, independently, right here —
|
|
542
|
+
that load's only job is producing LIVE function references for the
|
|
543
|
+
runtime registry, which cannot cross a process boundary as data
|
|
544
|
+
(Python callables are not serialisable). This is unchanged from the
|
|
545
|
+
pre-split design and was confirmed safe by
|
|
546
|
+
`docs/apigen/proposals/py-extract-serve-split-findings.md` §1.2/§1.4.
|
|
547
|
+
"""
|
|
548
|
+
ops: list[dict[str, Any]] = self._plan["operations"]
|
|
549
|
+
routes: dict[str, dict[str, str]] = self._plan["routes"]
|
|
550
|
+
if not ops:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"--plan-file for {self._module_path!r} carries zero "
|
|
553
|
+
"operations. Ensure the module has public callable exports."
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Load the module to get live function references.
|
|
557
|
+
# Register in sys.modules BEFORE exec_module (canonical importlib pattern)
|
|
558
|
+
# so dataclasses._is_type and typing.get_type_hints work correctly for
|
|
559
|
+
# modules that use `from __future__ import annotations` (PEP 563).
|
|
560
|
+
path = Path(self._module_path).resolve()
|
|
561
|
+
spec = importlib.util.spec_from_file_location("_apigen_flask_module_", str(path))
|
|
562
|
+
if spec is None or spec.loader is None:
|
|
563
|
+
raise ImportError(f"Cannot load module from {path}")
|
|
564
|
+
mod = importlib.util.module_from_spec(spec)
|
|
565
|
+
import sys as _sys
|
|
566
|
+
_sys.modules[spec.name] = mod
|
|
567
|
+
try:
|
|
568
|
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
569
|
+
except Exception:
|
|
570
|
+
_sys.modules.pop(spec.name, None)
|
|
571
|
+
raise
|
|
572
|
+
|
|
573
|
+
# Build fn_registry: op_id → callable
|
|
574
|
+
registry: dict[str, Any] = {}
|
|
575
|
+
for op in ops:
|
|
576
|
+
fn_name = op["path"][-1]["raw"]
|
|
577
|
+
fn = getattr(mod, fn_name, None)
|
|
578
|
+
if callable(fn):
|
|
579
|
+
registry[op["id"]] = fn
|
|
580
|
+
|
|
581
|
+
runtime = Runtime(registry)
|
|
582
|
+
return _ServerState(
|
|
583
|
+
namespace=self._namespace,
|
|
584
|
+
runtime=runtime,
|
|
585
|
+
operations=ops,
|
|
586
|
+
routes=routes,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def start(self) -> None:
|
|
590
|
+
"""Build state and start the HTTP server in a background thread."""
|
|
591
|
+
state = self._build_state()
|
|
592
|
+
|
|
593
|
+
class _Server(ThreadingHTTPServer):
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
httpd = _Server((self._host, self._port), _ApigenHandler)
|
|
597
|
+
httpd.state = state # type: ignore[attr-defined]
|
|
598
|
+
self._httpd = httpd
|
|
599
|
+
|
|
600
|
+
# BUG-APIGEN-TEST-FIXED-PORT-COLLISION-001: bind() already happened
|
|
601
|
+
# synchronously in the `_Server(...)` constructor above (TCPServer.
|
|
602
|
+
# __init__ calls server_bind()+server_activate() before returning), so
|
|
603
|
+
# the OS has already resolved `port=0` to a real ephemeral port by
|
|
604
|
+
# this point. Read the actual bound port back so callers that asked
|
|
605
|
+
# for port 0 can learn what they actually got — self._port is the
|
|
606
|
+
# single source of truth from here on (used by the log line below and
|
|
607
|
+
# by serve_forever()'s readiness signal).
|
|
608
|
+
self._port = httpd.socket.getsockname()[1]
|
|
609
|
+
|
|
610
|
+
# Log the registered routes to stderr — canonical route/verb, TS-
|
|
611
|
+
# computed via the injected --plan-file (BUG-APIGEN-OPENAPI-ROUTE-
|
|
612
|
+
# PATH-MISMATCH-001), not re-derived here.
|
|
613
|
+
print(f"apigen-py-flask listening on http://{self._host}:{self._port}", file=sys.stderr)
|
|
614
|
+
print(f" GET /_meta/health", file=sys.stderr)
|
|
615
|
+
for route, entry in state.route_map.items():
|
|
616
|
+
print(f" {entry.verb:<4} {route}", file=sys.stderr)
|
|
617
|
+
sys.stderr.flush()
|
|
618
|
+
|
|
619
|
+
self._thread = threading.Thread(
|
|
620
|
+
target=httpd.serve_forever,
|
|
621
|
+
daemon=True,
|
|
622
|
+
name="apigen-flask-http",
|
|
623
|
+
)
|
|
624
|
+
self._thread.start()
|
|
625
|
+
|
|
626
|
+
def stop(self) -> None:
|
|
627
|
+
"""Shutdown the HTTP server."""
|
|
628
|
+
if self._httpd is not None:
|
|
629
|
+
self._httpd.shutdown()
|
|
630
|
+
self._httpd = None
|
|
631
|
+
|
|
632
|
+
def serve_forever(self) -> None:
|
|
633
|
+
"""Block until interrupted (Ctrl-C / SIGTERM).
|
|
634
|
+
|
|
635
|
+
Prints the §13.1-compatible readiness signal ``{"ready": true, "port":
|
|
636
|
+
<n>}`` to stdout immediately after the server thread starts, so the TS
|
|
637
|
+
plugin subprocess launcher can poll for it. ``port`` is the ACTUAL
|
|
638
|
+
bound port (BUG-APIGEN-TEST-FIXED-PORT-COLLISION-001) — identical to
|
|
639
|
+
the requested ``--port`` unless 0 (OS-assigned ephemeral) was passed,
|
|
640
|
+
in which case this is the only way the caller learns the real port.
|
|
641
|
+
"""
|
|
642
|
+
self.start()
|
|
643
|
+
# §13.1 readiness signal — TS launcher polls for this line on stdout
|
|
644
|
+
print(json.dumps({"ready": True, "port": self._port}), flush=True)
|
|
645
|
+
try:
|
|
646
|
+
# Block main thread while the daemon thread serves
|
|
647
|
+
if self._thread is not None:
|
|
648
|
+
self._thread.join()
|
|
649
|
+
except KeyboardInterrupt:
|
|
650
|
+
pass
|
|
651
|
+
finally:
|
|
652
|
+
self.stop()
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
# Public convenience: build_server()
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
def build_server(
|
|
660
|
+
module_path: str,
|
|
661
|
+
namespace: str,
|
|
662
|
+
plan: dict[str, Any],
|
|
663
|
+
host: str = "127.0.0.1",
|
|
664
|
+
port: int = 8000,
|
|
665
|
+
) -> ApigenFlaskServer:
|
|
666
|
+
"""Construct an :class:`ApigenFlaskServer` for the given module.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
module_path: Path to the ``.py`` source file (absolute or relative).
|
|
670
|
+
namespace: The apigen namespace slug (used as the route prefix).
|
|
671
|
+
plan: The TS-computed serve plan (`{"operations": [...],
|
|
672
|
+
"routes": {"<opId>": {"route": str, "verb": "GET"|"POST"}}}`)
|
|
673
|
+
— see the module docstring's "Usage" section.
|
|
674
|
+
host: Bind address (default ``127.0.0.1``).
|
|
675
|
+
port: TCP port (default ``8000``).
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
An :class:`ApigenFlaskServer` instance ready to call ``.start()`` or
|
|
679
|
+
``.serve_forever()``.
|
|
680
|
+
"""
|
|
681
|
+
return ApigenFlaskServer(module_path, namespace, plan, host=host, port=port)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ---------------------------------------------------------------------------
|
|
685
|
+
# CLI entry point
|
|
686
|
+
# ---------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
def _main() -> None:
|
|
689
|
+
parser = argparse.ArgumentParser(
|
|
690
|
+
description=(
|
|
691
|
+
"apigen Python HTTP server — serves a .py module over HTTP.\n\n"
|
|
692
|
+
"Routes (TS-computed via @adhd/apigen-engine-naming's project().http and\n"
|
|
693
|
+
"injected via --plan-file — BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001 /\n"
|
|
694
|
+
"apigen-serve-core py-flask-serve-split):\n"
|
|
695
|
+
" GET /_meta/health → {status, host}\n"
|
|
696
|
+
" POST <route> → body {data: {<params>}} → result\n"
|
|
697
|
+
" GET <route> → query-string (per the injected plan's verb)\n"
|
|
698
|
+
" <route> = /<kebab-namespace>/<kebab-pathSeg>/… (multi-segment)\n\n"
|
|
699
|
+
"Startup: emits {ready: true} on stdout once the server is up."
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
parser.add_argument(
|
|
703
|
+
"--module", required=True,
|
|
704
|
+
help="Path to the Python source module to serve"
|
|
705
|
+
)
|
|
706
|
+
parser.add_argument(
|
|
707
|
+
"--namespace", required=True,
|
|
708
|
+
help="Namespace slug used as the URL prefix (e.g. 'myapi')"
|
|
709
|
+
)
|
|
710
|
+
parser.add_argument(
|
|
711
|
+
"--host", default="127.0.0.1",
|
|
712
|
+
help="Bind host (default: 127.0.0.1)"
|
|
713
|
+
)
|
|
714
|
+
parser.add_argument(
|
|
715
|
+
"--port", type=int, default=8000,
|
|
716
|
+
help="TCP port (default: 8000; pass 0 to bind an OS-assigned "
|
|
717
|
+
"ephemeral port — the actual bound port is reported back in "
|
|
718
|
+
"the {ready: true, port: <n>} stdout line)"
|
|
719
|
+
)
|
|
720
|
+
parser.add_argument(
|
|
721
|
+
"--plan-file", required=True,
|
|
722
|
+
help=(
|
|
723
|
+
"Path to a JSON file: {\"operations\": [...Operation dicts, exactly "
|
|
724
|
+
"apigen_python.extractor --emit-json's output...], \"routes\": "
|
|
725
|
+
"{\"<opId>\": {\"route\": \"<path>\", \"verb\": \"GET\"|\"POST\"}}}. "
|
|
726
|
+
"REQUIRED — this server no longer self-extracts or re-derives "
|
|
727
|
+
"route/verb (apigen-serve-core py-flask-serve-split); produced by "
|
|
728
|
+
"the py-flask TS plugin's two-phase spawn (extractor --emit-json "
|
|
729
|
+
"-> @adhd/apigen-engine-naming project() -> this flag)."
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
args = parser.parse_args()
|
|
733
|
+
|
|
734
|
+
with open(args.plan_file, "r", encoding="utf-8") as plan_fh:
|
|
735
|
+
plan = json.load(plan_fh)
|
|
736
|
+
|
|
737
|
+
server = build_server(
|
|
738
|
+
module_path=args.module,
|
|
739
|
+
namespace=args.namespace,
|
|
740
|
+
plan=plan,
|
|
741
|
+
host=args.host,
|
|
742
|
+
port=args.port,
|
|
743
|
+
)
|
|
744
|
+
server.serve_forever()
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
if __name__ == "__main__":
|
|
748
|
+
_main()
|