@adhd/apigen-python-env 0.2.0 → 0.2.2

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,1052 @@
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 concurrent.futures import ThreadPoolExecutor
77
+ from datetime import datetime
78
+ from decimal import Decimal
79
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
80
+ from pathlib import Path
81
+ from typing import Any
82
+ from uuid import UUID
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Bootstrap: make the package importable when run as __main__ from the
86
+ # repo root (python3 -m apigen_python.flask_server requires the package on
87
+ # sys.path, which is handled by -m; but for PYTHONPATH-based invocations we
88
+ # also accept the parent directory).
89
+ # ---------------------------------------------------------------------------
90
+
91
+ _THIS_DIR = Path(__file__).resolve().parent.parent
92
+ if str(_THIS_DIR) not in sys.path:
93
+ sys.path.insert(0, str(_THIS_DIR))
94
+
95
+ from apigen_python.errors import ApiError, HTTP_STATUS # noqa: E402
96
+ from apigen_python.runtime import HostRequest, Runtime # noqa: E402
97
+ from apigen_python.validator import validate, ValidationError # noqa: E402
98
+
99
+ # apigen_logical lives one level up from the package (repo layout):
100
+ # packages/apigen/python/apigen_logical.py
101
+ try:
102
+ import apigen_logical # type: ignore[import]
103
+ _HAS_LOGICAL = True
104
+ except ImportError:
105
+ _HAS_LOGICAL = False
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Logical-type decode helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _decode_with_schema(value: Any, schema: dict[str, Any]) -> Any:
113
+ """Schema-driven decode of a single wire value using apigen_logical.
114
+
115
+ Falls back to identity when apigen_logical is not on sys.path.
116
+ """
117
+ if not _HAS_LOGICAL or not schema:
118
+ return value
119
+ return apigen_logical.decode(value, schema)
120
+
121
+
122
+ def _encode_result(value: Any) -> Any:
123
+ """Encode a native Python return value to its canonical wire form.
124
+
125
+ Uses apigen_logical.encode_value when available, otherwise handles the
126
+ most common types (datetime, Decimal, UUID, bytes) directly.
127
+ """
128
+ if _HAS_LOGICAL:
129
+ return apigen_logical.encode_value(value)
130
+ # Fallback: handle the common cases so the server is usable without
131
+ # apigen_logical on the path.
132
+ if isinstance(value, datetime):
133
+ from datetime import timezone
134
+ if value.tzinfo is not None:
135
+ value = value.astimezone(timezone.utc)
136
+ else:
137
+ value = value.replace(tzinfo=timezone.utc)
138
+ base = value.strftime("%Y-%m-%dT%H:%M:%S")
139
+ ms = value.microsecond // 1000
140
+ return f"{base}.{ms:03d}Z"
141
+ if isinstance(value, Decimal):
142
+ return str(value)
143
+ if isinstance(value, UUID):
144
+ return str(value).lower()
145
+ if isinstance(value, (bytes, bytearray)):
146
+ import base64
147
+ return base64.b64encode(bytes(value)).decode("ascii")
148
+ if isinstance(value, dict):
149
+ return {k: _encode_result(v) for k, v in value.items()}
150
+ if isinstance(value, list):
151
+ return [_encode_result(v) for v in value]
152
+ return value
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Schema-driven parameter decode (apply logical types to each input param)
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def _decode_params(data: dict[str, Any], input_schema: dict[str, Any]) -> dict[str, Any]:
160
+ """Apply schema-driven decode to each parameter in the incoming data dict.
161
+
162
+ Only runs for schemas that carry ``format`` annotations (date-time,
163
+ decimal, etc.). Plain string/int/bool params pass through unchanged.
164
+ """
165
+ if not _HAS_LOGICAL or not input_schema:
166
+ return data
167
+ props: dict[str, Any] = input_schema.get("properties", {})
168
+ if not props:
169
+ return data
170
+ decoded: dict[str, Any] = {}
171
+ for key, val in data.items():
172
+ prop_schema = props.get(key, {})
173
+ decoded[key] = apigen_logical.decode(val, prop_schema)
174
+ return decoded
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Envelope extraction from request headers (§9.1)
179
+ #
180
+ # Two complementary strategies are combined:
181
+ #
182
+ # 1. Schema-driven (mirrors api-fastify): for each non-'data' field declared
183
+ # in the input schema's top-level properties, read the header named by
184
+ # the §9.1 binding (x-<pluginId>-<field>). This is the TS-side contract.
185
+ #
186
+ # 2. Direct x-adhd-* header scan: in Python, the 'ctx' first-parameter is
187
+ # stripped from the input schema by the extractor (§4 inv:ctx-name-only),
188
+ # so there is no schema field to match against. Instead we read ALL
189
+ # x-adhd-<field> headers and include them in the envelope. The runtime's
190
+ # _build_kwargs() already knows to pass the entire envelope dict to the
191
+ # function's 'ctx' parameter.
192
+ #
193
+ # The two strategies are merged (schema-driven wins on key conflicts).
194
+ # ---------------------------------------------------------------------------
195
+
196
+ _X_ADHD_PREFIX = "x-adhd-"
197
+
198
+
199
+ def _extract_envelope(
200
+ input_schema: dict[str, Any],
201
+ headers: dict[str, str],
202
+ ) -> dict[str, Any]:
203
+ """Extract envelope values from request headers per §9.1.
204
+
205
+ Combines schema-driven extraction (for TS-style envelope fields declared
206
+ in the schema) with a direct scan of all ``x-adhd-*`` headers (for
207
+ Python ``ctx`` parameters whose schema entry is stripped by the extractor).
208
+
209
+ Args:
210
+ input_schema: The operation's top-level input schema (with 'properties').
211
+ headers: The request headers (lowercased keys -> value strings).
212
+
213
+ Returns:
214
+ Dict of envelope field name -> header value for all matched fields.
215
+ """
216
+ envelope: dict[str, Any] = {}
217
+
218
+ # Strategy 1: collect ALL x-adhd-* headers directly.
219
+ # This ensures `ctx` functions receive headers even when no schema field
220
+ # declares them (the extractor strips 'ctx' from input properties).
221
+ for key, value in headers.items():
222
+ if key.startswith(_X_ADHD_PREFIX):
223
+ field = key[len(_X_ADHD_PREFIX):]
224
+ if field:
225
+ envelope[field] = value
226
+
227
+ # Strategy 2: schema-driven extraction for non-'data' declared properties
228
+ # (mirrors api-fastify's extractEnvelopeFromHeaders). May add fields for
229
+ # envelope fields declared by the schema under a non-'adhd' pluginId.
230
+ props: dict[str, Any] = input_schema.get("properties", {})
231
+ meta: dict[str, str] = input_schema.get("x-apigen-envelope", {})
232
+ for field in props:
233
+ if field == "data":
234
+ continue
235
+ plugin_id = meta.get(field, "adhd")
236
+ if plugin_id == "adhd":
237
+ # Already covered by Strategy 1 above.
238
+ continue
239
+ header_name = f"x-{plugin_id}-{field}"
240
+ value = headers.get(header_name)
241
+ if value is not None:
242
+ envelope[field] = value
243
+
244
+ return envelope
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # JSON serialisation helper — handles non-standard types gracefully
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def _json_dumps(obj: Any) -> str:
252
+ """Serialise obj to JSON, using ApigenEncoder if available."""
253
+ if _HAS_LOGICAL:
254
+ return json.dumps(obj, cls=apigen_logical.ApigenEncoder)
255
+ return json.dumps(obj)
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Server state — built once on startup, shared across request threads
260
+ #
261
+ # apigen-serve-core py-flask-serve-split: route + verb are no longer derived
262
+ # here. `_route_for_op()` / `_http_verb()` / `_is_primitive_only_input_schema()`
263
+ # (formerly here, a hand-maintained Python port of `@adhd/apigen-engine-naming`'s
264
+ # `project(op).http`) are DELETED — see the module docstring's "Route
265
+ # contract" section. `routes` is the TS-computed `{opId: {route, verb}}` map
266
+ # injected via `--plan-file`.
267
+ # ---------------------------------------------------------------------------
268
+
269
+ class _RouteEntry:
270
+ """One resolved route: the operation it dispatches to + its TS-computed verb."""
271
+
272
+ __slots__ = ("op", "verb")
273
+
274
+ def __init__(self, op: dict[str, Any], verb: str) -> None:
275
+ self.op = op
276
+ self.verb = verb
277
+
278
+
279
+ class _ServerState:
280
+ """Immutable server configuration shared across all request threads."""
281
+
282
+ __slots__ = ("namespace", "runtime", "operations", "route_map", "route_map_by_op_id")
283
+
284
+ def __init__(
285
+ self,
286
+ namespace: str,
287
+ runtime: Runtime,
288
+ operations: list[dict[str, Any]],
289
+ routes: dict[str, dict[str, str]],
290
+ ) -> None:
291
+ self.namespace = namespace
292
+ self.runtime = runtime
293
+ self.operations = operations
294
+ # Map canonical HTTP route → _RouteEntry(op, verb), for O(1) request
295
+ # dispatch. `routes[op["id"]]` supplies BOTH the route and the verb,
296
+ # computed TS-side by the REAL `@adhd/apigen-engine-naming` `project()`
297
+ # (BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001) — not re-derived here.
298
+ # The operation's `input` dict IS the inner param schema directly (no
299
+ # data-wrapper in the extractor's output — that wrapper is a TS-side
300
+ # composition artifact; the Python runtime receives bare params), so
301
+ # no separate schema map is needed alongside this.
302
+ self.route_map: dict[str, _RouteEntry] = {}
303
+ # (batch-rollout F8) op id -> _RouteEntry, built from the SAME loop
304
+ # that builds route_map above. `_dispatch_batch` resolves a batch
305
+ # request's `operation` field (an operation ID, not a URL path) to
306
+ # its real, dispatchable route entry through this index — `route_map`
307
+ # alone (keyed by URL path) cannot serve that lookup.
308
+ self.route_map_by_op_id: dict[str, _RouteEntry] = {}
309
+ for op in operations:
310
+ entry = routes.get(op["id"])
311
+ if entry is None:
312
+ raise ValueError(
313
+ f"apigen-py-flask: --plan-file is missing a routes entry "
314
+ f"for operation id {op['id']!r} — the injected plan must "
315
+ f"cover every operation extract_module() (phase 1) "
316
+ f"produced, 1:1."
317
+ )
318
+ route_entry = _RouteEntry(op=op, verb=entry["verb"])
319
+ self.route_map[entry["route"]] = route_entry
320
+ self.route_map_by_op_id[op["id"]] = route_entry
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Batch/bulk fan-out (`_batch/<kind>`) — BATCH_0.0.1.md §5, this host's own
325
+ # executor implementation, ported from the design at
326
+ # tmp/apigen-batch-python-design.md (§2.2, corrected per §3.5's architect-
327
+ # review findings F2-F5). This is the Python-idiom equivalent of the TS
328
+ # `invokeBatch` (`apigen-engine-runtime/src/lib/batch.ts`), never a call into
329
+ # it — the target op is dispatched locally, the same way every other route on
330
+ # this server already dispatches, N times, with concurrency/mode/onItemError
331
+ # control.
332
+ # ---------------------------------------------------------------------------
333
+
334
+ # (F7) Hard cap on requested concurrency — a literal port of the TS
335
+ # constant's VALUE (intentionally duplicated per-host rather than unified
336
+ # into one cross-language source of truth; there is no cross-language
337
+ # constant-sharing mechanism in this repo today — see
338
+ # `apigen-engine-runtime/src/lib/batch.ts:35`'s
339
+ # `export const BATCH_MAX_CONCURRENCY = 32;`).
340
+ BATCH_MAX_CONCURRENCY = 32
341
+
342
+ # Mirrors `apigen-engine-runtime/src/lib/batch.ts`'s
343
+ # `BATCH_DEFAULT_CONCURRENCY = 4` — the fan-out width used when the request
344
+ # doesn't specify one.
345
+ BATCH_DEFAULT_CONCURRENCY = 4
346
+
347
+
348
+ def _not_attempted_reason(mode: str) -> dict[str, Any]:
349
+ """The per-item rejection reason for an item never attempted because an
350
+ earlier item aborted the whole batch (F5): a REAL
351
+ ``ApiError('internal', ...).to_json()``, never a hand-rolled dict with an
352
+ invented ``'cancelled'`` code that exists in neither ``errors.py``'s
353
+ taxonomy nor ``apigen-base-errors`` — mirrors TS `invokeBatch`'s own
354
+ `notAttempted()` mapping (`apigen-engine-runtime/src/lib/batch.ts`)."""
355
+ if mode == "chained":
356
+ message = (
357
+ "batch aborted (mode=chained) — an earlier item failed and this "
358
+ "item was never attempted"
359
+ )
360
+ else:
361
+ message = (
362
+ "batch aborted (onItemError=abort) — an earlier item failed and "
363
+ "this item was never attempted"
364
+ )
365
+ return ApiError("internal", message).to_json()
366
+
367
+
368
+ def _run_batch_item(
369
+ state: _ServerState,
370
+ entry: _RouteEntry,
371
+ index: int,
372
+ item: Any,
373
+ envelope: dict[str, Any],
374
+ ) -> dict[str, Any]:
375
+ """Dispatch ONE batch item through the SAME validate -> decode logical
376
+ types -> invoke -> encode result pipeline `_ApigenHandler._dispatch`
377
+ already performs for a real single-op request (F4 — logical-type
378
+ parity). Never a bypass of `_decode_params`/`pre_validated=True`/
379
+ `_encode_result`; the only difference from `_dispatch` is that a failure
380
+ here becomes a per-item `BatchItemResult` instead of an HTTP response.
381
+ """
382
+ input_schema: dict[str, Any] = entry.op.get("input", {})
383
+ try:
384
+ data = item if isinstance(item, dict) else {}
385
+
386
+ # SPEC §6: validate BEFORE dispatch, exactly like the single-op path.
387
+ if input_schema:
388
+ try:
389
+ validate(input_schema, data)
390
+ except ValidationError as exc:
391
+ raise ApiError(
392
+ "invalid_argument", f"input validation failed: {exc}"
393
+ ) from exc
394
+
395
+ # Schema-driven logical-type decode (Decimal, datetime, ...) — F4.
396
+ decoded_data = _decode_params(data, input_schema)
397
+
398
+ host_req = HostRequest(
399
+ operation=entry.op,
400
+ data=decoded_data,
401
+ envelope=envelope,
402
+ transport="http",
403
+ pre_validated=True,
404
+ )
405
+ value = state.runtime.invoke_sync(host_req)
406
+ return {"index": index, "status": "fulfilled", "value": _encode_result(value)}
407
+ except ApiError as exc:
408
+ return {"index": index, "status": "rejected", "reason": exc.to_json()}
409
+ except Exception as exc: # noqa: BLE001 - must never leak a bare traceback over the wire
410
+ return {
411
+ "index": index,
412
+ "status": "rejected",
413
+ "reason": ApiError("internal", f"dispatch error: {exc}").to_json(),
414
+ }
415
+
416
+
417
+ def _resolve_batch_concurrency(mode: str, requested: Any, item_count: int) -> int:
418
+ """Mirror `apigen-engine-runtime/src/lib/batch.ts`'s `resolveConcurrency`:
419
+ `serial`/`chained` are always width-1 (never reach this — callers only
420
+ invoke this for `mode == 'parallel'`), a bad/missing request falls back
421
+ to `BATCH_DEFAULT_CONCURRENCY`, and the result is always clamped to
422
+ `[1, BATCH_MAX_CONCURRENCY]` and never exceeds the item count (so
423
+ `ThreadPoolExecutor` is never constructed with `max_workers=0`).
424
+ """
425
+ if requested is None or not isinstance(requested, (int, float)) or requested < 1:
426
+ n = BATCH_DEFAULT_CONCURRENCY
427
+ else:
428
+ n = int(requested)
429
+ n = min(n, BATCH_MAX_CONCURRENCY)
430
+ return max(1, min(n, max(item_count, 1)))
431
+
432
+
433
+ def _dispatch_batch(
434
+ state: _ServerState, body: dict[str, Any], envelope: dict[str, Any]
435
+ ) -> list[dict[str, Any]]:
436
+ """Fan out `body['items']` to the real operation `body['operation']` —
437
+ the Python host's OWN executor against BATCH_0.0.1.md §5's wire contract
438
+ (LEFT column only; this function is never a call into the TS
439
+ `invokeBatch`). Implements the architect-reviewed design's binding fixes
440
+ (`tmp/apigen-batch-python-design.md` §3.5):
441
+
442
+ - F2: `mode` branching — `parallel` uses a bounded `ThreadPoolExecutor`;
443
+ `serial`/`chained` use a plain sequential loop; `chained` additionally
444
+ stops on the first failure regardless of `onItemError`.
445
+ - F3: `itemTimeoutMs` is rejected upfront (whole-batch `invalid_argument`)
446
+ — `Runtime.invoke_sync` has no timeout kwarg / cancellation hook, so no
447
+ partial/best-effort cancellation is offered under the same field name
448
+ TS uses for real `AbortSignal`-based cancellation.
449
+ - F4: every item goes through `_run_batch_item`'s real
450
+ validate/decode/invoke/encode pipeline (logical-type parity with the
451
+ single-op path); the nested-batch guard checks the `_batch/` ID
452
+ PREFIX, never `op["kind"] == "batch"` (impossible — `kind` is always
453
+ the real grouped kind, e.g. `'action'`/`'query'`).
454
+ - F5: every rejected result's `reason` is a real `ApiError(...).to_json()`
455
+ using an existing `errors.py` taxonomy code.
456
+
457
+ Raises:
458
+ ApiError: for whole-request-level rejections (bad `operation`,
459
+ unsupported `itemTimeoutMs`, malformed `items`/`mode`/
460
+ `onItemError`/`concurrency`) — the caller maps this to a real
461
+ HTTP 4xx via `_send_error`, the SAME mechanism every other route
462
+ on this server already uses.
463
+ """
464
+ # F3 — reject the WHOLE batch upfront; never a silently-weaker
465
+ # wait-only guarantee under the same field name TS uses for real
466
+ # cancellation.
467
+ if body.get("itemTimeoutMs") is not None:
468
+ raise ApiError(
469
+ "invalid_argument",
470
+ "itemTimeoutMs is not supported by the Python host",
471
+ )
472
+
473
+ target_op_id = body.get("operation")
474
+ if not isinstance(target_op_id, str) or not target_op_id:
475
+ raise ApiError(
476
+ "invalid_argument",
477
+ '"operation" must be a non-empty string naming the target operation id',
478
+ )
479
+ # F4 — the nested-batch guard checks the ID PREFIX (`syntheticOp` always
480
+ # names batch mounts this way), NOT `op["kind"] == "batch"` (impossible:
481
+ # `kind` on a real target op is always its real grouped kind, never the
482
+ # literal string 'batch').
483
+ if target_op_id.startswith("_batch/"):
484
+ raise ApiError(
485
+ "invalid_argument",
486
+ f"not a real batchable operation: {target_op_id!r} is itself a "
487
+ "batch mount",
488
+ )
489
+ entry = state.route_map_by_op_id.get(target_op_id)
490
+ if entry is None:
491
+ raise ApiError(
492
+ "invalid_argument",
493
+ f"not a real batchable operation: {target_op_id!r} is not one "
494
+ "of this server's operations",
495
+ )
496
+
497
+ items = body.get("items")
498
+ if not isinstance(items, list):
499
+ raise ApiError("invalid_argument", '"items" must be an array')
500
+
501
+ mode = body.get("mode", "parallel")
502
+ if mode not in ("parallel", "serial", "chained"):
503
+ raise ApiError(
504
+ "invalid_argument",
505
+ '"mode" must be one of "parallel" | "serial" | "chained"',
506
+ )
507
+ on_item_error = body.get("onItemError", "continue")
508
+ if on_item_error not in ("continue", "abort"):
509
+ raise ApiError(
510
+ "invalid_argument",
511
+ '"onItemError" must be one of "continue" | "abort"',
512
+ )
513
+ requested_concurrency = body.get("concurrency")
514
+ if requested_concurrency is not None and not isinstance(requested_concurrency, (int, float)):
515
+ raise ApiError("invalid_argument", '"concurrency" must be a number')
516
+
517
+ results: list[dict[str, Any] | None] = [None] * len(items)
518
+
519
+ # F2 — serial/chained: a plain sequential loop, no thread pool at all
520
+ # (strictly simpler than the parallel path below).
521
+ if mode in ("serial", "chained"):
522
+ stop = False
523
+ for i, item in enumerate(items):
524
+ if stop:
525
+ results[i] = {
526
+ "index": i,
527
+ "status": "rejected",
528
+ "reason": _not_attempted_reason(mode),
529
+ }
530
+ continue
531
+ result = _run_batch_item(state, entry, i, item, envelope)
532
+ results[i] = result
533
+ if result["status"] == "rejected" and (mode == "chained" or on_item_error == "abort"):
534
+ stop = True
535
+ return results # type: ignore[return-value]
536
+
537
+ # F2 — parallel: bounded ThreadPoolExecutor, the idiomatic sync-Python
538
+ # equivalent of TS's worker-pool scheduler (runtime.invoke is NOT used —
539
+ # introducing asyncio into this synchronous BaseHTTPRequestHandler server
540
+ # is out of scope; see the design doc's "Concurrency primitive" note).
541
+ concurrency = _resolve_batch_concurrency(mode, requested_concurrency, len(items))
542
+ aborted = threading.Event()
543
+
544
+ def run_one(i: int, item: Any) -> None:
545
+ if aborted.is_set():
546
+ results[i] = {
547
+ "index": i,
548
+ "status": "rejected",
549
+ "reason": _not_attempted_reason(mode),
550
+ }
551
+ return
552
+ result = _run_batch_item(state, entry, i, item, envelope)
553
+ results[i] = result
554
+ if result["status"] == "rejected" and on_item_error == "abort":
555
+ # Stop starting NEW items once one fails — cannot un-submit
556
+ # already-running thread-pool work, identical in spirit to TS's
557
+ # `onItemError:'abort'` behavior (in-flight items are allowed to
558
+ # finish; they are not force-cancelled).
559
+ aborted.set()
560
+
561
+ if items:
562
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
563
+ futures = [pool.submit(run_one, i, item) for i, item in enumerate(items)]
564
+ for f in futures:
565
+ f.result() # propagate any genuinely unexpected exception loudly
566
+
567
+ return results # type: ignore[return-value]
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # HTTP request handler
572
+ # ---------------------------------------------------------------------------
573
+
574
+ class _ApigenHandler(BaseHTTPRequestHandler):
575
+ """HTTP handler wired to a _ServerState via the server's `state` attribute."""
576
+
577
+ # Suppress the default request log lines; we print our own.
578
+ def log_message(self, _fmt: str, *_args: Any) -> None: # noqa: ANN001
579
+ pass
580
+
581
+ def log_error(self, _fmt: str, *_args: Any) -> None: # noqa: ANN001
582
+ pass
583
+
584
+ # ------------------------------------------------------------------
585
+ # Route: GET /_meta/health
586
+ # ------------------------------------------------------------------
587
+
588
+ def _handle_health(self) -> None:
589
+ ns = self.server.state.namespace # type: ignore[attr-defined]
590
+ body = _json_dumps({"status": "ok", "host": ns}).encode()
591
+ self._send_json(200, body)
592
+
593
+ # ------------------------------------------------------------------
594
+ # Route: GET <route> (safe / primitive-only-input ops — query-string params)
595
+ # ------------------------------------------------------------------
596
+
597
+ def _handle_safe_get(self, op: dict[str, Any]) -> None:
598
+ # Parse query-string → data dict
599
+ parsed = urllib.parse.urlparse(self.path)
600
+ qs = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
601
+ # Flatten single-value lists (query-string repeats are unusual for APIs)
602
+ data: dict[str, Any] = {k: v[0] if len(v) == 1 else v for k, v in qs.items()}
603
+
604
+ self._dispatch(op, data, envelope_from_headers=True)
605
+
606
+ # ------------------------------------------------------------------
607
+ # Route: POST <route>
608
+ # ------------------------------------------------------------------
609
+
610
+ def _handle_post(self, op: dict[str, Any]) -> None:
611
+ # Read body
612
+ content_length = int(self.headers.get("Content-Length", "0") or "0")
613
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b"{}"
614
+ try:
615
+ body = json.loads(body_bytes or b"{}")
616
+ except json.JSONDecodeError as exc:
617
+ self._send_error(400, "invalid_argument", f"JSON parse error: {exc}")
618
+ return
619
+
620
+ if not isinstance(body, dict):
621
+ self._send_error(400, "invalid_argument", "request body must be a JSON object")
622
+ return
623
+
624
+ data = body.get("data", {})
625
+ if not isinstance(data, dict):
626
+ self._send_error(400, "invalid_argument", '"data" must be a JSON object')
627
+ return
628
+
629
+ self._dispatch(op, data, envelope_from_headers=True)
630
+
631
+ # ------------------------------------------------------------------
632
+ # Route: POST <route> for a synthetic `_batch/<kind>` mount
633
+ # (BATCH_0.0.1.md §5 — see `_dispatch_batch`'s own doc comment).
634
+ # ------------------------------------------------------------------
635
+
636
+ def _handle_batch_post(self, op: dict[str, Any]) -> None:
637
+ content_length = int(self.headers.get("Content-Length", "0") or "0")
638
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b"{}"
639
+ try:
640
+ body = json.loads(body_bytes or b"{}")
641
+ except json.JSONDecodeError as exc:
642
+ self._send_error(400, "invalid_argument", f"JSON parse error: {exc}")
643
+ return
644
+
645
+ if not isinstance(body, dict):
646
+ self._send_error(400, "invalid_argument", "request body must be a JSON object")
647
+ return
648
+
649
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
650
+
651
+ # §9.1 envelope from headers, extracted once and shared across every
652
+ # item — mirrors the TS `invokeBatch` call site, which also threads
653
+ # ONE shared `call.envelope` across every fanned-out item (never a
654
+ # per-item envelope).
655
+ headers_lc = {k.lower(): v for k, v in self.headers.items()}
656
+ envelope = _extract_envelope(op.get("input", {}), headers_lc)
657
+
658
+ try:
659
+ results = _dispatch_batch(state, body, envelope)
660
+ except ApiError as exc:
661
+ status = HTTP_STATUS.get(exc.code, 500)
662
+ self._send_error(status, exc.code, exc.message)
663
+ return
664
+ except Exception as exc:
665
+ self._send_error(500, "internal", f"batch dispatch error: {exc}")
666
+ return
667
+
668
+ body_out = _json_dumps(results).encode()
669
+ self._send_json(200, body_out)
670
+
671
+ # ------------------------------------------------------------------
672
+ # Dispatch (shared between GET/POST paths)
673
+ # ------------------------------------------------------------------
674
+
675
+ def _dispatch(
676
+ self,
677
+ op: dict[str, Any],
678
+ data: dict[str, Any],
679
+ *,
680
+ envelope_from_headers: bool,
681
+ ) -> None:
682
+ """Validate input → decode logical types → invoke → encode result → respond."""
683
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
684
+
685
+ # The operation's `input` IS the inner param schema (no data-wrapper —
686
+ # see _ServerState.route_map's doc comment).
687
+ input_schema: dict[str, Any] = op.get("input", {})
688
+
689
+ # §9.1 envelope from headers
690
+ envelope: dict[str, Any] = {}
691
+ if envelope_from_headers:
692
+ headers_lc = {k.lower(): v for k, v in self.headers.items()}
693
+ envelope = _extract_envelope(input_schema, headers_lc)
694
+
695
+ # SPEC §6: validate BEFORE dispatch (malformed → 400 before fn is called)
696
+ if input_schema:
697
+ try:
698
+ validate(input_schema, data)
699
+ except ValidationError as exc:
700
+ self._send_error(400, "invalid_argument", f"input validation failed: {exc}")
701
+ return
702
+
703
+ # Schema-driven decode for logical types (Decimal, datetime, etc.)
704
+ decoded_data = _decode_params(data, input_schema)
705
+
706
+ # Build the HostRequest for the runtime.
707
+ # pre_validated=True tells the runtime not to re-validate the data
708
+ # (which now contains decoded native values like Decimal/datetime) against
709
+ # the wire-schema — we already validated the raw wire data above.
710
+ host_req = HostRequest(
711
+ operation=op,
712
+ data=decoded_data,
713
+ envelope=envelope,
714
+ transport="http",
715
+ pre_validated=True,
716
+ )
717
+
718
+ try:
719
+ result = state.runtime.invoke_sync(host_req)
720
+ except ApiError as exc:
721
+ status = HTTP_STATUS.get(exc.code, 500)
722
+ self._send_error(status, exc.code, exc.message)
723
+ return
724
+ except Exception as exc:
725
+ self._send_error(500, "internal", f"dispatch error: {exc}")
726
+ return
727
+
728
+ # Encode the result to its canonical wire form
729
+ wire_result = _encode_result(result)
730
+
731
+ body = _json_dumps(wire_result).encode()
732
+ self._send_json(200, body)
733
+
734
+ # ------------------------------------------------------------------
735
+ # Routing
736
+ # ------------------------------------------------------------------
737
+
738
+ def do_GET(self) -> None: # noqa: N802
739
+ """Handle GET requests: health endpoint + GET-eligible operations.
740
+
741
+ Routing is a direct lookup of the full request path against
742
+ `state.route_map` (BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001) — the
743
+ canonical, kebab-cased, multi-segment route computed by
744
+ `_route_for_op()`, NOT a `/<ns>/<lastSegmentRaw>` prefix strip. This is
745
+ what makes a multi-segment op (namespace + file segment + export
746
+ segment, e.g. `/testapi/test-api/echo-str`) resolve at all — the old
747
+ prefix-strip approach silently dropped every segment but the last.
748
+ """
749
+ parsed = urllib.parse.urlparse(self.path)
750
+ path_only = parsed.path
751
+
752
+ if path_only == "/_meta/health":
753
+ self._handle_health()
754
+ return
755
+
756
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
757
+ entry = state.route_map.get(path_only)
758
+ if entry is not None:
759
+ if entry.verb == "GET":
760
+ self._handle_safe_get(entry.op)
761
+ return
762
+ # Known route but wrong verb (POST-only operation, per the
763
+ # TS-computed --plan-file verb — see _ServerState's doc comment).
764
+ self._send_error(405, "invalid_argument", f"use POST for {path_only}")
765
+ return
766
+
767
+ self._send_error(404, "not_found", f"no route for GET {path_only}")
768
+
769
+ def do_POST(self) -> None: # noqa: N802
770
+ """Handle POST requests: function dispatch via the canonical route map
771
+ (see `do_GET`'s doc comment for why this is a full-path lookup, not a
772
+ prefix strip)."""
773
+ parsed = urllib.parse.urlparse(self.path)
774
+ path_only = parsed.path
775
+
776
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
777
+ entry = state.route_map.get(path_only)
778
+ if entry is not None:
779
+ # (batch-rollout) A synthetic `_batch/<kind>` mount's op id is
780
+ # the authoritative marker (`syntheticOp` always names batch
781
+ # mounts this way — F4) — never `entry.op.get("kind")`, which on
782
+ # a batch mount is its real grouped kind (e.g. 'action'), not the
783
+ # literal string 'batch'.
784
+ if entry.op.get("id", "").startswith("_batch/"):
785
+ self._handle_batch_post(entry.op)
786
+ else:
787
+ self._handle_post(entry.op)
788
+ return
789
+
790
+ self._send_error(404, "not_found", f"no route for POST {path_only}")
791
+
792
+ # ------------------------------------------------------------------
793
+ # Response helpers
794
+ # ------------------------------------------------------------------
795
+
796
+ def _send_json(self, status: int, body: bytes) -> None:
797
+ self.send_response(status)
798
+ self.send_header("Content-Type", "application/json")
799
+ self.send_header("Content-Length", str(len(body)))
800
+ self.end_headers()
801
+ self.wfile.write(body)
802
+
803
+ def _send_error(self, status: int, code: str, message: str) -> None:
804
+ body = _json_dumps({"code": code, "message": message}).encode()
805
+ self._send_json(status, body)
806
+
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # Server lifecycle
810
+ # ---------------------------------------------------------------------------
811
+
812
+ class ApigenFlaskServer:
813
+ """Thin wrapper around ThreadingHTTPServer for lifecycle management.
814
+
815
+ Named 'Flask' in the public API for consistency with the deliverable spec,
816
+ but implemented with stdlib ``http.server`` (Flask is an optional dep).
817
+ """
818
+
819
+ def __init__(
820
+ self,
821
+ module_path: str,
822
+ namespace: str,
823
+ plan: dict[str, Any],
824
+ host: str = "127.0.0.1",
825
+ port: int = 8000,
826
+ ) -> None:
827
+ self._module_path = module_path
828
+ self._namespace = namespace
829
+ self._plan = plan
830
+ self._host = host
831
+ self._port = port
832
+ self._httpd: ThreadingHTTPServer | None = None
833
+ self._thread: threading.Thread | None = None
834
+
835
+ def _build_state(self) -> _ServerState:
836
+ """Build the server state from the INJECTED `--plan-file` — this
837
+ server no longer self-extracts (apigen-serve-core py-flask-serve-
838
+ split). `self._plan["operations"]` supplies the full Operation[]
839
+ (schema-bearing dicts, produced by `apigen_python.extractor
840
+ --emit-json` in a separate, short-lived process — see plugin.ts's
841
+ two-phase spawn) and `self._plan["routes"]` supplies the per-op
842
+ canonical route/verb, computed by the REAL `@adhd/apigen-engine-naming`
843
+ `project()` — never re-derived here.
844
+
845
+ The module is still loaded a SECOND time, independently, right here —
846
+ that load's only job is producing LIVE function references for the
847
+ runtime registry, which cannot cross a process boundary as data
848
+ (Python callables are not serialisable). This is unchanged from the
849
+ pre-split design and was confirmed safe by
850
+ `docs/apigen/proposals/py-extract-serve-split-findings.md` §1.2/§1.4.
851
+ """
852
+ ops: list[dict[str, Any]] = self._plan["operations"]
853
+ routes: dict[str, dict[str, str]] = self._plan["routes"]
854
+ if not ops:
855
+ raise ValueError(
856
+ f"--plan-file for {self._module_path!r} carries zero "
857
+ "operations. Ensure the module has public callable exports."
858
+ )
859
+
860
+ # Load the module to get live function references.
861
+ # Register in sys.modules BEFORE exec_module (canonical importlib pattern)
862
+ # so dataclasses._is_type and typing.get_type_hints work correctly for
863
+ # modules that use `from __future__ import annotations` (PEP 563).
864
+ path = Path(self._module_path).resolve()
865
+ spec = importlib.util.spec_from_file_location("_apigen_flask_module_", str(path))
866
+ if spec is None or spec.loader is None:
867
+ raise ImportError(f"Cannot load module from {path}")
868
+ mod = importlib.util.module_from_spec(spec)
869
+ import sys as _sys
870
+ _sys.modules[spec.name] = mod
871
+ try:
872
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
873
+ except Exception:
874
+ _sys.modules.pop(spec.name, None)
875
+ raise
876
+
877
+ # Build fn_registry: op_id → callable
878
+ registry: dict[str, Any] = {}
879
+ for op in ops:
880
+ fn_name = op["path"][-1]["raw"]
881
+ fn = getattr(mod, fn_name, None)
882
+ if callable(fn):
883
+ registry[op["id"]] = fn
884
+
885
+ runtime = Runtime(registry)
886
+ return _ServerState(
887
+ namespace=self._namespace,
888
+ runtime=runtime,
889
+ operations=ops,
890
+ routes=routes,
891
+ )
892
+
893
+ def start(self) -> None:
894
+ """Build state and start the HTTP server in a background thread."""
895
+ state = self._build_state()
896
+
897
+ class _Server(ThreadingHTTPServer):
898
+ pass
899
+
900
+ httpd = _Server((self._host, self._port), _ApigenHandler)
901
+ httpd.state = state # type: ignore[attr-defined]
902
+ self._httpd = httpd
903
+
904
+ # BUG-APIGEN-TEST-FIXED-PORT-COLLISION-001: bind() already happened
905
+ # synchronously in the `_Server(...)` constructor above (TCPServer.
906
+ # __init__ calls server_bind()+server_activate() before returning), so
907
+ # the OS has already resolved `port=0` to a real ephemeral port by
908
+ # this point. Read the actual bound port back so callers that asked
909
+ # for port 0 can learn what they actually got — self._port is the
910
+ # single source of truth from here on (used by the log line below and
911
+ # by serve_forever()'s readiness signal).
912
+ self._port = httpd.socket.getsockname()[1]
913
+
914
+ # Log the registered routes to stderr — canonical route/verb, TS-
915
+ # computed via the injected --plan-file (BUG-APIGEN-OPENAPI-ROUTE-
916
+ # PATH-MISMATCH-001), not re-derived here.
917
+ print(f"apigen-py-flask listening on http://{self._host}:{self._port}", file=sys.stderr)
918
+ print(f" GET /_meta/health", file=sys.stderr)
919
+ for route, entry in state.route_map.items():
920
+ print(f" {entry.verb:<4} {route}", file=sys.stderr)
921
+ sys.stderr.flush()
922
+
923
+ self._thread = threading.Thread(
924
+ target=httpd.serve_forever,
925
+ daemon=True,
926
+ name="apigen-flask-http",
927
+ )
928
+ self._thread.start()
929
+
930
+ def stop(self) -> None:
931
+ """Shutdown the HTTP server."""
932
+ if self._httpd is not None:
933
+ self._httpd.shutdown()
934
+ self._httpd = None
935
+
936
+ def serve_forever(self) -> None:
937
+ """Block until interrupted (Ctrl-C / SIGTERM).
938
+
939
+ Prints the §13.1-compatible readiness signal ``{"ready": true, "port":
940
+ <n>}`` to stdout immediately after the server thread starts, so the TS
941
+ plugin subprocess launcher can poll for it. ``port`` is the ACTUAL
942
+ bound port (BUG-APIGEN-TEST-FIXED-PORT-COLLISION-001) — identical to
943
+ the requested ``--port`` unless 0 (OS-assigned ephemeral) was passed,
944
+ in which case this is the only way the caller learns the real port.
945
+ """
946
+ self.start()
947
+ # §13.1 readiness signal — TS launcher polls for this line on stdout
948
+ print(json.dumps({"ready": True, "port": self._port}), flush=True)
949
+ try:
950
+ # Block main thread while the daemon thread serves
951
+ if self._thread is not None:
952
+ self._thread.join()
953
+ except KeyboardInterrupt:
954
+ pass
955
+ finally:
956
+ self.stop()
957
+
958
+
959
+ # ---------------------------------------------------------------------------
960
+ # Public convenience: build_server()
961
+ # ---------------------------------------------------------------------------
962
+
963
+ def build_server(
964
+ module_path: str,
965
+ namespace: str,
966
+ plan: dict[str, Any],
967
+ host: str = "127.0.0.1",
968
+ port: int = 8000,
969
+ ) -> ApigenFlaskServer:
970
+ """Construct an :class:`ApigenFlaskServer` for the given module.
971
+
972
+ Args:
973
+ module_path: Path to the ``.py`` source file (absolute or relative).
974
+ namespace: The apigen namespace slug (used as the route prefix).
975
+ plan: The TS-computed serve plan (`{"operations": [...],
976
+ "routes": {"<opId>": {"route": str, "verb": "GET"|"POST"}}}`)
977
+ — see the module docstring's "Usage" section.
978
+ host: Bind address (default ``127.0.0.1``).
979
+ port: TCP port (default ``8000``).
980
+
981
+ Returns:
982
+ An :class:`ApigenFlaskServer` instance ready to call ``.start()`` or
983
+ ``.serve_forever()``.
984
+ """
985
+ return ApigenFlaskServer(module_path, namespace, plan, host=host, port=port)
986
+
987
+
988
+ # ---------------------------------------------------------------------------
989
+ # CLI entry point
990
+ # ---------------------------------------------------------------------------
991
+
992
+ def _main() -> None:
993
+ parser = argparse.ArgumentParser(
994
+ description=(
995
+ "apigen Python HTTP server — serves a .py module over HTTP.\n\n"
996
+ "Routes (TS-computed via @adhd/apigen-engine-naming's project().http and\n"
997
+ "injected via --plan-file — BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001 /\n"
998
+ "apigen-serve-core py-flask-serve-split):\n"
999
+ " GET /_meta/health → {status, host}\n"
1000
+ " POST <route> → body {data: {<params>}} → result\n"
1001
+ " GET <route> → query-string (per the injected plan's verb)\n"
1002
+ " <route> = /<kebab-namespace>/<kebab-pathSeg>/… (multi-segment)\n\n"
1003
+ "Startup: emits {ready: true} on stdout once the server is up."
1004
+ )
1005
+ )
1006
+ parser.add_argument(
1007
+ "--module", required=True,
1008
+ help="Path to the Python source module to serve"
1009
+ )
1010
+ parser.add_argument(
1011
+ "--namespace", required=True,
1012
+ help="Namespace slug used as the URL prefix (e.g. 'myapi')"
1013
+ )
1014
+ parser.add_argument(
1015
+ "--host", default="127.0.0.1",
1016
+ help="Bind host (default: 127.0.0.1)"
1017
+ )
1018
+ parser.add_argument(
1019
+ "--port", type=int, default=8000,
1020
+ help="TCP port (default: 8000; pass 0 to bind an OS-assigned "
1021
+ "ephemeral port — the actual bound port is reported back in "
1022
+ "the {ready: true, port: <n>} stdout line)"
1023
+ )
1024
+ parser.add_argument(
1025
+ "--plan-file", required=True,
1026
+ help=(
1027
+ "Path to a JSON file: {\"operations\": [...Operation dicts, exactly "
1028
+ "apigen_python.extractor --emit-json's output...], \"routes\": "
1029
+ "{\"<opId>\": {\"route\": \"<path>\", \"verb\": \"GET\"|\"POST\"}}}. "
1030
+ "REQUIRED — this server no longer self-extracts or re-derives "
1031
+ "route/verb (apigen-serve-core py-flask-serve-split); produced by "
1032
+ "the py-flask TS plugin's two-phase spawn (extractor --emit-json "
1033
+ "-> @adhd/apigen-engine-naming project() -> this flag)."
1034
+ ),
1035
+ )
1036
+ args = parser.parse_args()
1037
+
1038
+ with open(args.plan_file, "r", encoding="utf-8") as plan_fh:
1039
+ plan = json.load(plan_fh)
1040
+
1041
+ server = build_server(
1042
+ module_path=args.module,
1043
+ namespace=args.namespace,
1044
+ plan=plan,
1045
+ host=args.host,
1046
+ port=args.port,
1047
+ )
1048
+ server.serve_forever()
1049
+
1050
+
1051
+ if __name__ == "__main__":
1052
+ _main()