@adhd/apigen-python-env 0.2.1 โ†’ 0.2.3

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 CHANGED
@@ -1,3 +1,32 @@
1
+ ## 0.2.3 (2026-08-07)
2
+
3
+
4
+ ### ๐Ÿฉน Fixes
5
+
6
+ - **apigen-cli:** restore 2768 files mass-deleted by 0117eb22 (BUG-APIGEN-052)
7
+
8
+ - **apigen-python:** parent-death watchdog so Python hosts cannot outlive their spawning process (BUG-APIGEN-053)
9
+
10
+
11
+ ### โค๏ธ Thank You
12
+
13
+ - parity-harness-self-test
14
+ - pseudosky
15
+
16
+ ## 0.2.2 (2026-07-30)
17
+
18
+
19
+ ### ๐Ÿฉน Fixes
20
+
21
+ - **nx-build:** release-atomicity test coverage + CPU-guard flakiness + cache-input gap
22
+
23
+ - **apigen-python-env:** dist copy-plugin hardening alongside cache-input fix
24
+
25
+
26
+ ### โค๏ธ Thank You
27
+
28
+ - pseudosky
29
+
1
30
  ## 0.2.1 (2026-07-27)
2
31
 
3
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhd/apigen-python-env",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "main": "./index.js",
5
5
  "module": "./index.mjs",
6
6
  "types": "./index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "provisioning"
13
13
  ],
