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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 0.2.2 (2026-07-30)
2
+
3
+
4
+ ### ๐Ÿฉน Fixes
5
+
6
+ - **nx-build:** release-atomicity test coverage + CPU-guard flakiness + cache-input gap
7
+
8
+ - **apigen-python-env:** dist copy-plugin hardening alongside cache-input fix
9
+
10
+
11
+ ### โค๏ธ Thank You
12
+
13
+ - pseudosky
14
+
1
15
  ## 0.2.1 (2026-07-27)
2
16
 
3
17
 
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.2",
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.2.2"
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
@@ -278,7 +279,7 @@ class _RouteEntry:
278
279
  class _ServerState:
279
280
  """Immutable server configuration shared across all request threads."""
280
281
 
281
- __slots__ = ("namespace", "runtime", "operations", "route_map")
282
+ __slots__ = ("namespace", "runtime", "operations", "route_map", "route_map_by_op_id")
282
283
 
283
284
  def __init__(
284
285
  self,
@@ -299,6 +300,12 @@ class _ServerState:
299
300
  # composition artifact; the Python runtime receives bare params), so
300
301
  # no separate schema map is needed alongside this.
301
302
  self.route_map: dict[str, _RouteEntry] = {}
303
+ # (batch-rollout F8) op id -> _RouteEntry, built from the SAME loop
304
+ # that builds route_map above. `_dispatch_batch` resolves a batch
305
+ # request's `operation` field (an operation ID, not a URL path) to
306
+ # its real, dispatchable route entry through this index โ€” `route_map`
307
+ # alone (keyed by URL path) cannot serve that lookup.
308
+ self.route_map_by_op_id: dict[str, _RouteEntry] = {}
302
309
  for op in operations:
303
310
  entry = routes.get(op["id"])
304
311
  if entry is None:
@@ -308,7 +315,256 @@ class _ServerState:
308
315
  f"cover every operation extract_module() (phase 1) "
309
316
  f"produced, 1:1."
310
317
  )
