@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.
@@ -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()