@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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/python/run_tests.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 /<
|
|
1894
|
-
|
|
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
|
|
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
|
|
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",
|
|
1913
|
-
if code == 0 and "
|
|
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
|
|
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
|
# ---------------------------------------------------------------------------
|