311
- self.route_map[entry["route"]] = _RouteEntry(op=op, verb=entry["verb"])
318
+ route_entry = _RouteEntry(op=op, verb=entry["verb"])
319
+ self.route_map[entry["route"]] = route_entry
320
+ self.route_map_by_op_id[op["id"]] = route_entry
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Batch/bulk fan-out (`_batch/<kind>`) โ€” BATCH_0.0.1.md ยง5, this host's own
325
+ # executor implementation, ported from the design at
326
+ # tmp/apigen-batch-python-design.md (ยง2.2, corrected per ยง3.5's architect-
327
+ # review findings F2-F5). This is the Python-idiom equivalent of the TS
328
+ # `invokeBatch` (`apigen-engine-runtime/src/lib/batch.ts`), never a call into
329
+ # it โ€” the target op is dispatched locally, the same way every other route on
330
+ # this server already dispatches, N times, with concurrency/mode/onItemError
331
+ # control.
332
+ # ---------------------------------------------------------------------------
333
+
334
+ # (F7) Hard cap on requested concurrency โ€” a literal port of the TS
335
+ # constant's VALUE (intentionally duplicated per-host rather than unified
336
+ # into one cross-language source of truth; there is no cross-language
337
+ # constant-sharing mechanism in this repo today โ€” see
338
+ # `apigen-engine-runtime/src/lib/batch.ts:35`'s
339
+ # `export const BATCH_MAX_CONCURRENCY = 32;`).
340
+ BATCH_MAX_CONCURRENCY = 32
341
+
342
+ # Mirrors `apigen-engine-runtime/src/lib/batch.ts`'s
343
+ # `BATCH_DEFAULT_CONCURRENCY = 4` โ€” the fan-out width used when the request
344
+ # doesn't specify one.
345
+ BATCH_DEFAULT_CONCURRENCY = 4
346
+
347
+
348
+ def _not_attempted_reason(mode: str) -> dict[str, Any]:
349
+ """The per-item rejection reason for an item never attempted because an
350
+ earlier item aborted the whole batch (F5): a REAL
351
+ ``ApiError('internal', ...).to_json()``, never a hand-rolled dict with an
352
+ invented ``'cancelled'`` code that exists in neither ``errors.py``'s
353
+ taxonomy nor ``apigen-base-errors`` โ€” mirrors TS `invokeBatch`'s own
354
+ `notAttempted()` mapping (`apigen-engine-runtime/src/lib/batch.ts`)."""
355
+ if mode == "chained":
356
+ message = (
357
+ "batch aborted (mode=chained) โ€” an earlier item failed and this "
358
+ "item was never attempted"
359
+ )
360
+ else:
361
+ message = (
362
+ "batch aborted (onItemError=abort) โ€” an earlier item failed and "
363
+ "this item was never attempted"
364
+ )
365
+ return ApiError("internal", message).to_json()
366
+
367
+
368
+ def _run_batch_item(
369
+ state: _ServerState,
370
+ entry: _RouteEntry,
371
+ index: int,
372
+ item: Any,
373
+ envelope: dict[str, Any],
374
+ ) -> dict[str, Any]:
375
+ """Dispatch ONE batch item through the SAME validate -> decode logical
376
+ types -> invoke -> encode result pipeline `_ApigenHandler._dispatch`
377
+ already performs for a real single-op request (F4 โ€” logical-type
378
+ parity). Never a bypass of `_decode_params`/`pre_validated=True`/
379
+ `_encode_result`; the only difference from `_dispatch` is that a failure
380
+ here becomes a per-item `BatchItemResult` instead of an HTTP response.
381
+ """
382
+ input_schema: dict[str, Any] = entry.op.get("input", {})
383
+ try:
384
+ data = item if isinstance(item, dict) else {}
385
+
386
+ # SPEC ยง6: validate BEFORE dispatch, exactly like the single-op path.
387
+ if input_schema:
388
+ try:
389
+ validate(input_schema, data)
390
+ except ValidationError as exc:
391
+ raise ApiError(
392
+ "invalid_argument", f"input validation failed: {exc}"
393
+ ) from exc
394
+
395
+ # Schema-driven logical-type decode (Decimal, datetime, ...) โ€” F4.
396
+ decoded_data = _decode_params(data, input_schema)
397
+
398
+ host_req = HostRequest(
399
+ operation=entry.op,
400
+ data=decoded_data,
401
+ envelope=envelope,
402
+ transport="http",
403
+ pre_validated=True,
404
+ )
405
+ value = state.runtime.invoke_sync(host_req)
406
+ return {"index": index, "status": "fulfilled", "value": _encode_result(value)}
407
+ except ApiError as exc:
408
+ return {"index": index, "status": "rejected", "reason": exc.to_json()}
409
+ except Exception as exc: # noqa: BLE001 - must never leak a bare traceback over the wire
410
+ return {
411
+ "index": index,
412
+ "status": "rejected",
413
+ "reason": ApiError("internal", f"dispatch error: {exc}").to_json(),
414
+ }
415
+
416
+
417
+ def _resolve_batch_concurrency(mode: str, requested: Any, item_count: int) -> int:
418
+ """Mirror `apigen-engine-runtime/src/lib/batch.ts`'s `resolveConcurrency`:
419
+ `serial`/`chained` are always width-1 (never reach this โ€” callers only
420
+ invoke this for `mode == 'parallel'`), a bad/missing request falls back
421
+ to `BATCH_DEFAULT_CONCURRENCY`, and the result is always clamped to
422
+ `[1, BATCH_MAX_CONCURRENCY]` and never exceeds the item count (so
423
+ `ThreadPoolExecutor` is never constructed with `max_workers=0`).
424
+ """
425
+ if requested is None or not isinstance(requested, (int, float)) or requested < 1:
426
+ n = BATCH_DEFAULT_CONCURRENCY
427
+ else:
428
+ n = int(requested)
429
+ n = min(n, BATCH_MAX_CONCURRENCY)
430
+ return max(1, min(n, max(item_count, 1)))
431
+
432
+
433
+ def _dispatch_batch(
434
+ state: _ServerState, body: dict[str, Any], envelope: dict[str, Any]
435
+ ) -> list[dict[str, Any]]:
436
+ """Fan out `body['items']` to the real operation `body['operation']` โ€”
437
+ the Python host's OWN executor against BATCH_0.0.1.md ยง5's wire contract
438
+ (LEFT column only; this function is never a call into the TS
439
+ `invokeBatch`). Implements the architect-reviewed design's binding fixes
440
+ (`tmp/apigen-batch-python-design.md` ยง3.5):
441
+
442
+ - F2: `mode` branching โ€” `parallel` uses a bounded `ThreadPoolExecutor`;
443
+ `serial`/`chained` use a plain sequential loop; `chained` additionally
444
+ stops on the first failure regardless of `onItemError`.
445
+ - F3: `itemTimeoutMs` is rejected upfront (whole-batch `invalid_argument`)
446
+ โ€” `Runtime.invoke_sync` has no timeout kwarg / cancellation hook, so no
447
+ partial/best-effort cancellation is offered under the same field name
448
+ TS uses for real `AbortSignal`-based cancellation.
449
+ - F4: every item goes through `_run_batch_item`'s real
450
+ validate/decode/invoke/encode pipeline (logical-type parity with the
451
+ single-op path); the nested-batch guard checks the `_batch/` ID
452
+ PREFIX, never `op["kind"] == "batch"` (impossible โ€” `kind` is always
453
+ the real grouped kind, e.g. `'action'`/`'query'`).
454
+ - F5: every rejected result's `reason` is a real `ApiError(...).to_json()`
455
+ using an existing `errors.py` taxonomy code.
456
+
457
+ Raises:
458
+ ApiError: for whole-request-level rejections (bad `operation`,
459
+ unsupported `itemTimeoutMs`, malformed `items`/`mode`/
460
+ `onItemError`/`concurrency`) โ€” the caller maps this to a real
461
+ HTTP 4xx via `_send_error`, the SAME mechanism every other route
462
+ on this server already uses.
463
+ """
464
+ # F3 โ€” reject the WHOLE batch upfront; never a silently-weaker
465
+ # wait-only guarantee under the same field name TS uses for real
466
+ # cancellation.
467
+ if body.get("itemTimeoutMs") is not None:
468
+ raise ApiError(
469
+ "invalid_argument",
470
+ "itemTimeoutMs is not supported by the Python host",
471
+ )
472
+
473
+ target_op_id = body.get("operation")
474
+ if not isinstance(target_op_id, str) or not target_op_id:
475
+ raise ApiError(
476
+ "invalid_argument",
477
+ '"operation" must be a non-empty string naming the target operation id',
478
+ )
479
+ # F4 โ€” the nested-batch guard checks the ID PREFIX (`syntheticOp` always
480
+ # names batch mounts this way), NOT `op["kind"] == "batch"` (impossible:
481
+ # `kind` on a real target op is always its real grouped kind, never the
482
+ # literal string 'batch').
483
+ if target_op_id.startswith("_batch/"):
484
+ raise ApiError(
485
+ "invalid_argument",
486
+ f"not a real batchable operation: {target_op_id!r} is itself a "
487
+ "batch mount",
488
+ )
489
+ entry = state.route_map_by_op_id.get(target_op_id)
490
+ if entry is None:
491
+ raise ApiError(
492
+ "invalid_argument",
493
+ f"not a real batchable operation: {target_op_id!r} is not one "
494
+ "of this server's operations",
495
+ )
496
+
497
+ items = body.get("items")
498
+ if not isinstance(items, list):
499
+ raise ApiError("invalid_argument", '"items" must be an array')
500
+
501
+ mode = body.get("mode", "parallel")
502
+ if mode not in ("parallel", "serial", "chained"):
503
+ raise ApiError(
504
+ "invalid_argument",
505
+ '"mode" must be one of "parallel" | "serial" | "chained"',
506
+ )
507
+ on_item_error = body.get("onItemError", "continue")
508
+ if on_item_error not in ("continue", "abort"):
509
+ raise ApiError(
510
+ "invalid_argument",
511
+ '"onItemError" must be one of "continue" | "abort"',
512
+ )
513
+ requested_concurrency = body.get("concurrency")
514
+ if requested_concurrency is not None and not isinstance(requested_concurrency, (int, float)):
515
+ raise ApiError("invalid_argument", '"concurrency" must be a number')
516
+
517
+ results: list[dict[str, Any] | None] = [None] * len(items)
518
+
519
+ # F2 โ€” serial/chained: a plain sequential loop, no thread pool at all
520
+ # (strictly simpler than the parallel path below).
521
+ if mode in ("serial", "chained"):
522
+ stop = False
523
+ for i, item in enumerate(items):
524
+ if stop:
525
+ results[i] = {
526
+ "index": i,
527
+ "status": "rejected",
528
+ "reason": _not_attempted_reason(mode),
529
+ }
530
+ continue
531
+ result = _run_batch_item(state, entry, i, item, envelope)
532
+ results[i] = result
533
+ if result["status"] == "rejected" and (mode == "chained" or on_item_error == "abort"):
534
+ stop = True
535
+ return results # type: ignore[return-value]
536
+
537
+ # F2 โ€” parallel: bounded ThreadPoolExecutor, the idiomatic sync-Python
538
+ # equivalent of TS's worker-pool scheduler (runtime.invoke is NOT used โ€”
539
+ # introducing asyncio into this synchronous BaseHTTPRequestHandler server
540
+ # is out of scope; see the design doc's "Concurrency primitive" note).
541
+ concurrency = _resolve_batch_concurrency(mode, requested_concurrency, len(items))
542
+ aborted = threading.Event()
543
+
544
+ def run_one(i: int, item: Any) -> None:
545
+ if aborted.is_set():
546
+ results[i] = {
547
+ "index": i,
548
+ "status": "rejected",
549
+ "reason": _not_attempted_reason(mode),
550
+ }
551
+ return
552
+ result = _run_batch_item(state, entry, i, item, envelope)
553
+ results[i] = result
554
+ if result["status"] == "rejected" and on_item_error == "abort":
555
+ # Stop starting NEW items once one fails โ€” cannot un-submit
556
+ # already-running thread-pool work, identical in spirit to TS's
557
+ # `onItemError:'abort'` behavior (in-flight items are allowed to
558
+ # finish; they are not force-cancelled).
559
+ aborted.set()
560
+
561
+ if items:
562
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
563
+ futures = [pool.submit(run_one, i, item) for i, item in enumerate(items)]
564
+ for f in futures:
565
+ f.result() # propagate any genuinely unexpected exception loudly
566
+
567
+ return results # type: ignore[return-value]
312
568
 