14
14
  "dependencies": {
15
- "@adhd/apigen-core-client": "^0.2.1"
15
+ "@adhd/apigen-core-client": "^0.3.0"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
@@ -73,6 +73,7 @@ import json
73
73
  import sys
74
74
  import threading
75
75
  import urllib.parse
76
+ from concurrent.futures import ThreadPoolExecutor
76
77
  from datetime import datetime
77
78
  from decimal import Decimal
78
79
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -92,6 +93,7 @@ if str(_THIS_DIR) not in sys.path:
92
93
  sys.path.insert(0, str(_THIS_DIR))
93
94
 
94
95
  from apigen_python.errors import ApiError, HTTP_STATUS # noqa: E402
96
+ from apigen_python.parent_watchdog import start_parent_death_watchdog # noqa: E402
95
97
  from apigen_python.runtime import HostRequest, Runtime # noqa: E402
96
98
  from apigen_python.validator import validate, ValidationError # noqa: E402
97
99
 
@@ -278,7 +280,7 @@ class _RouteEntry:
278
280
  class _ServerState:
279
281
  """Immutable server configuration shared across all request threads."""
280
282
 
281
- __slots__ = ("namespace", "runtime", "operations", "route_map")
283
+ __slots__ = ("namespace", "runtime", "operations", "route_map", "route_map_by_op_id")
282
284
 
283
285
  def __init__(
284
286
  self,
@@ -299,6 +301,12 @@ class _ServerState:
299
301
  # composition artifact; the Python runtime receives bare params), so
300
302
  # no separate schema map is needed alongside this.
301
303
  self.route_map: dict[str, _RouteEntry] = {}
304
+ # (batch-rollout F8) op id -> _RouteEntry, built from the SAME loop
305
+ # that builds route_map above. `_dispatch_batch` resolves a batch
306
+ # request's `operation` field (an operation ID, not a URL path) to
307
+ # its real, dispatchable route entry through this index โ€” `route_map`
308
+ # alone (keyed by URL path) cannot serve that lookup.
309
+ self.route_map_by_op_id: dict[str, _RouteEntry] = {}
302
310
  for op in operations:
303
311
  entry = routes.get(op["id"])
304
312
  if entry is None:
@@ -308,7 +316,256 @@ class _ServerState:
308
316
  f"cover every operation extract_module() (phase 1) "
309
317
  f"produced, 1:1."
310
318
  )
311
- self.route_map[entry["route"]] = _RouteEntry(op=op, verb=entry["verb"])
319
+ route_entry = _RouteEntry(op=op, verb=entry["verb"])
320
+ self.route_map[entry["route"]] = route_entry
321
+ self.route_map_by_op_id[op["id"]] = route_entry
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Batch/bulk fan-out (`_batch/<kind>`) โ€” BATCH_0.0.1.md ยง5, this host's own
326
+ # executor implementation, ported from the design at
327
+ # tmp/apigen-batch-python-design.md (ยง2.2, corrected per ยง3.5's architect-
328
+ # review findings F2-F5). This is the Python-idiom equivalent of the TS
329
+ # `invokeBatch` (`apigen-engine-runtime/src/lib/batch.ts`), never a call into
330
+ # it โ€” the target op is dispatched locally, the same way every other route on
331
+ # this server already dispatches, N times, with concurrency/mode/onItemError
332
+ # control.
333
+ # ---------------------------------------------------------------------------
334
+
335
+ # (F7) Hard cap on requested concurrency โ€” a literal port of the TS
336
+ # constant's VALUE (intentionally duplicated per-host rather than unified
337
+ # into one cross-language source of truth; there is no cross-language
338
+ # constant-sharing mechanism in this repo today โ€” see
339
+ # `apigen-engine-runtime/src/lib/batch.ts:35`'s
340
+ # `export const BATCH_MAX_CONCURRENCY = 32;`).
341
+ BATCH_MAX_CONCURRENCY = 32
342
+
343
+ # Mirrors `apigen-engine-runtime/src/lib/batch.ts`'s
344
+ # `BATCH_DEFAULT_CONCURRENCY = 4` โ€” the fan-out width used when the request
345
+ # doesn't specify one.
346
+ BATCH_DEFAULT_CONCURRENCY = 4
347
+
348
+
349
+ def _not_attempted_reason(mode: str) -> dict[str, Any]:
350
+ """The per-item rejection reason for an item never attempted because an
351
+ earlier item aborted the whole batch (F5): a REAL
352
+ ``ApiError('internal', ...).to_json()``, never a hand-rolled dict with an
353
+ invented ``'cancelled'`` code that exists in neither ``errors.py``'s
354
+ taxonomy nor ``apigen-base-errors`` โ€” mirrors TS `invokeBatch`'s own
355
+ `notAttempted()` mapping (`apigen-engine-runtime/src/lib/batch.ts`)."""
356
+ if mode == "chained":
357
+ message = (
358
+ "batch aborted (mode=chained) โ€” an earlier item failed and this "
359
+ "item was never attempted"
360
+ )
361
+ else:
362
+ message = (
363
+ "batch aborted (onItemError=abort) โ€” an earlier item failed and "
364
+ "this item was never attempted"
365
+ )
366
+ return ApiError("internal", message).to_json()
367
+
368
+
369
+ def _run_batch_item(
370
+ state: _ServerState,
371
+ entry: _RouteEntry,
372
+ index: int,
373
+ item: Any,
374
+ envelope: dict[str, Any],
375
+ ) -> dict[str, Any]:
376
+ """Dispatch ONE batch item through the SAME validate -> decode logical
377
+ types -> invoke -> encode result pipeline `_ApigenHandler._dispatch`
378
+ already performs for a real single-op request (F4 โ€” logical-type
379
+ parity). Never a bypass of `_decode_params`/`pre_validated=True`/
380
+ `_encode_result`; the only difference from `_dispatch` is that a failure
381
+ here becomes a per-item `BatchItemResult` instead of an HTTP response.
382
+ """
383
+ input_schema: dict[str, Any] = entry.op.get("input", {})
384
+ try:
385
+ data = item if isinstance(item, dict) else {}
386
+
387
+ # SPEC ยง6: validate BEFORE dispatch, exactly like the single-op path.
388
+ if input_schema:
389
+ try:
390
+ validate(input_schema, data)
391
+ except ValidationError as exc:
392
+ raise ApiError(
393
+ "invalid_argument", f"input validation failed: {exc}"
394
+ ) from exc
395
+
396
+ # Schema-driven logical-type decode (Decimal, datetime, ...) โ€” F4.
397
+ decoded_data = _decode_params(data, input_schema)
398
+
399
+ host_req = HostRequest(
400
+ operation=entry.op,
401
+ data=decoded_data,
402
+ envelope=envelope,
403
+ transport="http",
404
+ pre_validated=True,
405
+ )
406
+ value = state.runtime.invoke_sync(host_req)
407
+ return {"index": index, "status": "fulfilled", "value": _encode_result(value)}
408
+ except ApiError as exc:
409
+ return {"index": index, "status": "rejected", "reason": exc.to_json()}
410
+ except Exception as exc: # noqa: BLE001 - must never leak a bare traceback over the wire
411
+ return {
412
+ "index": index,
413
+ "status": "rejected",
414
+ "reason": ApiError("internal", f"dispatch error: {exc}").to_json(),
415
+ }
416
+
417
+
418
+ def _resolve_batch_concurrency(mode: str, requested: Any, item_count: int) -> int:
419
+ """Mirror `apigen-engine-runtime/src/lib/batch.ts`'s `resolveConcurrency`:
420
+ `serial`/`chained` are always width-1 (never reach this โ€” callers only
421
+ invoke this for `mode == 'parallel'`), a bad/missing request falls back
422
+ to `BATCH_DEFAULT_CONCURRENCY`, and the result is always clamped to
423
+ `[1, BATCH_MAX_CONCURRENCY]` and never exceeds the item count (so
424
+ `ThreadPoolExecutor` is never constructed with `max_workers=0`).
425
+ """
426
+ if requested is None or not isinstance(requested, (int, float)) or requested < 1:
427
+ n = BATCH_DEFAULT_CONCURRENCY
428
+ else:
429
+ n = int(requested)
430
+ n = min(n, BATCH_MAX_CONCURRENCY)
431
+ return max(1, min(n, max(item_count, 1)))
432
+
433
+
434
+ def _dispatch_batch(
435
+ state: _ServerState, body: dict[str, Any], envelope: dict[str, Any]
436
+ ) -> list[dict[str, Any]]:
437
+ """Fan out `body['items']` to the real operation `body['operation']` โ€”
438
+ the Python host's OWN executor against BATCH_0.0.1.md ยง5's wire contract
439
+ (LEFT column only; this function is never a call into the TS
440
+ `invokeBatch`). Implements the architect-reviewed design's binding fixes
441
+ (`tmp/apigen-batch-python-design.md` ยง3.5):
442
+
443
+ - F2: `mode` branching โ€” `parallel` uses a bounded `ThreadPoolExecutor`;
444
+ `serial`/`chained` use a plain sequential loop; `chained` additionally
445
+ stops on the first failure regardless of `onItemError`.
446
+ - F3: `itemTimeoutMs` is rejected upfront (whole-batch `invalid_argument`)
447
+ โ€” `Runtime.invoke_sync` has no timeout kwarg / cancellation hook, so no
448
+ partial/best-effort cancellation is offered under the same field name
449
+ TS uses for real `AbortSignal`-based cancellation.
450
+ - F4: every item goes through `_run_batch_item`'s real
451
+ validate/decode/invoke/encode pipeline (logical-type parity with the
452
+ single-op path); the nested-batch guard checks the `_batch/` ID
453
+ PREFIX, never `op["kind"] == "batch"` (impossible โ€” `kind` is always
454
+ the real grouped kind, e.g. `'action'`/`'query'`).
455
+ - F5: every rejected result's `reason` is a real `ApiError(...).to_json()`
456
+ using an existing `errors.py` taxonomy code.
457
+
458
+ Raises:
459
+ ApiError: for whole-request-level rejections (bad `operation`,
460
+ unsupported `itemTimeoutMs`, malformed `items`/`mode`/
461
+ `onItemError`/`concurrency`) โ€” the caller maps this to a real
462
+ HTTP 4xx via `_send_error`, the SAME mechanism every other route
463
+ on this server already uses.
464
+ """
465
+ # F3 โ€” reject the WHOLE batch upfront; never a silently-weaker
466
+ # wait-only guarantee under the same field name TS uses for real
467
+ # cancellation.
468
+ if body.get("itemTimeoutMs") is not None:
469
+ raise ApiError(
470
+ "invalid_argument",
471
+ "itemTimeoutMs is not supported by the Python host",
472
+ )
473
+
474
+ target_op_id = body.get("operation")
475
+ if not isinstance(target_op_id, str) or not target_op_id:
476
+ raise ApiError(
477
+ "invalid_argument",
478
+ '"operation" must be a non-empty string naming the target operation id',
479
+ )
480
+ # F4 โ€” the nested-batch guard checks the ID PREFIX (`syntheticOp` always
481
+ # names batch mounts this way), NOT `op["kind"] == "batch"` (impossible:
482
+ # `kind` on a real target op is always its real grouped kind, never the
483
+ # literal string 'batch').
484
+ if target_op_id.startswith("_batch/"):
485
+ raise ApiError(
486
+ "invalid_argument",
487
+ f"not a real batchable operation: {target_op_id!r} is itself a "
488
+ "batch mount",
489
+ )
490
+ entry = state.route_map_by_op_id.get(target_op_id)
491
+ if entry is None:
492
+ raise ApiError(
493
+ "invalid_argument",
494
+ f"not a real batchable operation: {target_op_id!r} is not one "
495
+ "of this server's operations",
496
+ )
497
+
498
+ items = body.get("items")
499
+ if not isinstance(items, list):
500
+ raise ApiError("invalid_argument", '"items" must be an array')
501
+
502
+ mode = body.get("mode", "parallel")
503
+ if mode not in ("parallel", "serial", "chained"):
504
+ raise ApiError(
505
+ "invalid_argument",
506
+ '"mode" must be one of "parallel" | "serial" | "chained"',
507
+ )
508
+ on_item_error = body.get("onItemError", "continue")
509
+ if on_item_error not in ("continue", "abort"):
510
+ raise ApiError(
511
+ "invalid_argument",
512
+ '"onItemError" must be one of "continue" | "abort"',
513
+ )
514
+ requested_concurrency = body.get("concurrency")
515
+ if requested_concurrency is not None and not isinstance(requested_concurrency, (int, float)):
516
+ raise ApiError("invalid_argument", '"concurrency" must be a number')
517
+
518
+ results: list[dict[str, Any] | None] = [None] * len(items)
519
+
520
+ # F2 โ€” serial/chained: a plain sequential loop, no thread pool at all
521
+ # (strictly simpler than the parallel path below).
522
+ if mode in ("serial", "chained"):
523
+ stop = False
524
+ for i, item in enumerate(items):
525
+ if stop:
526
+ results[i] = {
527
+ "index": i,
528
+ "status": "rejected",
529
+ "reason": _not_attempted_reason(mode),
530
+ }
531
+ continue
532
+ result = _run_batch_item(state, entry, i, item, envelope)
533
+ results[i] = result
534
+ if result["status"] == "rejected" and (mode == "chained" or on_item_error == "abort"):
535
+ stop = True
536
+ return results # type: ignore[return-value]
537
+
538
+ # F2 โ€” parallel: bounded ThreadPoolExecutor, the idiomatic sync-Python
539
+ # equivalent of TS's worker-pool scheduler (runtime.invoke is NOT used โ€”
540
+ # introducing asyncio into this synchronous BaseHTTPRequestHandler server
541
+ # is out of scope; see the design doc's "Concurrency primitive" note).
542
+ concurrency = _resolve_batch_concurrency(mode, requested_concurrency, len(items))
543
+ aborted = threading.Event()
544
+
545
+ def run_one(i: int, item: Any) -> None:
546
+ if aborted.is_set():
547
+ results[i] = {
548
+ "index": i,
549
+ "status": "rejected",
550
+ "reason": _not_attempted_reason(mode),
551
+ }
552
+ return
553
+ result = _run_batch_item(state, entry, i, item, envelope)
554
+ results[i] = result
555
+ if result["status"] == "rejected" and on_item_error == "abort":
556
+ # Stop starting NEW items once one fails โ€” cannot un-submit
557
+ # already-running thread-pool work, identical in spirit to TS's
558
+ # `onItemError:'abort'` behavior (in-flight items are allowed to
559
+ # finish; they are not force-cancelled).
560
+ aborted.set()
561
+
562
+ if items:
563
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
564
+ futures = [pool.submit(run_one, i, item) for i, item in enumerate(items)]
565
+ for f in futures:
566
+ f.result() # propagate any genuinely unexpected exception loudly
567
+
568
+ return results # type: ignore[return-value]
312
569
 
313
570
 
314
571
  # ---------------------------------------------------------------------------
@@ -372,6 +629,46 @@ class _ApigenHandler(BaseHTTPRequestHandler):
372
629
 
373
630
  self._dispatch(op, data, envelope_from_headers=True)
374
631
 
632
+ # ------------------------------------------------------------------
633
+ # Route: POST <route> for a synthetic `_batch/<kind>` mount
634
+ # (BATCH_0.0.1.md ยง5 โ€” see `_dispatch_batch`'s own doc comment).
635
+ # ------------------------------------------------------------------
636
+
637
+ def _handle_batch_post(self, op: dict[str, Any]) -> None:
638
+ content_length = int(self.headers.get("Content-Length", "0") or "0")
639
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b"{}"
640
+ try:
641
+ body = json.loads(body_bytes or b"{}")
642
+ except json.JSONDecodeError as exc:
643
+ self._send_error(400, "invalid_argument", f"JSON parse error: {exc}")
644
+ return
645
+
646
+ if not isinstance(body, dict):
647
+ self._send_error(400, "invalid_argument", "request body must be a JSON object")
648
+ return
649
+
650
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
651
+
652
+ # ยง9.1 envelope from headers, extracted once and shared across every
653
+ # item โ€” mirrors the TS `invokeBatch` call site, which also threads
654
+ # ONE shared `call.envelope` across every fanned-out item (never a
655
+ # per-item envelope).
656
+ headers_lc = {k.lower(): v for k, v in self.headers.items()}
657
+ envelope = _extract_envelope(op.get("input", {}), headers_lc)
658
+
659
+ try:
660
+ results = _dispatch_batch(state, body, envelope)
661
+ except ApiError as exc:
662
+ status = HTTP_STATUS.get(exc.code, 500)
663
+ self._send_error(status, exc.code, exc.message)
664
+ return
665
+ except Exception as exc:
666
+ self._send_error(500, "internal", f"batch dispatch error: {exc}")
667
+ return
668
+
669
+ body_out = _json_dumps(results).encode()
670
+ self._send_json(200, body_out)
671
+
375
672
  # ------------------------------------------------------------------
376
673
  # Dispatch (shared between GET/POST paths)
377
674
  # ------------------------------------------------------------------
@@ -480,7 +777,15 @@ class _ApigenHandler(BaseHTTPRequestHandler):
480
777
  state: _ServerState = self.server.state # type: ignore[attr-defined]
481
778
  entry = state.route_map.get(path_only)
482
779
  if entry is not None:
483
- self._handle_post(entry.op)
780
+ # (batch-rollout) A synthetic `_batch/<kind>` mount's op id is
781
+ # the authoritative marker (`syntheticOp` always names batch
782
+ # mounts this way โ€” F4) โ€” never `entry.op.get("kind")`, which on
783
+ # a batch mount is its real grouped kind (e.g. 'action'), not the
784
+ # literal string 'batch'.
785
+ if entry.op.get("id", "").startswith("_batch/"):
786
+ self._handle_batch_post(entry.op)
787
+ else:
788
+ self._handle_post(entry.op)
484
789
  return
485
790
 
486
791
  self._send_error(404, "not_found", f"no route for POST {path_only}")
@@ -642,6 +947,10 @@ class ApigenFlaskServer:
642
947
  self.start()
643
948
  # ยง13.1 readiness signal โ€” TS launcher polls for this line on stdout
644
949
  print(json.dumps({"ready": True, "port": self._port}), flush=True)
950
+ # BUG-APIGEN-053: self-terminate if the TS parent process dies
951
+ # outright (SIGKILL/OOM-kill/crash) without ever sending a signal โ€”
952
+ # see apigen_python.parent_watchdog's module docstring.
953
+ start_parent_death_watchdog()
645
954
  try:
646
955
  # Block main thread while the daemon thread serves
647
956
  if self._thread is not None:
@@ -119,6 +119,7 @@ if str(_THIS_DIR) not in sys.path:
119
119
  sys.path.insert(0, str(_THIS_DIR))
120
120
 
121
121
  from apigen_python.errors import ApiError, GRPC_CODE # noqa: E402
122
+ from apigen_python.parent_watchdog import start_parent_death_watchdog # noqa: E402
122
123
  from apigen_python.runtime import HostRequest, Runtime # noqa: E402
123
124
  from apigen_python.validator import validate, ValidationError # noqa: E402
124
125
 
@@ -842,6 +843,11 @@ class ApigenGrpcServer:
842
843
  _signal.signal(_signal.SIGTERM, _sig_handler)
843
844
  _signal.signal(_signal.SIGINT, _sig_handler)
844
845
 
846
+ # BUG-APIGEN-053: self-terminate if the TS parent process dies
847
+ # outright (SIGKILL/OOM-kill/crash) without ever sending a signal โ€”
848
+ # see apigen_python.parent_watchdog's module docstring.
849
+ start_parent_death_watchdog()
850
+
845
851
  try:
846
852
  stop_event.wait()
847
853
  except KeyboardInterrupt:
@@ -0,0 +1,79 @@
1
+ """Parent-death watchdog (BUG-APIGEN-053).
2
+
3
+ Both of apigen_python's long-running servers (flask_server.py, grpc_server.py)
4
+ are spawned by a TS parent (apigen-plugin-py-flask/-py-grpc's `run()`) with
5
+ stdio `['pipe', 'pipe', 'pipe']`. The only teardown path either plugin wires
6
+ is `input.signal`'s 'abort' handler, which sends SIGTERM to the child -- but
7
+ that requires the TS parent process to still be alive to fire it. When the
8
+ parent instead dies outright (SIGKILL, OOM-kill, CI job cancellation, an
9
+ uncaught crash before its abort handler runs), no signal is ever sent,
10
+ `serve_forever()`'s wait/join call never wakes up, and the process reparents
11
+ to launchd (ppid=1), holding its listening socket forever -- this is
12
+ BUG-APIGEN-053's exact, measured failure mode (132 orphaned processes / 137
13
+ listening sockets, ~23h old).
14
+
15
+ Neither Python's default SIGTERM disposition (fires only when someone SENDS
16
+ SIGTERM) nor a PR_SET_PDEATHSIG-style mechanism (Linux-only; unavailable on
17
+ macOS, where this failure was measured) closes this gap. The portable fix:
18
+ the child's stdin is a pipe whose write end is owned by the parent process.
19
+ The OS closes that write end the INSTANT the parent dies, for ANY reason,
20
+ including SIGKILL -- no cooperation from the dying parent required. A
21
+ background thread blocked reading stdin sees EOF at exactly that moment.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import threading
27
+ from typing import Callable, Optional
28
+
29
+
30
+ def start_parent_death_watchdog(
31
+ on_parent_death: Optional[Callable[[], None]] = None,
32
+ ) -> threading.Thread:
33
+ """Starts a daemon thread blocked reading stdin (fd 0) via a raw
34
+ `os.read()` -- see the in-loop comment for why not `sys.stdin`. On EOF
35
+ (the parent's write end of the stdin pipe closed -- i.e. the parent
36
+ died, for ANY reason including SIGKILL), calls `on_parent_death`.
37
+
38
+ Default `on_parent_death` is `os._exit(0)` -- an immediate hard process
39
+ exit. That is deliberate and safe here: there is no client left to serve
40
+ a graceful response to (the process that spawned this server is gone),
41
+ and the OS reclaims the listening socket on process exit regardless of
42
+ whether `Server.stop()`/`shutdown()` ran first.
43
+
44
+ Returns the started (daemon=True) Thread so a caller/test can join it.
45
+ """
46
+ callback = on_parent_death or (lambda: os._exit(0))
47
+
48
+ def _watch() -> None:
49
+ try:
50
+ while True:
51
+ # Deliberately `os.read(0, ...)` (a raw fd read via the
52
+ # syscall wrapper), NOT `sys.stdin.buffer.read(...)`: the
53
+ # buffered form takes CPython's internal BufferedReader lock
54
+ # for the duration of the blocking read. `grpc_server.py`
55
+ # handles SIGTERM/SIGINT gracefully (its own handler sets an
56
+ # Event and `_main()` returns normally, reaching an ORDERLY
57
+ # `Py_Finalize()`) -- and finalization tries to flush/close
58
+ # `sys.stdin`, which cannot acquire that same lock while this
59
+ # daemon thread still holds it, producing a real, reproduced
60
+ # `Fatal Python error: _enter_buffered_busy: could not
61
+ # acquire lock ... at interpreter shutdown` on every single
62
+ # graceful teardown. A raw `os.read()` never touches that
63
+ # lock, so it can be safely abandoned mid-call when the
64
+ # interpreter exits either gracefully or via `os._exit()`.
65
+ chunk = os.read(0, 4096)
66
+ if not chunk:
67
+ break
68
+ except OSError:
69
+ # stdin fd already closed/invalid (e.g. a harness that doesn't
70
+ # wire a pipe, or redirects fd 0 elsewhere) -- nothing to watch;
71
+ # exit the thread quietly.
72
+ return
73
+ callback()
74
+
75
+ thread = threading.Thread(
76
+ target=_watch, name="apigen-parent-death-watchdog", daemon=True
77
+ )
78
+ thread.start()
79
+ return thread
@@ -25,6 +25,7 @@ import importlib.util
25
25
  import inspect
26
26
  import json
27
27
  import os
28
+ import shutil
28
29
  import subprocess
29
30
  import sys
30
31
  import tempfile
@@ -916,6 +917,44 @@ def run_echo_roundtrip(results: _Results) -> None:
916
917
  # Never sleep-as-proof: all waits are bounded polls.
917
918
  # ---------------------------------------------------------------------------
918
919
 
920
+ def _build_flask_plan(
921
+ fixture_module: Path,
922
+ namespace: str,
923
+ ops: list[dict[str, Any]] | None = None,
924
+ extra_operations: list[dict[str, Any]] | None = None,
925
+ extra_routes: dict[str, dict[str, str]] | None = None,
926
+ ) -> tuple[Path, Path]:
927
+ """Build a real `--plan-file` for `flask_server.py` from a real
928
+ `extract_module()` pass โ€” `flask_server.py` has required `--plan-file`
929
+ since the apigen-serve-core py-flask-serve-split (it no longer
930
+ self-extracts or re-derives route/verb; see its module docstring).
931
+
932
+ Routes use the flat `/{namespace}/{fnName}` shape this file's own
933
+ `_post`/`_get` test helpers already assume โ€” this suite tests
934
+ `flask_server.py`'s OWN dispatch/validation/logical-type/batch behavior
935
+ given a correct plan, not route derivation itself (the real
936
+ `project()`-derived kebab-route shape is independently proven
937
+ byte-for-byte by `apigen-plugin-py-flask`'s own TS test suite,
938
+ BUG-APIGEN-OPENAPI-ROUTE-PATH-MISMATCH-001).
939
+
940
+ Returns:
941
+ `(plan_path, tmpdir)` โ€” caller removes `tmpdir` (best-effort) once
942
+ the spawned server is done with it.
943
+ """
944
+ if ops is None:
945
+ ops = extract_module(str(fixture_module), namespace)
946
+ routes: dict[str, dict[str, str]] = {}
947
+ for op in ops:
948
+ fn_name = op["path"][-1]["raw"]
949
+ routes[op["id"]] = {"route": f"/{namespace}/{fn_name}", "verb": "POST"}
950
+ all_ops = list(ops) + list(extra_operations or [])
951
+ routes.update(extra_routes or {})
952
+ tmpdir = Path(tempfile.mkdtemp(prefix="apigen-py-flask-runtests-plan-"))
953
+ plan_path = tmpdir / "plan.json"
954
+ plan_path.write_text(json.dumps({"operations": all_ops, "routes": routes}))
955
+ return plan_path, tmpdir
956
+
957
+
919
958
  def run_flask_server_tests(results: _Results) -> None:
920
959
  """Run live HTTP tests against a real flask_server subprocess."""
921
960
  import signal
@@ -930,7 +969,7 @@ def run_flask_server_tests(results: _Results) -> None:
930
969
  # SCRIPT_DIR.parent = packages/apigen
931
970
  fixture_module = (
932
971
  SCRIPT_DIR.parent / # packages/apigen
933
- "plugins" / "py-flask" / "src" / "test" / "fixtures" / "test_api.py"
972
+ "apigen-plugin-py-flask" / "src" / "test" / "fixtures" / "test_api.py"
934
973
  )
935
974
  if not fixture_module.exists():
936
975
  results.fail(
@@ -943,6 +982,37 @@ def run_flask_server_tests(results: _Results) -> None:
943
982
  namespace = "testapi"
944
983
  base_url = f"http://127.0.0.1:{port}"
945
984
 
985
+ # (batch-rollout) Every fixture op below (`echo_str`/`double_decimal`/...)
986
+ # is `kind='action'` (see extractor.py: every callable export โ†’ 'action'),
987
+ # so they group under ONE synthetic `_batch/action` mount โ€” mirrors what
988
+ # `buildBatchMountedOperations` (apigen-core-client/src/lib/batch.ts)
989
+ # would produce TS-side for this same fixture module, hand-built here
990
+ # since this suite has no Node/TS runtime available to call it for real.
991
+ real_ops = extract_module(str(fixture_module), namespace)
992
+ batch_op_id = "_batch/action"
993
+ batch_op: dict[str, Any] = {
994
+ "id": batch_op_id,
995
+ "host": "python",
996
+ "namespace": {"raw": namespace, "words": [namespace]},
997
+ "path": [{"raw": "batch", "words": ["batch"]}, {"raw": "action", "words": ["action"]}],
998
+ "kind": "action",
999
+ "async": False,
1000
+ "streaming": False,
1001
+ "safe": False,
1002
+ "input": {"type": "object"},
1003
+ "output": {},
1004
+ "envelope": {},
1005
+ "typeText": None,
1006
+ "operationIds": [op["id"] for op in real_ops],
1007
+ }
1008
+ plan_path, plan_tmpdir = _build_flask_plan(
1009
+ fixture_module,
1010
+ namespace,
1011
+ ops=real_ops,
1012
+ extra_operations=[batch_op],
1013
+ extra_routes={batch_op_id: {"route": f"/{namespace}/_batch/action", "verb": "POST"}},
1014
+ )
1015
+
946
1016
  # Spawn the server subprocess.
947
1017
  proc = subprocess.Popen(
948
1018
  [
@@ -950,6 +1020,7 @@ def run_flask_server_tests(results: _Results) -> None:
950
1020
  "--module", str(fixture_module),
951
1021
  "--namespace", namespace,
952
1022
  "--port", str(port),
1023
+ "--plan-file", str(plan_path),
953
1024
  ],
954
1025
  cwd=str(SCRIPT_DIR),
955
1026
  env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
@@ -971,6 +1042,7 @@ def run_flask_server_tests(results: _Results) -> None:
971
1042
 
972
1043
  if not ready:
973
1044
  proc.kill()
1045
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
974
1046
  results.fail("flask.startup", "Server did not become ready within 10 s")
975
1047
  return
976
1048
 
@@ -1119,12 +1191,219 @@ def run_flask_server_tests(results: _Results) -> None:
1119
1191
  else:
1120
1192
  results.fail("flask.envelope.ctx", f"expected session in response, got {env_status} {env_body!r}")
1121
1193
 
1194
+ # ------------------------------------------------------------------
1195
+ # Batch/bulk fan-out โ€” BATCH_0.0.1.md ยง5 (batch-rollout, py-flask)
1196
+ # ------------------------------------------------------------------
1197
+ batch_url = f"{base_url}/{namespace}/_batch/action"
1198
+
1199
+ def _post_batch(payload: dict[str, Any]) -> tuple[int, Any]:
1200
+ req = urllib.request.Request(
1201
+ batch_url,
1202
+ data=json.dumps(payload).encode(),
1203
+ headers={"Content-Type": "application/json"},
1204
+ method="POST",
1205
+ )
1206
+ try:
1207
+ with urllib.request.urlopen(req, timeout=10) as resp:
1208
+ return resp.status, json.loads(resp.read())
1209
+ except urllib.error.HTTPError as e:
1210
+ return e.code, json.loads(e.read())
1211
+
1212
+ # Test: parallel fan-out, 3 items (2 valid echo_str + 1 that fails
1213
+ # input validation) โ€” proves per-item partial-failure with
1214
+ # onItemError='continue' (the default) using the REAL ApiError
1215
+ # reason shape.
1216
+ status, body = _post_batch({
1217
+ "operation": f"{namespace}/test-api/echo-str",
1218
+ "items": [{"msg": "one"}, {"msg": 123}, {"msg": "two"}],
1219
+ })
1220
+ if (
1221
+ status == 200
1222
+ and isinstance(body, list)
1223
+ and len(body) == 3
1224
+ and body[0] == {"index": 0, "status": "fulfilled", "value": "one"}
1225
+ and body[1]["status"] == "rejected"
1226
+ and body[1]["reason"].get("code") == "invalid_argument"
1227
+ and body[2] == {"index": 2, "status": "fulfilled", "value": "two"}
1228
+ ):
1229
+ results.ok("flask.batch.parallel.partial_failure")
1230
+ else:
1231
+ results.fail(
1232
+ "flask.batch.parallel.partial_failure",
1233
+ f"expected [fulfilled,rejected(invalid_argument),fulfilled], got {status} {body!r}",
1234
+ )
1235
+
1236
+ # Test: Decimal logical-type parity through the batch path (F4) โ€”
1237
+ # same decoded-Decimal-then-encoded-string round-trip as the
1238
+ # single-op `double_decimal` test above.
1239
+ status, body = _post_batch({
1240
+ "operation": f"{namespace}/test-api/double-decimal",
1241
+ "items": [{"amount": "123.456"}, {"amount": "0.1"}],
1242
+ })
1243
+ if (
1244
+ status == 200
1245
+ and isinstance(body, list)
1246
+ and len(body) == 2
1247
+ and body[0] == {"index": 0, "status": "fulfilled", "value": "246.912"}
1248
+ and body[1] == {"index": 1, "status": "fulfilled", "value": "0.2"}
1249
+ and isinstance(body[1]["value"], str)
1250
+ ):
1251
+ results.ok("flask.batch.logical_type.decimal_parity")
1252
+ else:
1253
+ results.fail(
1254
+ "flask.batch.logical_type.decimal_parity",
1255
+ f"expected decimal-string parity with single-op path, got {status} {body!r}",
1256
+ )
1257
+
1258
+ # Test: mode='serial' actually runs sequentially, in declared order,
1259
+ # all fulfilled (no failures to react to).
1260
+ status, body = _post_batch({
1261
+ "operation": f"{namespace}/test-api/echo-str",
1262
+ "items": [{"msg": "a"}, {"msg": "b"}, {"msg": "c"}],
1263
+ "mode": "serial",
1264
+ })
1265
+ if status == 200 and body == [
1266
+ {"index": 0, "status": "fulfilled", "value": "a"},
1267
+ {"index": 1, "status": "fulfilled", "value": "b"},
1268
+ {"index": 2, "status": "fulfilled", "value": "c"},
1269
+ ]:
1270
+ results.ok("flask.batch.serial.order")
1271
+ else:
1272
+ results.fail("flask.batch.serial.order", f"expected in-order fulfilled triple, got {status} {body!r}")
1273
+
1274
+ # Test: mode='chained' stops immediately on the first failure โ€”
1275
+ # every remaining item is marked not-attempted regardless of
1276
+ # onItemError (which defaults to 'continue' here, deliberately, to
1277
+ # prove 'chained' overrides it).
1278
+ status, body = _post_batch({
1279
+ "operation": f"{namespace}/test-api/echo-str",
1280
+ "items": [{"msg": 999}, {"msg": "never-runs"}, {"msg": "never-runs-2"}],
1281
+ "mode": "chained",
1282
+ })
1283
+ if (
1284
+ status == 200
1285
+ and isinstance(body, list)
1286
+ and len(body) == 3
1287
+ and body[0]["status"] == "rejected"
1288
+ and body[0]["reason"].get("code") == "invalid_argument"
1289
+ and body[1]["status"] == "rejected"
1290
+ and body[1]["reason"].get("code") == "internal"
1291
+ and "never attempted" in body[1]["reason"].get("message", "").lower()
1292
+ and body[2]["status"] == "rejected"
1293
+ and body[2]["reason"].get("code") == "internal"
1294
+ ):
1295
+ results.ok("flask.batch.chained.stops_on_first_failure")
1296
+ else:
1297
+ results.fail(
1298
+ "flask.batch.chained.stops_on_first_failure",
1299
+ f"expected item0 rejected + items1/2 not-attempted, got {status} {body!r}",
1300
+ )
1301
+
1302
+ # NEGATIVE CONTROL for the chained test above: with mode='parallel'
1303
+ # (the default) and onItemError='continue' (the default), the SAME
1304
+ # failing-first-item batch must NOT abort the remaining items โ€” if
1305
+ # it did, this would go RED, proving the 'chained' assertion above
1306
+ # has teeth (it is actually 'chained'-specific behavior, not just
1307
+ # "the server always stops on first failure").
1308
+ status, body = _post_batch({
1309
+ "operation": f"{namespace}/test-api/echo-str",
1310
+ "items": [{"msg": 999}, {"msg": "still-runs"}, {"msg": "still-runs-2"}],
1311
+ })
1312
+ if (
1313
+ status == 200
1314
+ and isinstance(body, list)
1315
+ and len(body) == 3
1316
+ and body[0]["status"] == "rejected"
1317
+ and body[1] == {"index": 1, "status": "fulfilled", "value": "still-runs"}
1318
+ and body[2] == {"index": 2, "status": "fulfilled", "value": "still-runs-2"}
1319
+ ):
1320
+ results.ok("flask.batch.chained.negative_control_default_continues")
1321
+ else:
1322
+ results.fail(
1323
+ "flask.batch.chained.negative_control_default_continues",
1324
+ f"default mode must NOT stop on first failure, got {status} {body!r}",
1325
+ )
1326
+
1327
+ # Test: onItemError='abort' under mode='parallel' with concurrency=1
1328
+ # (forces strict submission-order execution through the single
1329
+ # ThreadPoolExecutor worker) โ€” the first item fails, so items 1/2
1330
+ # must never actually run (not-attempted), deterministically (no
1331
+ # timing/sleep dependency: with exactly one worker thread pulling
1332
+ # from the pool's FIFO queue, item 0 always completes โ€” and sets the
1333
+ # abort flag โ€” strictly before item 1 is dequeued).
1334
+ status, body = _post_batch({
1335
+ "operation": f"{namespace}/test-api/echo-str",
1336
+ "items": [{"msg": 999}, {"msg": "should-not-run"}, {"msg": "should-not-run-2"}],
1337
+ "concurrency": 1,
1338
+ "onItemError": "abort",
1339
+ })
1340
+ if (
1341
+ status == 200
1342
+ and isinstance(body, list)
1343
+ and len(body) == 3
1344
+ and body[0]["status"] == "rejected"
1345
+ and body[0]["reason"].get("code") == "invalid_argument"
1346
+ and body[1]["status"] == "rejected"
1347
+ and body[1]["reason"].get("code") == "internal"
1348
+ and "abort" in body[1]["reason"].get("message", "").lower()
1349
+ and body[2]["status"] == "rejected"
1350
+ ):
1351
+ results.ok("flask.batch.parallel.on_item_error_abort")
1352
+ else:
1353
+ results.fail(
1354
+ "flask.batch.parallel.on_item_error_abort",
1355
+ f"expected item0 rejected + items1/2 not-attempted (abort), got {status} {body!r}",
1356
+ )
1357
+
1358
+ # Test: itemTimeoutMs is rejected upfront (F3) โ€” the WHOLE batch
1359
+ # request 400s with invalid_argument, before any item is dispatched.
1360
+ status, body = _post_batch({
1361
+ "operation": f"{namespace}/test-api/echo-str",
1362
+ "items": [{"msg": "x"}],
1363
+ "itemTimeoutMs": 1000,
1364
+ })
1365
+ if status == 400 and body.get("code") == "invalid_argument":
1366
+ results.ok("flask.batch.item_timeout_ms.rejected")
1367
+ else:
1368
+ results.fail(
1369
+ "flask.batch.item_timeout_ms.rejected",
1370
+ f"expected 400 invalid_argument for itemTimeoutMs, got {status} {body!r}",
1371
+ )
1372
+
1373
+ # Test: targeting a nested `_batch/*` mount is rejected (F4's
1374
+ # ID-prefix guard) โ€” never silently accepted as a real op.
1375
+ status, body = _post_batch({
1376
+ "operation": batch_op_id,
1377
+ "items": [{}],
1378
+ })
1379
+ if status == 400 and body.get("code") == "invalid_argument":
1380
+ results.ok("flask.batch.nested_batch_target.rejected")
1381
+ else:
1382
+ results.fail(
1383
+ "flask.batch.nested_batch_target.rejected",
1384
+ f"expected 400 invalid_argument for nested batch target, got {status} {body!r}",
1385
+ )
1386
+
1387
+ # Test: targeting an operation id that doesn't exist at all.
1388
+ status, body = _post_batch({
1389
+ "operation": f"{namespace}/does-not-exist",
1390
+ "items": [{}],
1391
+ })
1392
+ if status == 400 and body.get("code") == "invalid_argument":
1393
+ results.ok("flask.batch.unknown_operation.rejected")
1394
+ else:
1395
+ results.fail(
1396
+ "flask.batch.unknown_operation.rejected",
1397
+ f"expected 400 invalid_argument for unknown operation, got {status} {body!r}",
1398
+ )
1399
+
1122
1400
  finally:
1123
1401
  proc.kill()
1124
1402
  try:
1125
1403
  proc.wait(timeout=3)
1126
1404
  except subprocess.TimeoutExpired:
1127
1405
  proc.kill()
1406
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1128
1407
 
1129
1408
 
1130
1409
  # ---------------------------------------------------------------------------
@@ -1338,7 +1617,7 @@ def run_cli_decimal_live_tests(results: _Results) -> None:
1338
1617
  # Locate the decimal_api fixture module.
1339
1618
  fixture_module = (
1340
1619
  SCRIPT_DIR.parent / # packages/apigen
1341
- "plugins" / "py-flask" / "src" / "test" / "fixtures" / "decimal_api.py"
1620
+ "apigen-plugin-py-flask" / "src" / "test" / "fixtures" / "decimal_api.py"
1342
1621
  )
1343
1622
  if not fixture_module.exists():
1344
1623
  results.fail(
@@ -1351,6 +1630,8 @@ def run_cli_decimal_live_tests(results: _Results) -> None:
1351
1630
  namespace = "decapi"
1352
1631
  base_url = f"http://127.0.0.1:{port}"
1353
1632
 
1633
+ plan_path, plan_tmpdir = _build_flask_plan(fixture_module, namespace)
1634
+
1354
1635
  # Spawn the flask_server directly (not through the CLI, to keep this test
1355
1636
  # self-contained within run_tests.py) but using PYTHONPATH=SCRIPT_DIR so
1356
1637
  # the environment matches the CLI-spawned environment exactly.
@@ -1360,6 +1641,7 @@ def run_cli_decimal_live_tests(results: _Results) -> None:
1360
1641
  "--module", str(fixture_module),
1361
1642
  "--namespace", namespace,
1362
1643
  "--port", str(port),
1644
+ "--plan-file", str(plan_path),
1363
1645
  ],
1364
1646
  cwd=str(SCRIPT_DIR),
1365
1647
  env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
@@ -1382,6 +1664,7 @@ def run_cli_decimal_live_tests(results: _Results) -> None:
1382
1664
  if not ready:
1383
1665
  stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1384
1666
  proc.kill()
1667
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1385
1668
  results.fail(
1386
1669
  "cli_decimal.startup",
1387
1670
  f"Server did not become ready within 10 s. stderr: {stderr_out[:500]}",
@@ -1557,6 +1840,7 @@ def run_cli_decimal_live_tests(results: _Results) -> None:
1557
1840
  proc.wait(timeout=3)
1558
1841
  except subprocess.TimeoutExpired:
1559
1842
  proc.kill()
1843
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1560
1844
 
1561
1845
 
1562
1846
  # ---------------------------------------------------------------------------
@@ -1585,7 +1869,7 @@ def run_future_annotations_tests(results: _Results) -> None:
1585
1869
 
1586
1870
  fixture_module = (
1587
1871
  SCRIPT_DIR.parent / # packages/apigen
1588
- "plugins" / "py-flask" / "src" / "test" / "fixtures" / "future_annotations_api.py"
1872
+ "apigen-plugin-py-flask" / "src" / "test" / "fixtures" / "future_annotations_api.py"
1589
1873
  )
1590
1874
  if not fixture_module.exists():
1591
1875
  results.fail(
@@ -1674,12 +1958,15 @@ def run_future_annotations_tests(results: _Results) -> None:
1674
1958
  namespace = "futureann"
1675
1959
  base_url = f"http://127.0.0.1:{port}"
1676
1960
 
1961
+ plan_path, plan_tmpdir = _build_flask_plan(fixture_module, namespace, ops=ops)
1962
+
1677
1963
  proc = subprocess.Popen(
1678
1964
  [
1679
1965
  sys.executable, "-m", "apigen_python.flask_server",
1680
1966
  "--module", str(fixture_module),
1681
1967
  "--namespace", namespace,
1682
1968
  "--port", str(port),
1969
+ "--plan-file", str(plan_path),
1683
1970
  ],
1684
1971
  cwd=str(SCRIPT_DIR),
1685
1972
  env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
@@ -1702,6 +1989,7 @@ def run_future_annotations_tests(results: _Results) -> None:
1702
1989
  if not ready:
1703
1990
  stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1704
1991
  proc.kill()
1992
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1705
1993
  results.fail(
1706
1994
  "future_ann.startup",
1707
1995
  f"Server did not become ready within 10 s. stderr: {stderr_out[:500]}",
@@ -1782,6 +2070,7 @@ def run_future_annotations_tests(results: _Results) -> None:
1782
2070
  proc.wait(timeout=3)
1783
2071
  except subprocess.TimeoutExpired:
1784
2072
  proc.kill()
2073
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1785
2074
 
1786
2075
 
1787
2076
  # ---------------------------------------------------------------------------
@@ -1799,6 +2088,65 @@ def run_future_annotations_tests(results: _Results) -> None:
1799
2088
  # NEGATIVE CONTROL: JSON number response would fail typeof=='string' check
1800
2089
  # ---------------------------------------------------------------------------
1801
2090
 
2091
+ def _grpc_snake(words: list[str]) -> str:
2092
+ """Join tokenised Segment `words` into a snake_case identifier."""
2093
+ return "_".join(words)
2094
+
2095
+
2096
+ def _grpc_pascal(words: list[str]) -> str:
2097
+ """Join tokenised Segment `words` into a PascalCase identifier."""
2098
+ return "".join(w.capitalize() for w in words)
2099
+
2100
+
2101
+ def _build_grpc_plan(
2102
+ fixture_module: Path,
2103
+ namespace: str,
2104
+ ) -> tuple[Path, Path, str, str]:
2105
+ """Build a real `--plan-file` for `grpc_server.py` from a real
2106
+ `extract_module()` pass โ€” `grpc_server.py` has required `--plan-file`
2107
+ since the apigen-serve-core py-grpc-serve-split (it no longer
2108
+ self-extracts or re-derives package/service/method; see its module
2109
+ docstring).
2110
+
2111
+ Computes each op's `{package, service, method}` the SAME way the real
2112
+ `@adhd/apigen-engine-naming` `project(op).grpc` does (grpc_server.py's
2113
+ module docstring, mirrored from `naming.ts`):
2114
+ package = dotted, snake_cased, all `[namespace, ...path]` segments
2115
+ but the last
2116
+ service = Pascal-cased second-to-last segment (the "file" segment,
2117
+ falling back to the first segment when there is no
2118
+ second-to-last)
2119
+ method = Pascal-cased last segment (the "export" segment)
2120
+
2121
+ This suite tests `grpc_server.py`'s OWN dispatch/validation/logical-type
2122
+ behavior given a correct plan, not naming derivation itself (the real
2123
+ `project()`-derived package/service/method shape is independently
2124
+ proven byte-for-byte by `apigen-plugin-py-grpc`'s own TS test suite,
2125
+ `apigen-engine-naming/src/test/naming.spec.ts`).
2126
+
2127
+ Returns:
2128
+ `(plan_path, tmpdir, package, service)` โ€” caller removes `tmpdir`
2129
+ (best-effort) once the spawned server is done with it; `package`
2130
+ and `service` are returned so callers can build the expected
2131
+ `<package>.<Service>/<Method>` gRPC target without recomputing it.
2132
+ """
2133
+ ops = extract_module(str(fixture_module), namespace)
2134
+ grpc_map: dict[str, dict[str, str]] = {}
2135
+ package = ""
2136
+ service = ""
2137
+ for op in ops:
2138
+ all_segs = [op["namespace"]] + op["path"]
2139
+ package = ".".join(_grpc_snake(seg["words"]) for seg in all_segs[:-1])
2140
+ second_to_last = all_segs[-2] if len(all_segs) >= 2 else all_segs[0]
2141
+ service = _grpc_pascal(second_to_last["words"])
2142
+ method = _grpc_pascal(all_segs[-1]["words"])
2143
+ grpc_map[op["id"]] = {"package": package, "service": service, "method": method}
2144
+ tmpdir = Path(tempfile.mkdtemp(prefix="apigen-py-grpc-runtests-plan-"))
2145
+ plan_path = tmpdir / "plan.json"
2146
+ plan_path.write_text(json.dumps({"operations": ops, "grpc": grpc_map}))
2147
+ return plan_path, tmpdir, package, service
2148
+
2149
+
1802
2150
  def run_grpc_server_tests(results: _Results) -> None:
1803
2151
  """Run live gRPC tests against a real grpc_server subprocess (using grpcurl)."""
1804
2152
  import re as _re
@@ -1821,7 +2169,7 @@ def run_grpc_server_tests(results: _Results) -> None:
1821
2169
  # Locate the gRPC fixture module
1822
2170
  fixture_module = (
1823
2171
  SCRIPT_DIR.parent / # packages/apigen
1824
- "plugins" / "py-grpc" / "src" / "test" / "fixtures" / "grpc_api.py"
2172
+ "apigen-plugin-py-grpc" / "src" / "test" / "fixtures" / "grpc_api.py"
1825
2173
  )
1826
2174
  if not fixture_module.exists():
1827
2175
  results.fail(
@@ -1836,12 +2184,22 @@ def run_grpc_server_tests(results: _Results) -> None:
1836
2184
  namespace = "pkg"
1837
2185
  addr = f"localhost:{port}"
1838
2186
 
2187
+ plan_path, plan_tmpdir, grpc_package, grpc_service = _build_grpc_plan(
2188
+ fixture_module, namespace,
2189
+ )
2190
+ # Full gRPC method target, e.g. "pkg.grpc_api.GrpcApi/AddDecimal" โ€”
2191
+ # computed from the SAME injected plan the server itself consumes (never
2192
+ # a re-derived/hard-coded literal), so this suite can never drift from
2193
+ # `grpc_server.py`'s actual `--plan-file` contract.
2194
+ service_target = f"{grpc_package}.{grpc_service}"
2195
+
1839
2196
  proc = subprocess.Popen(
1840
2197
  [
1841
2198
  sys.executable, "-m", "apigen_python.grpc_server",
1842
2199
  "--module", str(fixture_module),
1843
2200
  "--namespace", namespace,
1844
2201
  "--port", str(port),
2202
+ "--plan-file", str(plan_path),
1845
2203
  ],
1846
2204
  cwd=str(SCRIPT_DIR),
1847
2205
  env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
@@ -1873,6 +2231,7 @@ def run_grpc_server_tests(results: _Results) -> None:
1873
2231
  if not ready:
1874
2232
  stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1875
2233
  proc.kill()
2234
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
1876
2235
  results.fail(
1877
2236
  "grpc.startup",
1878
2237
  f"Server did not emit ready signal within 10 s. stderr: {stderr_out[:500]}",
@@ -1890,32 +2249,37 @@ def run_grpc_server_tests(results: _Results) -> None:
1890
2249
  return r.returncode, r.stdout.strip(), r.stderr.strip()
1891
2250
 
1892
2251
  def _call(method: str, data: dict[str, Any]) -> tuple[int, str, str]:
1893
- """Call /<namespace>.<Namespace>Service/<method> with typed data payload."""
1894
- return _grpcurl("-d", json.dumps(data), addr, f"{namespace}.PkgService/{method}")
2252
+ """Call /<package>.<Service>/<Method> with typed data payload โ€” target
2253
+ computed from the SAME injected `--plan-file` the server consumes.
2254
+ `method` is the fixture's raw snake_case function name (e.g.
2255
+ "add_decimal"); it is Pascal-cased here to match the plan's
2256
+ `grpc_map[...]["method"]` the server actually registered."""
2257
+ method_pascal = _grpc_pascal(method.split("_"))
2258
+ return _grpcurl("-d", json.dumps(data), addr, f"{service_target}/{method_pascal}")
1895
2259
 
1896
2260
  try:
1897
2261
  # ------------------------------------------------------------------
1898
2262
  # Test 1: grpcurl list โ†’ service visible via reflection
1899
2263
  # ------------------------------------------------------------------
1900
2264
  code, out, err = _grpcurl(addr, "list")
1901
- if code == 0 and f"{namespace}.PkgService" in out:
2265
+ if code == 0 and service_target in out:
1902
2266
  results.ok("grpc.list.service_visible")
1903
2267
  else:
1904
2268
  results.fail(
1905
2269
  "grpc.list.service_visible",
1906
- f"expected pkg.PkgService in list; exit={code} out={out!r} err={err[:200]!r}",
2270
+ f"expected {service_target} in list; exit={code} out={out!r} err={err[:200]!r}",
1907
2271
  )
1908
2272
 
1909
2273
  # ------------------------------------------------------------------
1910
2274
  # Test 2: describe โ†’ methods present
1911
2275
  # ------------------------------------------------------------------
1912
- code, out, err = _grpcurl(addr, "describe", f"{namespace}.PkgService")
1913
- if code == 0 and "add_decimal" in out and "greet" in out:
2276
+ code, out, err = _grpcurl(addr, "describe", service_target)
2277
+ if code == 0 and "AddDecimal" in out and "Greet" in out:
1914
2278
  results.ok("grpc.describe.methods_present")
1915
2279
  else:
1916
2280
  results.fail(
1917
2281
  "grpc.describe.methods_present",
1918
- f"expected add_decimal, greet in describe; exit={code} out={out[:300]!r}",
2282
+ f"expected AddDecimal, Greet in describe; exit={code} out={out[:300]!r}",
1919
2283
  )
1920
2284
 
1921
2285
  # ------------------------------------------------------------------
@@ -2056,6 +2420,7 @@ def run_grpc_server_tests(results: _Results) -> None:
2056
2420
  proc.wait(timeout=3)
2057
2421
  except subprocess.TimeoutExpired:
2058
2422
  proc.kill()
2423
+ shutil.rmtree(plan_tmpdir, ignore_errors=True)
2059
2424
 
2060
2425
 
2061
2426
  # ---------------------------------------------------------------------------