313
569
 
314
570
  # ---------------------------------------------------------------------------
@@ -372,6 +628,46 @@ class _ApigenHandler(BaseHTTPRequestHandler):
372
628
 
373
629
  self._dispatch(op, data, envelope_from_headers=True)
374
630
 
631
+ # ------------------------------------------------------------------
632
+ # Route: POST <route> for a synthetic `_batch/<kind>` mount
633
+ # (BATCH_0.0.1.md ยง5 โ€” see `_dispatch_batch`'s own doc comment).
634
+ # ------------------------------------------------------------------
635
+
636
+ def _handle_batch_post(self, op: dict[str, Any]) -> None:
637
+ content_length = int(self.headers.get("Content-Length", "0") or "0")
638
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b"{}"
639
+ try:
640
+ body = json.loads(body_bytes or b"{}")
641
+ except json.JSONDecodeError as exc:
642
+ self._send_error(400, "invalid_argument", f"JSON parse error: {exc}")
643
+ return
644
+
645
+ if not isinstance(body, dict):
646
+ self._send_error(400, "invalid_argument", "request body must be a JSON object")
647
+ return
648
+
649
+ state: _ServerState = self.server.state # type: ignore[attr-defined]
650
+
651
+ # ยง9.1 envelope from headers, extracted once and shared across every
652
+ # item โ€” mirrors the TS `invokeBatch` call site, which also threads
653
+ # ONE shared `call.envelope` across every fanned-out item (never a
654
+ # per-item envelope).
655
+ headers_lc = {k.lower(): v for k, v in self.headers.items()}
656
+ envelope = _extract_envelope(op.get("input", {}), headers_lc)
657
+
658
+ try:
659
+ results = _dispatch_batch(state, body, envelope)
660
+ except ApiError as exc:
661
+ status = HTTP_STATUS.get(exc.code, 500)
662
+ self._send_error(status, exc.code, exc.message)
663
+ return
664
+ except Exception as exc:
665
+ self._send_error(500, "internal", f"batch dispatch error: {exc}")
666
+ return
667
+
668
+ body_out = _json_dumps(results).encode()
669
+ self._send_json(200, body_out)
670
+
375
671
  # ------------------------------------------------------------------
376
672
  # Dispatch (shared between GET/POST paths)
377
673
  # ------------------------------------------------------------------
@@ -480,7 +776,15 @@ class _ApigenHandler(BaseHTTPRequestHandler):
480
776
  state: _ServerState = self.server.state # type: ignore[attr-defined]
481
777
  entry = state.route_map.get(path_only)
482
778
  if entry is not None:
483
- self._handle_post(entry.op)
779
+ # (batch-rollout) A synthetic `_batch/<kind>` mount's op id is
780
+ # the authoritative marker (`syntheticOp` always names batch
781
+ # mounts this way โ€” F4) โ€” never `entry.op.get("kind")`, which on
782
+ # a batch mount is its real grouped kind (e.g. 'action'), not the
783
+ # literal string 'batch'.
784
+ if entry.op.get("id", "").startswith("_batch/"):
785
+ self._handle_batch_post(entry.op)
786
+ else:
787
+ self._handle_post(entry.op)
484
788
  return
485
789
 
486
790
  self._send_error(404, "not_found", f"no route for POST {path_only}")
@@ -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
  # ---------------------------------------------------------------------------