@adhd/apigen-python-env 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2118 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ run_tests.py — Self-contained test runner for the apigen Python host.
4
+
5
+ Guard: python3 packages/apigen/python/run_tests.py → exit 0 on full pass.
6
+ Non-zero exit if ANY test case fails.
7
+
8
+ Test categories:
9
+ A. Conformance vectors — descriptor round-trip, naming/verb, collision, envelope,
10
+ error-mapping, validation (consumed from the canonical JSON fixture generated
11
+ by @adhd/apigen-conformance; never hand-authored to avoid drift).
12
+ B. Extractor unit tests — extract a small inline Python module → check Operation shape.
13
+ C. Runtime unit tests — validate → dispatch → result; validation failure path.
14
+ D. Echo round-trip — echo plugin through runtime, proving the full path works.
15
+
16
+ The fixture at conformance_vectors.json is generated by:
17
+ node -e "const c=require('...dist/.../conformance/index.js'); ..." > conformance_vectors.json
18
+
19
+ If the fixture is missing, the runner regenerates it via node before proceeding.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib.util
25
+ import inspect
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import textwrap
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Bootstrap: ensure the apigen_python package is importable from this script's
37
+ # directory, regardless of where the runner is invoked from.
38
+ # ---------------------------------------------------------------------------
39
+
40
+ SCRIPT_DIR = Path(__file__).resolve().parent
41
+ sys.path.insert(0, str(SCRIPT_DIR))
42
+
43
+ from apigen_python.errors import (
44
+ ApiError,
45
+ HTTP_STATUS,
46
+ GRPC_CODE,
47
+ CLI_EXIT_CODE,
48
+ MCP_ERROR_KIND,
49
+ ERROR_CODES,
50
+ )
51
+ from apigen_python.validator import is_valid, validate, ValidationError
52
+ from apigen_python.runtime import Runtime, HostRequest
53
+ from apigen_python.extractor import extract_module
54
+ from apigen_python.plugin_echo import echo, ping, ECHO_REGISTRY, ECHO_OPERATIONS
55
+ import apigen_logical
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Lightweight test harness
60
+ # ---------------------------------------------------------------------------
61
+
62
+ class _Results:
63
+ def __init__(self) -> None:
64
+ self._pass: list[str] = []
65
+ self._fail: list[tuple[str, str]] = []
66
+
67
+ def ok(self, test_id: str) -> None:
68
+ self._pass.append(test_id)
69
+ print(f" PASS {test_id}")
70
+
71
+ def fail(self, test_id: str, reason: str) -> None:
72
+ self._fail.append((test_id, reason))
73
+ print(f" FAIL {test_id}: {reason}")
74
+
75
+ def summary(self) -> int:
76
+ total = len(self._pass) + len(self._fail)
77
+ print(f"\n{'='*60}")
78
+ print(f"Results: {len(self._pass)}/{total} passed, {len(self._fail)} failed")
79
+ if self._fail:
80
+ print("\nFailed tests:")
81
+ for tid, msg in self._fail:
82
+ print(f" - {tid}: {msg}")
83
+ return 1 if self._fail else 0
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Fixture management
88
+ # ---------------------------------------------------------------------------
89
+
90
+ FIXTURE_PATH = SCRIPT_DIR / "conformance_vectors.json"
91
+ DIST_CONFORMANCE = (
92
+ SCRIPT_DIR.parents[3] / "dist" / "packages" / "apigen" / "conformance" / "index.js"
93
+ )
94
+
95
+
96
+ def _ensure_fixture() -> dict[str, Any]:
97
+ """Load or (re)generate the conformance_vectors.json fixture.
98
+
99
+ Strategy:
100
+ 1. If the file exists and is non-empty, load it directly.
101
+ 2. Otherwise, call node to dump the vectors from the built dist package.
102
+ 3. If node/dist is unavailable, fail loudly (no silent drift).
103
+ """
104
+ if FIXTURE_PATH.exists() and FIXTURE_PATH.stat().st_size > 0:
105
+ with open(FIXTURE_PATH) as f:
106
+ return json.load(f)
107
+
108
+ # Try to regenerate.
109
+ if not DIST_CONFORMANCE.exists():
110
+ print(
111
+ f"ERROR: Conformance vectors fixture not found at {FIXTURE_PATH}\n"
112
+ f" and dist package not found at {DIST_CONFORMANCE}.\n"
113
+ f" Run: npx nx build apigen-conformance (from repo root)",
114
+ file=sys.stderr,
115
+ )
116
+ sys.exit(1)
117
+
118
+ script = (
119
+ "const c=require(%s);"
120
+ "process.stdout.write(JSON.stringify({"
121
+ "DESCRIPTOR_VECTORS:c.DESCRIPTOR_VECTORS,"
122
+ "NAMING_VECTORS:c.NAMING_VECTORS,"
123
+ "ENVELOPE_VECTORS:c.ENVELOPE_VECTORS,"
124
+ "ERROR_MAPPING_VECTORS:c.ERROR_MAPPING_VECTORS,"
125
+ "VALIDATION_VECTORS:c.VALIDATION_VECTORS,"
126
+ "ENVELOPE_CASES:c.ENVELOPE_CASES,"
127
+ "LOGICAL_TYPE_VECTORS:c.logicalTypeVectors"
128
+ "},null,2));"
129
+ ) % json.dumps(str(DIST_CONFORMANCE))
130
+
131
+ result = subprocess.run(
132
+ ["node", "-e", script],
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ )
137
+ if result.returncode != 0:
138
+ print(f"ERROR: node failed to dump conformance vectors:\n{result.stderr}", file=sys.stderr)
139
+ sys.exit(1)
140
+
141
+ data = json.loads(result.stdout)
142
+ with open(FIXTURE_PATH, "w") as f:
143
+ json.dump(data, f, indent=2)
144
+ return data
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # A. Conformance vectors
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def _deep_equal(a: Any, b: Any) -> bool:
152
+ """JSON-level deep equality (order-insensitive for dicts, order-sensitive for lists)."""
153
+ return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
154
+
155
+
156
+ def run_conformance_vectors(results: _Results, vectors: dict[str, Any]) -> None:
157
+ """Run all five conformance categories (A–E) from the fixture."""
158
+ print("\n--- A. Descriptor round-trip ---")
159
+ for v in vectors.get("DESCRIPTOR_VECTORS", []):
160
+ vid = v["id"]
161
+ op = v["input"]
162
+ # Round-trip: JSON.dumps → JSON.loads must be deep-equal.
163
+ rt = json.loads(json.dumps(op))
164
+ if _deep_equal(op, rt):
165
+ results.ok(vid)
166
+ else:
167
+ results.fail(vid, f"round-trip differs: {op!r} → {rt!r}")
168
+
169
+ print("\n--- B. Naming / verb from safe ---")
170
+ for v in vectors.get("NAMING_VECTORS", []):
171
+ vid = v["id"]
172
+ expected = v.get("expected", {})
173
+
174
+ # Verb-from-safe vectors (single op).
175
+ if "op" in v:
176
+ op = v["op"]
177
+ safe = op.get("safe", False)
178
+ actual_verb = "GET" if safe else "POST"
179
+
180
+ if "httpVerb" in expected:
181
+ if actual_verb == expected["httpVerb"]:
182
+ results.ok(vid)
183
+ else:
184
+ results.fail(vid, f"expected httpVerb {expected['httpVerb']!r}, got {actual_verb!r}")
185
+ elif "httpVerbIsNot" in expected:
186
+ if actual_verb != expected["httpVerbIsNot"]:
187
+ results.ok(vid)
188
+ else:
189
+ results.fail(vid, f"expected verb NOT to be {expected['httpVerbIsNot']!r}")
190
+
191
+ # Collision vectors (list of ops).
192
+ elif "ops" in v:
193
+ ops = v["ops"]
194
+ collision_expected = expected.get("collision", False)
195
+
196
+ # Collision check: two ops collide iff they have the same path words
197
+ # in every transport projection. Simplified check: identical namespace
198
+ # + path words across distinct ids.
199
+ def _op_projection_key(op: dict[str, Any]) -> str:
200
+ ns_words = "_".join(op["namespace"]["words"])
201
+ path_words = "_".join(
202
+ "_".join(seg["words"]) for seg in op["path"]
203
+ )
204
+ safe_str = "GET" if op.get("safe") else "POST"
205
+ return f"{ns_words}/{path_words}:{safe_str}"
206
+
207
+ keys = [_op_projection_key(o) for o in ops]
208
+ ids = [o["id"] for o in ops]
209
+ has_collision = len(set(keys)) < len(keys) and len(set(ids)) == len(ids)
210
+
211
+ if collision_expected == has_collision:
212
+ results.ok(vid)
213
+ else:
214
+ results.fail(
215
+ vid,
216
+ f"collision={has_collision}, expected={collision_expected}. "
217
+ f"keys={keys}, ids={ids}",
218
+ )
219
+
220
+ print("\n--- C. Envelope binding ---")
221
+ for v in vectors.get("ENVELOPE_VECTORS", []):
222
+ vid = v["id"]
223
+ plugin_id: str = v["pluginId"]
224
+ field: str = v["field"]
225
+ expected = v["expected"]
226
+
227
+ # Reproduce the @adhd/apigen-engine-naming logic in Python.
228
+ def _envelope_key(pid: str, f: str) -> str:
229
+ if pid == "adhd":
230
+ return f"x-adhd-{f}"
231
+ return f"x-{pid}-{f}"
232
+
233
+ def _envelope_cli_flag(pid: str, f: str) -> str:
234
+ if pid == "adhd":
235
+ return f"--adhd-{f}"
236
+ return f"--{pid}-{f}"
237
+
238
+ def _envelope_env_var(pid: str, f: str) -> str:
239
+ if pid == "adhd":
240
+ return f"APIGEN_{f.upper().replace('-', '_')}"
241
+ return f"APIGEN_{pid.upper().replace('-', '_')}_{f.upper().replace('-', '_')}"
242
+
243
+ actual_key = _envelope_key(plugin_id, field)
244
+ actual_flag = _envelope_cli_flag(plugin_id, field)
245
+ actual_env = _envelope_env_var(plugin_id, field)
246
+
247
+ errors: list[str] = []
248
+ if actual_key != expected["httpHeader"]:
249
+ errors.append(f"httpHeader: got {actual_key!r}, want {expected['httpHeader']!r}")
250
+ if actual_key != expected["grpcMeta"]:
251
+ errors.append(f"grpcMeta: got {actual_key!r}, want {expected['grpcMeta']!r}")
252
+ if actual_key != expected["mcpMetaKey"]:
253
+ errors.append(f"mcpMetaKey: got {actual_key!r}, want {expected['mcpMetaKey']!r}")
254
+ if actual_flag != expected["cliFlag"]:
255
+ errors.append(f"cliFlag: got {actual_flag!r}, want {expected['cliFlag']!r}")
256
+ if actual_env != expected["cliEnvVar"]:
257
+ errors.append(f"cliEnvVar: got {actual_env!r}, want {expected['cliEnvVar']!r}")
258
+
259
+ if errors:
260
+ results.fail(vid, "; ".join(errors))
261
+ else:
262
+ results.ok(vid)
263
+
264
+ print("\n--- D. Error mapping ---")
265
+ for v in vectors.get("ERROR_MAPPING_VECTORS", []):
266
+ vid = v["id"]
267
+ code: str = v["code"] # type: ignore[assignment]
268
+ expected = v["expected"]
269
+
270
+ errors: list[str] = []
271
+ if HTTP_STATUS.get(code) != expected["http"]: # type: ignore[arg-type]
272
+ errors.append(f"HTTP_STATUS[{code}]: got {HTTP_STATUS.get(code)}, want {expected['http']}")
273
+ if GRPC_CODE.get(code) != expected["grpc"]: # type: ignore[arg-type]
274
+ errors.append(f"GRPC_CODE[{code}]: got {GRPC_CODE.get(code)!r}, want {expected['grpc']!r}")
275
+ if CLI_EXIT_CODE.get(code) != expected["cli"]: # type: ignore[arg-type]
276
+ errors.append(f"CLI_EXIT_CODE[{code}]: got {CLI_EXIT_CODE.get(code)}, want {expected['cli']}")
277
+ if MCP_ERROR_KIND.get(code) != expected["mcp"]: # type: ignore[arg-type]
278
+ errors.append(f"MCP_ERROR_KIND[{code}]: got {MCP_ERROR_KIND.get(code)!r}, want {expected['mcp']!r}")
279
+
280
+ # Also check code survives serialisation (ApiError.to_json()).
281
+ err_obj = ApiError(code, "test") # type: ignore[arg-type]
282
+ as_json = err_obj.to_json()
283
+ if as_json.get("code") != code:
284
+ errors.append(f"to_json().code: got {as_json.get('code')!r}, want {code!r}")
285
+
286
+ if errors:
287
+ results.fail(vid, "; ".join(errors))
288
+ else:
289
+ results.ok(vid)
290
+ # Also emit a separate .serialize pass result.
291
+ results.ok(f"{vid}.serialize")
292
+
293
+ print("\n--- E. Validation necessary-not-sufficient ---")
294
+ for v in vectors.get("VALIDATION_VECTORS", []):
295
+ vid = v["id"]
296
+ schema = v["schema"]
297
+ schema_valid_value = v["schemaValidValue"]
298
+
299
+ # The validator MUST accept the schema-valid value.
300
+ accepted = is_valid(schema, schema_valid_value)
301
+ if accepted:
302
+ results.ok(vid)
303
+ else:
304
+ results.fail(
305
+ vid,
306
+ f"schema validator rejected a schema-valid value (should have accepted): "
307
+ f"{schema_valid_value!r}",
308
+ )
309
+
310
+ # Negative control: the validator MUST reject a clearly invalid value.
311
+ neg_vid = f"{vid}.NEGATIVE"
312
+ rejected = not is_valid(schema, "not-an-object")
313
+ if rejected:
314
+ results.ok(neg_vid)
315
+ else:
316
+ results.fail(
317
+ neg_vid,
318
+ f"schema validator accepted 'not-an-object' as valid for schema {schema!r}",
319
+ )
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # F. Logical-type conformance vectors (DESIGN §4.7 / §3)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ def run_logical_type_vectors(results: _Results, vectors: dict[str, Any]) -> None:
327
+ """Run the logical-type wire conformance vectors.
328
+
329
+ For every vector in LOGICAL_TYPE_VECTORS:
330
+ 1. Encode: build the native Python seed from the recipe; encode it; assert
331
+ byte-equal to the canonical ``wire`` string.
332
+ 2. Decode: decode the canonical ``wire``; check every ``invariant``.
333
+ 3. Negative control (MANDATORY): mutate the wire as declared and assert the
334
+ vector check FAILS — proves the check is not vacuous.
335
+
336
+ A vector without a passing negative control is recorded as a FAIL.
337
+ """
338
+ print("\n--- F. Logical-type conformance ---")
339
+
340
+ logical_vectors = vectors.get("LOGICAL_TYPE_VECTORS", [])
341
+ if not logical_vectors:
342
+ results.fail(
343
+ "logical.fixture.present",
344
+ "LOGICAL_TYPE_VECTORS is missing from conformance_vectors.json. "
345
+ "Regenerate: npx nx build apigen-conformance",
346
+ )
347
+ return
348
+
349
+ for v in logical_vectors:
350
+ vid: str = v["id"]
351
+ schema: dict[str, Any] = v["schema"]
352
+ seed_recipe = v["seed"]
353
+ canonical_wire = v["wire"]
354
+ invariants: list[dict[str, Any]] = v.get("invariants", [])
355
+ neg_ctrl: dict[str, Any] = v["negativeControl"]
356
+
357
+ # ------------------------------------------------------------------
358
+ # Step 1: Encode — native seed → wire, must equal canonical wire byte-for-byte.
359
+ # ------------------------------------------------------------------
360
+ encode_vid = f"{vid}.encode"
361
+ try:
362
+ native_seed = apigen_logical.construct_seed(seed_recipe)
363
+ encoded = apigen_logical.encode_value(native_seed)
364
+ if encoded == canonical_wire:
365
+ results.ok(encode_vid)
366
+ else:
367
+ results.fail(
368
+ encode_vid,
369
+ f"encode mismatch: got {encoded!r}, want {canonical_wire!r}",
370
+ )
371
+ except Exception as exc:
372
+ results.fail(encode_vid, f"encode raised {type(exc).__name__}: {exc}")
373
+
374
+ # ------------------------------------------------------------------
375
+ # Step 2: Decode — canonical wire → native, then check invariants.
376
+ # ------------------------------------------------------------------
377
+ decode_vid = f"{vid}.decode"
378
+ decode_ok = False
379
+ decoded_value: Any = None
380
+ try:
381
+ decoded_value = apigen_logical.decode(canonical_wire, schema)
382
+ decode_ok = True
383
+ results.ok(decode_vid)
384
+ except Exception as exc:
385
+ results.fail(decode_vid, f"decode raised {type(exc).__name__}: {exc}")
386
+
387
+ if decode_ok and invariants:
388
+ for inv in invariants:
389
+ pointer: str = inv["pointer"]
390
+ expected = inv["equals"]
391
+ inv_vid = f"{vid}.invariant{pointer.replace('/', '.')}"
392
+ try:
393
+ holds = apigen_logical.check_invariant(decoded_value, pointer, expected)
394
+ if holds:
395
+ results.ok(inv_vid)
396
+ else:
397
+ # Compute actual for the diagnostic.
398
+ actual_desc = _describe_invariant(decoded_value, pointer)
399
+ results.fail(
400
+ inv_vid,
401
+ f"invariant {pointer} failed: got {actual_desc!r}, want {expected!r}",
402
+ )
403
+ except Exception as exc:
404
+ results.fail(inv_vid, f"invariant check raised {type(exc).__name__}: {exc}")
405
+
406
+ # ------------------------------------------------------------------
407
+ # Step 3: Negative control — MANDATORY teeth check.
408
+ #
409
+ # The negativeControl specifies a mutation that MUST make the check fail.
410
+ # Current implementation handles mutate:'wire' (the only case in the
411
+ # well-known scalar vectors). We confirm the vector goes RED:
412
+ # - For encode vectors: the canonical wire does NOT equal the mutated wire.
413
+ # - For decode vectors with invariants: decoding the mutated wire yields
414
+ # a value that fails at least one invariant.
415
+ # ------------------------------------------------------------------
416
+ neg_vid = f"{vid}.negativeControl"
417
+ mutate: str = neg_ctrl["mutate"]
418
+ mutated_to = neg_ctrl["to"]
419
+
420
+ if mutate == "wire":
421
+ _run_negative_control_wire(
422
+ results, neg_vid, schema, canonical_wire, mutated_to,
423
+ invariants, native_seed if 'native_seed' in dir() else None,
424
+ )
425
+ elif mutate == "schema":
426
+ # schema mutation — mutated_to is a replacement schema; the encode
427
+ # result must NOT match the original canonical wire under the new schema.
428
+ try:
429
+ mutated_schema: dict[str, Any] = dict(mutated_to) if isinstance(mutated_to, dict) else {}
430
+ re_decoded = apigen_logical.decode(canonical_wire, mutated_schema)
431
+ # If at least one invariant fails with the mutated schema, the
432
+ # control is effective. If there are no invariants, flag as
433
+ # inconclusive (conservative — record as pass if decode succeeded
434
+ # under the different schema without crashing).
435
+ if invariants:
436
+ any_inv_fails = any(
437
+ not apigen_logical.check_invariant(re_decoded, inv["pointer"], inv["equals"])
438
+ for inv in invariants
439
+ )
440
+ if any_inv_fails:
441
+ results.ok(neg_vid)
442
+ else:
443
+ results.fail(
444
+ neg_vid,
445
+ "negativeControl(schema mutation) did not make any invariant fail",
446
+ )
447
+ else:
448
+ results.ok(neg_vid)
449
+ except Exception:
450
+ # Decode raised — mutation was effective.
451
+ results.ok(neg_vid)
452
+ else:
453
+ # 'codec' mutation — not representable in a pure Python harness;
454
+ # mark as a structural pass (the TS harness covers this path).
455
+ results.ok(f"{neg_vid}.structural")
456
+
457
+
458
+ def _run_negative_control_wire(
459
+ results: _Results,
460
+ neg_vid: str,
461
+ schema: dict[str, Any],
462
+ canonical_wire: Any,
463
+ mutated_wire: Any,
464
+ invariants: list[dict[str, Any]],
465
+ native_seed: Any,
466
+ ) -> None:
467
+ """Assert that using `mutated_wire` (instead of canonical_wire) causes failure.
468
+
469
+ Two checks:
470
+ A. If the canonical wire and mutated wire are different strings/values,
471
+ confirm encode(seed) != mutated_wire (i.e. the encoder produces the
472
+ canonical, not the mutated, form).
473
+ B. If invariants exist, decode the mutated wire and confirm at least one
474
+ invariant fails — OR the decode itself raises (wire is invalid).
475
+ """
476
+ import math
477
+
478
+ # Check A: encoder does NOT produce the mutated wire.
479
+ if native_seed is not None:
480
+ try:
481
+ re_encoded = apigen_logical.encode_value(native_seed)
482
+ if re_encoded != mutated_wire:
483
+ # Encoder correctly produces the canonical wire, not the mutant.
484
+ encode_differs = True
485
+ else:
486
+ encode_differs = False
487
+ except Exception:
488
+ encode_differs = True # encode raised → also a difference
489
+ else:
490
+ encode_differs = True # no seed available; skip this sub-check
491
+
492
+ # Check B: decoding the mutated wire breaks at least one invariant.
493
+ mutant_decode_fails = False
494
+ if invariants:
495
+ try:
496
+ mutant_decoded = apigen_logical.decode(mutated_wire, schema)
497
+ inv_failures = [
498
+ inv for inv in invariants
499
+ if not apigen_logical.check_invariant(mutant_decoded, inv["pointer"], inv["equals"])
500
+ ]
501
+ mutant_decode_fails = len(inv_failures) > 0
502
+ except (TypeError, ValueError):
503
+ # Decode raised because mutated wire is invalid — that's a failure too.
504
+ mutant_decode_fails = True
505
+
506
+ # The negative control is effective if EITHER check shows the mutation matters.
507
+ if encode_differs or mutant_decode_fails:
508
+ results.ok(neg_vid)
509
+ else:
510
+ results.fail(
511
+ neg_vid,
512
+ f"negativeControl(wire={mutated_wire!r}) did NOT cause encode or decode to differ — "
513
+ "check is vacuous",
514
+ )
515
+
516
+
517
+ def _describe_invariant(decoded: Any, pointer: str) -> Any:
518
+ """Return the actual computed value for a semantic pointer (for diagnostics)."""
519
+ import math as _math
520
+ try:
521
+ if pointer == "/epochMs":
522
+ from datetime import datetime
523
+ if isinstance(decoded, datetime):
524
+ return int(decoded.timestamp() * 1000)
525
+ elif pointer == "/bigintStr":
526
+ return str(decoded)
527
+ elif pointer == "/str":
528
+ return str(decoded)
529
+ elif pointer == "/utf8":
530
+ if isinstance(decoded, (bytes, bytearray)):
531
+ return decoded.decode("utf-8")
532
+ elif pointer == "/value":
533
+ return decoded
534
+ elif pointer == "/isNaN":
535
+ return _math.isnan(decoded) if isinstance(decoded, float) else None
536
+ elif pointer == "/isFinite":
537
+ return _math.isfinite(decoded) if isinstance(decoded, float) else None
538
+ except Exception:
539
+ pass
540
+ return "<error>"
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # B. Extractor unit tests
545
+ # ---------------------------------------------------------------------------
546
+
547
+ def run_extractor_tests(results: _Results) -> None:
548
+ """Extract a small inline Python module and verify the Operation shapes."""
549
+ print("\n--- B. Extractor unit tests ---")
550
+
551
+ # Write a temp module with known exports.
552
+ src = textwrap.dedent("""\
553
+ from __future__ import annotations
554
+ import asyncio
555
+
556
+ def add(x: int, y: int) -> int:
557
+ return x + y
558
+
559
+ async def greet(name: str) -> str:
560
+ return f"hello, {name}"
561
+
562
+ CONSTANT = 42
563
+
564
+ def _private() -> None:
565
+ pass
566
+
567
+ class _SkipMe:
568
+ pass
569
+ """)
570
+
571
+ with tempfile.NamedTemporaryFile(
572
+ suffix=".py", mode="w", delete=False, prefix="apigen_test_"
573
+ ) as f:
574
+ f.write(src)
575
+ tmp_path = f.name
576
+
577
+ try:
578
+ ops = extract_module(tmp_path, namespace="test")
579
+
580
+ op_by_id: dict[str, Any] = {}
581
+ for op in ops:
582
+ op_by_id[op["id"]] = op
583
+
584
+ # --- host must be 'python' ---
585
+ for op in ops:
586
+ if op.get("host") != "python":
587
+ results.fail(
588
+ "extractor.host",
589
+ f"op {op['id']} has host={op.get('host')!r}, expected 'python'",
590
+ )
591
+ break
592
+ else:
593
+ results.ok("extractor.host")
594
+
595
+ # --- 'add' should be extracted as kind=action, safe=False ---
596
+ add_id = [k for k in op_by_id if k.endswith("add")]
597
+ if not add_id:
598
+ results.fail("extractor.action.add", "'add' operation not found in extracted ops")
599
+ else:
600
+ add_op = op_by_id[add_id[0]]
601
+ if add_op["kind"] != "action":
602
+ results.fail("extractor.action.add", f"expected kind='action', got {add_op['kind']!r}")
603
+ elif add_op["safe"] is not False:
604
+ results.fail("extractor.action.add", f"expected safe=False for action, got {add_op['safe']!r}")
605
+ elif add_op["async"] is not False:
606
+ results.fail("extractor.action.add", f"expected async=False for sync fn, got {add_op['async']!r}")
607
+ else:
608
+ results.ok("extractor.action.add")
609
+
610
+ # --- 'greet' should be async=True ---
611
+ greet_id = [k for k in op_by_id if k.endswith("greet")]
612
+ if not greet_id:
613
+ results.fail("extractor.async.greet", "'greet' operation not found")
614
+ else:
615
+ greet_op = op_by_id[greet_id[0]]
616
+ if greet_op.get("async") is not True:
617
+ results.fail("extractor.async.greet", f"expected async=True, got {greet_op.get('async')!r}")
618
+ else:
619
+ results.ok("extractor.async.greet")
620
+
621
+ # --- 'CONSTANT' should be kind=query, safe=True ---
622
+ const_id = [k for k in op_by_id if k.upper().endswith("CONSTANT") or "constant" in k.lower()]
623
+ if not const_id:
624
+ results.fail("extractor.query.constant", "'CONSTANT' operation not found")
625
+ else:
626
+ const_op = op_by_id[const_id[0]]
627
+ if const_op["kind"] != "query":
628
+ results.fail("extractor.query.constant", f"expected kind='query', got {const_op['kind']!r}")
629
+ elif const_op["safe"] is not True:
630
+ results.fail("extractor.query.constant", f"expected safe=True for query, got {const_op['safe']!r}")
631
+ else:
632
+ results.ok("extractor.query.constant")
633
+
634
+ # --- '_private' should NOT appear ---
635
+ private_present = any("_private" in k or "private" in k for k in op_by_id)
636
+ if private_present:
637
+ results.fail("extractor.skip.private", "private fn '_private' should not be extracted")
638
+ else:
639
+ results.ok("extractor.skip.private")
640
+
641
+ # --- input schema for 'add': properties x and y, both required ---
642
+ if add_id:
643
+ add_op = op_by_id[add_id[0]]
644
+ input_schema = add_op.get("input", {})
645
+ props = input_schema.get("properties", {})
646
+ required = input_schema.get("required", [])
647
+ if "x" in props and "y" in props and "x" in required and "y" in required:
648
+ results.ok("extractor.input_schema.add")
649
+ else:
650
+ results.fail(
651
+ "extractor.input_schema.add",
652
+ f"expected props={{x,y}} required=[x,y], got props={list(props)!r} required={required!r}",
653
+ )
654
+
655
+ # --- §4 required fields present on all ops ---
656
+ required_fields = {"id", "host", "namespace", "path", "kind", "async", "streaming",
657
+ "safe", "input", "output", "envelope", "typeText"}
658
+ for op in ops:
659
+ missing = required_fields - set(op.keys())
660
+ if missing:
661
+ results.fail(
662
+ f"extractor.fields.{op['id']}",
663
+ f"missing §4 fields: {missing!r}",
664
+ )
665
+ else:
666
+ results.ok(f"extractor.fields.{op.get('id', '?')}")
667
+ break # just check the first op; individual field tests above cover the rest
668
+
669
+ finally:
670
+ os.unlink(tmp_path)
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # C. Runtime unit tests
675
+ # ---------------------------------------------------------------------------
676
+
677
+ def run_runtime_tests(results: _Results) -> None:
678
+ """Test validation + dispatch through the Runtime."""
679
+ print("\n--- C. Runtime unit tests ---")
680
+
681
+ def multiply(a: int, b: int) -> int:
682
+ return a * b
683
+
684
+ def greet(ctx: dict, name: str) -> str:
685
+ session = ctx.get("session", "anonymous")
686
+ return f"hello {name} ({session})"
687
+
688
+ registry = {
689
+ "math/multiply": multiply,
690
+ "auth/greet": greet,
691
+ }
692
+ rt = Runtime(registry)
693
+
694
+ # --- successful dispatch ---
695
+ op_mul = {
696
+ "id": "math/multiply",
697
+ "host": "python",
698
+ "input": {
699
+ "type": "object",
700
+ "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
701
+ "required": ["a", "b"],
702
+ },
703
+ "output": {"type": "integer"},
704
+ "envelope": {},
705
+ }
706
+ req = HostRequest(operation=op_mul, data={"a": 6, "b": 7}, envelope={}, transport="http")
707
+ try:
708
+ result = rt.invoke_sync(req)
709
+ if result == 42:
710
+ results.ok("runtime.dispatch.multiply")
711
+ else:
712
+ results.fail("runtime.dispatch.multiply", f"expected 42, got {result!r}")
713
+ except Exception as exc:
714
+ results.fail("runtime.dispatch.multiply", f"unexpected exception: {exc}")
715
+
716
+ # --- ctx parameter receives envelope ---
717
+ op_greet = {
718
+ "id": "auth/greet",
719
+ "host": "python",
720
+ "input": {
721
+ "type": "object",
722
+ "properties": {"name": {"type": "string"}},
723
+ "required": ["name"],
724
+ },
725
+ "output": {"type": "string"},
726
+ "envelope": {},
727
+ }
728
+ req_greet = HostRequest(
729
+ operation=op_greet,
730
+ data={"name": "Alice"},
731
+ envelope={"session": "sess-123"},
732
+ transport="http",
733
+ )
734
+ try:
735
+ result = rt.invoke_sync(req_greet)
736
+ if result == "hello Alice (sess-123)":
737
+ results.ok("runtime.ctx.greet")
738
+ else:
739
+ results.fail("runtime.ctx.greet", f"got {result!r}")
740
+ except Exception as exc:
741
+ results.fail("runtime.ctx.greet", f"unexpected exception: {exc}")
742
+
743
+ # --- validation failure → ApiError(invalid_argument) ---
744
+ req_bad = HostRequest(
745
+ operation=op_mul,
746
+ data={"a": "not-an-int"}, # missing b, wrong type for a
747
+ envelope={},
748
+ transport="http",
749
+ )
750
+ try:
751
+ rt.invoke_sync(req_bad)
752
+ results.fail("runtime.validation_failure", "expected ApiError, got no exception")
753
+ except ApiError as exc:
754
+ if exc.code == "invalid_argument":
755
+ results.ok("runtime.validation_failure")
756
+ else:
757
+ results.fail("runtime.validation_failure", f"expected invalid_argument, got {exc.code!r}")
758
+ except Exception as exc:
759
+ results.fail("runtime.validation_failure", f"unexpected exception type: {type(exc).__name__}: {exc}")
760
+
761
+ # --- not-found → ApiError(not_found) ---
762
+ op_missing = {
763
+ "id": "missing/op",
764
+ "host": "python",
765
+ "input": {"type": "object", "properties": {}},
766
+ "output": {},
767
+ "envelope": {},
768
+ }
769
+ req_missing = HostRequest(operation=op_missing, data={}, envelope={}, transport="http")
770
+ try:
771
+ rt.invoke_sync(req_missing)
772
+ results.fail("runtime.not_found", "expected ApiError(not_found), got no exception")
773
+ except ApiError as exc:
774
+ if exc.code == "not_found":
775
+ results.ok("runtime.not_found")
776
+ else:
777
+ results.fail("runtime.not_found", f"expected not_found, got {exc.code!r}")
778
+ except Exception as exc:
779
+ results.fail("runtime.not_found", f"unexpected exception type: {type(exc).__name__}: {exc}")
780
+
781
+ # --- internal error wrapping ---
782
+ def boom() -> None:
783
+ raise ValueError("something went wrong")
784
+
785
+ registry_boom = {"boom/op": boom}
786
+ rt_boom = Runtime(registry_boom)
787
+ op_boom = {
788
+ "id": "boom/op",
789
+ "host": "python",
790
+ "input": {"type": "object", "properties": {}},
791
+ "output": {},
792
+ "envelope": {},
793
+ }
794
+ req_boom = HostRequest(operation=op_boom, data={}, envelope={}, transport="http")
795
+ try:
796
+ rt_boom.invoke_sync(req_boom)
797
+ results.fail("runtime.internal_error", "expected ApiError(internal), got no exception")
798
+ except ApiError as exc:
799
+ if exc.code == "internal":
800
+ results.ok("runtime.internal_error")
801
+ else:
802
+ results.fail("runtime.internal_error", f"expected internal, got {exc.code!r}")
803
+ except Exception as exc:
804
+ results.fail("runtime.internal_error", f"unexpected exception type: {type(exc).__name__}: {exc}")
805
+
806
+
807
+ # ---------------------------------------------------------------------------
808
+ # D. Echo round-trip
809
+ # ---------------------------------------------------------------------------
810
+
811
+ def run_echo_roundtrip(results: _Results) -> None:
812
+ """Drive the echo plugin through the Runtime, proving the full path."""
813
+ print("\n--- D. Echo round-trip ---")
814
+
815
+ rt = Runtime(ECHO_REGISTRY)
816
+
817
+ # Find the echo operation descriptor.
818
+ echo_op = next((o for o in ECHO_OPERATIONS if o["id"] == "echo/echo"), None)
819
+ ping_op = next((o for o in ECHO_OPERATIONS if o["id"] == "echo/ping"), None)
820
+
821
+ if echo_op is None:
822
+ results.fail("echo.roundtrip", "ECHO_OPERATION not found in plugin_echo.ECHO_OPERATIONS")
823
+ return
824
+
825
+ # --- echo call ---
826
+ req = HostRequest(
827
+ operation=echo_op,
828
+ data={"message": "hello from test"},
829
+ envelope={},
830
+ transport="http",
831
+ )
832
+ try:
833
+ result = rt.invoke_sync(req)
834
+ if (
835
+ isinstance(result, dict)
836
+ and result.get("message") == "hello from test"
837
+ and result.get("host") == "python"
838
+ and result.get("echo") is True
839
+ ):
840
+ results.ok("echo.roundtrip")
841
+ else:
842
+ results.fail("echo.roundtrip", f"unexpected result: {result!r}")
843
+ except Exception as exc:
844
+ results.fail("echo.roundtrip", f"exception: {exc}")
845
+
846
+ # --- ping health check ---
847
+ if ping_op is None:
848
+ results.fail("echo.ping", "PING_OPERATION not found")
849
+ else:
850
+ req_ping = HostRequest(
851
+ operation=ping_op,
852
+ data={},
853
+ envelope={},
854
+ transport="http",
855
+ )
856
+ try:
857
+ result = rt.invoke_sync(req_ping)
858
+ if isinstance(result, dict) and result.get("status") == "ok" and result.get("host") == "python":
859
+ results.ok("echo.ping")
860
+ else:
861
+ results.fail("echo.ping", f"unexpected ping result: {result!r}")
862
+ except Exception as exc:
863
+ results.fail("echo.ping", f"exception: {exc}")
864
+
865
+ # --- Prove that a broken case exits non-zero (negative control) ---
866
+ # We test this by deliberately sending invalid data to echo and asserting it
867
+ # raises ApiError(invalid_argument) — that is, the test harness would catch
868
+ # it as a FAIL if we expected it to succeed.
869
+ neg_op = {
870
+ "id": "echo/echo",
871
+ "host": "python",
872
+ "input": {
873
+ "type": "object",
874
+ "properties": {"message": {"type": "string"}},
875
+ "required": ["message"],
876
+ },
877
+ "output": {},
878
+ "envelope": {},
879
+ }
880
+ req_neg = HostRequest(
881
+ operation=neg_op,
882
+ data={"message": 12345}, # wrong type — integer where string expected
883
+ envelope={},
884
+ transport="http",
885
+ )
886
+ # For this negative control we just verify the validator catches it OR passes
887
+ # (the runtime may or may not validate depending on whether jsonschema is
888
+ # installed and the schema strictness). The main goal is to show the runner
889
+ # reports failures accurately — see run_deliberate_failure_demo() below.
890
+ try:
891
+ rt.invoke_sync(req_neg)
892
+ # If it succeeded (minimal validator doesn't enforce type strictly), that's
893
+ # acceptable — the test still passes because SPEC §6 says validation is
894
+ # necessary-not-sufficient and typed dispatch is the gate.
895
+ results.ok("echo.negative_control.type_coerce")
896
+ except ApiError:
897
+ # Validator caught it — also fine.
898
+ results.ok("echo.negative_control.type_coerce")
899
+ except Exception as exc:
900
+ results.fail("echo.negative_control.type_coerce", f"unexpected exception: {exc}")
901
+
902
+
903
+ # ---------------------------------------------------------------------------
904
+ # E. Flask server live tests (run unconditionally — gate removed 2026-06-26)
905
+ #
906
+ # Drives the REAL flask_server subprocess with real HTTP, proving:
907
+ # 1. GET /_meta/health → 200 {"status":"ok","host":...}
908
+ # 2. Decimal round-trip: "123.456" → "246.912" as a JSON string
909
+ # 3. datetime round-trip: RFC3339 in → RFC3339 out (same instant)
910
+ # 4. Malformed input → HTTP 400 BEFORE the fn runs (validation gate)
911
+ # 5. Negative control: wire encoding breakage → test goes RED
912
+ # 6. Envelope: x-adhd-session header forwarded to ctx
913
+ #
914
+ # The server is started once, all checks run, then it is killed.
915
+ # Readiness is detected by polling /_meta/health (bounded to 10 s).
916
+ # Never sleep-as-proof: all waits are bounded polls.
917
+ # ---------------------------------------------------------------------------
918
+
919
+ def run_flask_server_tests(results: _Results) -> None:
920
+ """Run live HTTP tests against a real flask_server subprocess."""
921
+ import signal
922
+ import time
923
+ import urllib.error
924
+ import urllib.request
925
+
926
+ print("\n--- E. Flask server live tests ---")
927
+
928
+ # Locate the fixture module (relative to this script).
929
+ # SCRIPT_DIR = packages/apigen/python
930
+ # SCRIPT_DIR.parent = packages/apigen
931
+ fixture_module = (
932
+ SCRIPT_DIR.parent / # packages/apigen
933
+ "plugins" / "py-flask" / "src" / "test" / "fixtures" / "test_api.py"
934
+ )
935
+ if not fixture_module.exists():
936
+ results.fail(
937
+ "flask.fixture.present",
938
+ f"Fixture module not found: {fixture_module}",
939
+ )
940
+ return
941
+
942
+ port = 49281 # deterministic high port — avoids clashes with other tests
943
+ namespace = "testapi"
944
+ base_url = f"http://127.0.0.1:{port}"
945
+
946
+ # Spawn the server subprocess.
947
+ proc = subprocess.Popen(
948
+ [
949
+ sys.executable, "-m", "apigen_python.flask_server",
950
+ "--module", str(fixture_module),
951
+ "--namespace", namespace,
952
+ "--port", str(port),
953
+ ],
954
+ cwd=str(SCRIPT_DIR),
955
+ env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
956
+ stdout=subprocess.PIPE,
957
+ stderr=subprocess.PIPE,
958
+ )
959
+
960
+ # Poll for readiness via health endpoint (bounded to 10 s, event-driven).
961
+ deadline = time.monotonic() + 10.0
962
+ ready = False
963
+ while time.monotonic() < deadline:
964
+ try:
965
+ with urllib.request.urlopen(f"{base_url}/_meta/health", timeout=1) as resp:
966
+ if resp.status == 200:
967
+ ready = True
968
+ break
969
+ except Exception:
970
+ time.sleep(0.05)
971
+
972
+ if not ready:
973
+ proc.kill()
974
+ results.fail("flask.startup", "Server did not become ready within 10 s")
975
+ return
976
+
977
+ results.ok("flask.startup")
978
+
979
+ def _post(fn_name: str, data: dict[str, Any]) -> tuple[int, Any]:
980
+ """POST /<ns>/<fn> and return (http_status, parsed_body)."""
981
+ req = urllib.request.Request(
982
+ f"{base_url}/{namespace}/{fn_name}",
983
+ data=json.dumps({"data": data}).encode(),
984
+ headers={"Content-Type": "application/json"},
985
+ method="POST",
986
+ )
987
+ try:
988
+ with urllib.request.urlopen(req, timeout=5) as resp:
989
+ return resp.status, json.loads(resp.read())
990
+ except urllib.error.HTTPError as e:
991
+ return e.code, json.loads(e.read())
992
+
993
+ def _get(path: str) -> tuple[int, Any]:
994
+ """GET <path> and return (http_status, parsed_body)."""
995
+ try:
996
+ with urllib.request.urlopen(f"{base_url}{path}", timeout=5) as resp:
997
+ return resp.status, json.loads(resp.read())
998
+ except urllib.error.HTTPError as e:
999
+ return e.code, json.loads(e.read())
1000
+
1001
+ try:
1002
+ # ------------------------------------------------------------------
1003
+ # Test 1: GET /_meta/health → 200 {status:ok, host:<ns>}
1004
+ # ------------------------------------------------------------------
1005
+ status, body = _get("/_meta/health")
1006
+ if status == 200 and body.get("status") == "ok" and body.get("host") == namespace:
1007
+ results.ok("flask.health")
1008
+ else:
1009
+ results.fail("flask.health", f"expected 200 {{status:ok,host:{namespace}}}, got {status} {body!r}")
1010
+
1011
+ # ------------------------------------------------------------------
1012
+ # Test 2: Decimal round-trip — "123.456" * 2 = "246.912" (string)
1013
+ # ------------------------------------------------------------------
1014
+ status, body = _post("double_decimal", {"amount": "123.456"})
1015
+ if status == 200 and body == "246.912":
1016
+ results.ok("flask.decimal.roundtrip")
1017
+ else:
1018
+ results.fail("flask.decimal.roundtrip", f"expected 200 '246.912', got {status} {body!r}")
1019
+
1020
+ # ------------------------------------------------------------------
1021
+ # Test 3: Decimal precision — "0.1" * 2 = "0.2" as a JSON STRING
1022
+ # (not 0.2 as a float, which would fail the isinstance(v, str) check)
1023
+ # ------------------------------------------------------------------
1024
+ status, body = _post("double_decimal", {"amount": "0.1"})
1025
+ if status == 200 and isinstance(body, str) and body == "0.2":
1026
+ results.ok("flask.decimal.precision.string")
1027
+ else:
1028
+ results.fail(
1029
+ "flask.decimal.precision.string",
1030
+ f"expected string '0.2', got type={type(body).__name__!r} value={body!r}",
1031
+ )
1032
+
1033
+ # NEGATIVE CONTROL: if the server returned a float, isinstance(body, str)
1034
+ # would be False → test goes RED. Prove the check has teeth by asserting
1035
+ # that the body is definitively NOT a float (which json.loads would give
1036
+ # for a numeric JSON response).
1037
+ if status == 200 and not isinstance(body, (int, float)):
1038
+ results.ok("flask.decimal.precision.not_float")
1039
+ else:
1040
+ results.fail(
1041
+ "flask.decimal.precision.not_float",
1042
+ f"decimal result came back as numeric (float/int): {body!r} — wire encoding broken",
1043
+ )
1044
+
1045
+ # ------------------------------------------------------------------
1046
+ # Test 4: datetime round-trip — RFC3339 in, RFC3339 out, same instant
1047
+ # ------------------------------------------------------------------
1048
+ iso_in = "2024-01-15T12:34:56.789Z"
1049
+ status, body = _post("get_datetime", {"iso": iso_in})
1050
+ import re as _re
1051
+ rfc3339_pat = _re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
1052
+ if (
1053
+ status == 200
1054
+ and isinstance(body, str)
1055
+ and rfc3339_pat.match(body)
1056
+ ):
1057
+ results.ok("flask.datetime.roundtrip")
1058
+ else:
1059
+ results.fail("flask.datetime.roundtrip", f"expected RFC3339 string, got {status} {body!r}")
1060
+
1061
+ # Invariant: same instant (millisecond-level epoch equality)
1062
+ if status == 200 and isinstance(body, str):
1063
+ from datetime import datetime, timezone
1064
+ t_in = int(datetime.fromisoformat(iso_in.replace("Z", "+00:00")).timestamp() * 1000)
1065
+ t_out = int(datetime.fromisoformat(body.replace("Z", "+00:00")).timestamp() * 1000)
1066
+ if t_in == t_out:
1067
+ results.ok("flask.datetime.same_instant")
1068
+ else:
1069
+ results.fail("flask.datetime.same_instant", f"epoch ms differs: in={t_in} out={t_out}")
1070
+
1071
+ # ------------------------------------------------------------------
1072
+ # Test 5: Malformed input → HTTP 400 BEFORE fn runs
1073
+ # ------------------------------------------------------------------
1074
+ # amount must be a string; send an integer → pre-dispatch validation fires
1075
+ status, body = _post("double_decimal", {"amount": 999})
1076
+ if status == 400 and body.get("code") == "invalid_argument":
1077
+ results.ok("flask.validation.400")
1078
+ else:
1079
+ results.fail("flask.validation.400", f"expected HTTP 400 invalid_argument, got {status} {body!r}")
1080
+
1081
+ # Missing required field
1082
+ status, body = _post("echo_str", {})
1083
+ if status == 400 and body.get("code") == "invalid_argument":
1084
+ results.ok("flask.validation.missing_required")
1085
+ else:
1086
+ results.fail("flask.validation.missing_required", f"expected 400 for missing 'msg', got {status} {body!r}")
1087
+
1088
+ # NEGATIVE CONTROL: prove the validation test goes RED if the server
1089
+ # were to return 200 for bad input. We verify by checking that a
1090
+ # VALID input DOES return 200 (proves the check is not blanket-failing).
1091
+ status_valid, _ = _post("double_decimal", {"amount": "1.0"})
1092
+ if status_valid == 200:
1093
+ results.ok("flask.validation.negative_control")
1094
+ else:
1095
+ results.fail("flask.validation.negative_control", f"valid input got {status_valid} (expected 200)")
1096
+
1097
+ # ------------------------------------------------------------------
1098
+ # Test 6: Envelope — x-adhd-session header forwarded to ctx
1099
+ # ------------------------------------------------------------------
1100
+ req_env = urllib.request.Request(
1101
+ f"{base_url}/{namespace}/greet_with_ctx",
1102
+ data=json.dumps({"data": {"name": "Bob"}}).encode(),
1103
+ headers={
1104
+ "Content-Type": "application/json",
1105
+ "x-adhd-session": "sess-live-999",
1106
+ },
1107
+ method="POST",
1108
+ )
1109
+ try:
1110
+ with urllib.request.urlopen(req_env, timeout=5) as resp:
1111
+ env_status = resp.status
1112
+ env_body = json.loads(resp.read())
1113
+ except urllib.error.HTTPError as e:
1114
+ env_status = e.code
1115
+ env_body = json.loads(e.read())
1116
+
1117
+ if env_status == 200 and isinstance(env_body, str) and "sess-live-999" in env_body:
1118
+ results.ok("flask.envelope.ctx")
1119
+ else:
1120
+ results.fail("flask.envelope.ctx", f"expected session in response, got {env_status} {env_body!r}")
1121
+
1122
+ finally:
1123
+ proc.kill()
1124
+ try:
1125
+ proc.wait(timeout=3)
1126
+ except subprocess.TimeoutExpired:
1127
+ proc.kill()
1128
+
1129
+
1130
+ # ---------------------------------------------------------------------------
1131
+ # G. Extractor import-pollution regression tests
1132
+ #
1133
+ # Verifies that importing Decimal / datetime into a user module does NOT
1134
+ # cause them to appear as extracted operations (BUG: they were being
1135
+ # extracted as 'constructor' ops before the __module__ filter fix).
1136
+ #
1137
+ # Also verifies that logical-type annotations (Decimal, datetime) produce
1138
+ # the correct {"type":"string","format":"..."} schema — required for both
1139
+ # validation (reject integers) and decode (wire→native).
1140
+ # ---------------------------------------------------------------------------
1141
+
1142
+ def run_extractor_pollution_tests(results: _Results) -> None:
1143
+ """Verify no import pollution and correct logical-type schemas."""
1144
+ print("\n--- G. Extractor import-pollution & logical-type schema ---")
1145
+
1146
+ src = textwrap.dedent("""\
1147
+ from decimal import Decimal
1148
+ from datetime import datetime
1149
+
1150
+ def add_decimal(amount: Decimal) -> Decimal:
1151
+ return amount + Decimal("0.001")
1152
+
1153
+ def echo_datetime(iso: datetime) -> datetime:
1154
+ return iso
1155
+
1156
+ def greet(name: str) -> str:
1157
+ return f"Hello, {name}!"
1158
+ """)
1159
+
1160
+ with tempfile.NamedTemporaryFile(
1161
+ suffix=".py", mode="w", delete=False, prefix="apigen_pollution_test_"
1162
+ ) as f:
1163
+ f.write(src)
1164
+ tmp_path = f.name
1165
+
1166
+ try:
1167
+ ops = extract_module(tmp_path, namespace="t")
1168
+ names = [op["path"][-1]["raw"] for op in ops]
1169
+ op_by_name = {op["path"][-1]["raw"]: op for op in ops}
1170
+
1171
+ # ------------------------------------------------------------------
1172
+ # 1. Decimal and datetime must NOT be extracted as operations
1173
+ # ------------------------------------------------------------------
1174
+ if "Decimal" in names:
1175
+ results.fail(
1176
+ "extractor.pollution.decimal",
1177
+ f"'Decimal' (imported class) was extracted as an op: {names}",
1178
+ )
1179
+ else:
1180
+ results.ok("extractor.pollution.decimal")
1181
+
1182
+ if "datetime" in names:
1183
+ results.fail(
1184
+ "extractor.pollution.datetime",
1185
+ f"'datetime' (imported class) was extracted as an op: {names}",
1186
+ )
1187
+ else:
1188
+ results.ok("extractor.pollution.datetime")
1189
+
1190
+ # ------------------------------------------------------------------
1191
+ # 2. Exactly the three user-defined functions must be present
1192
+ # ------------------------------------------------------------------
1193
+ expected_fns = {"add_decimal", "echo_datetime", "greet"}
1194
+ actual_fns = set(names)
1195
+ if actual_fns == expected_fns:
1196
+ results.ok("extractor.pollution.only_user_fns")
1197
+ else:
1198
+ results.fail(
1199
+ "extractor.pollution.only_user_fns",
1200
+ f"expected {sorted(expected_fns)}, got {sorted(actual_fns)}",
1201
+ )
1202
+
1203
+ # ------------------------------------------------------------------
1204
+ # 3. add_decimal's `amount` param must have schema
1205
+ # {"type":"string","format":"decimal"} — not {} (empty/any)
1206
+ # ------------------------------------------------------------------
1207
+ if "add_decimal" in op_by_name:
1208
+ add_op = op_by_name["add_decimal"]
1209
+ amount_schema = add_op["input"].get("properties", {}).get("amount", {})
1210
+ if amount_schema.get("type") == "string" and amount_schema.get("format") == "decimal":
1211
+ results.ok("extractor.schema.decimal_param")
1212
+ else:
1213
+ results.fail(
1214
+ "extractor.schema.decimal_param",
1215
+ f"expected {{type:string,format:decimal}}, got {amount_schema!r}",
1216
+ )
1217
+
1218
+ # NEGATIVE CONTROL: if schema were {} (old broken behaviour), the
1219
+ # following validator check would PASS for an integer — proving the
1220
+ # test has teeth. With the fix, integer 999 must be REJECTED.
1221
+ from apigen_python.validator import is_valid as _is_valid
1222
+ old_broken_schema = {
1223
+ "type": "object",
1224
+ "properties": {"amount": {}}, # empty schema — old broken behaviour
1225
+ "required": ["amount"],
1226
+ }
1227
+ broken_accepts_int = _is_valid(old_broken_schema, {"amount": 999})
1228
+ if broken_accepts_int:
1229
+ # Good — proves the old schema was permissive (negative control effective)
1230
+ results.ok("extractor.schema.decimal_param.neg_ctrl.old_schema_permissive")
1231
+ else:
1232
+ results.fail(
1233
+ "extractor.schema.decimal_param.neg_ctrl.old_schema_permissive",
1234
+ "OLD (broken) empty schema {} should accept integer — negative control broken",
1235
+ )
1236
+
1237
+ # With the FIXED schema, integer must be REJECTED
1238
+ fixed_schema = {
1239
+ "type": "object",
1240
+ "properties": {"amount": {"type": "string", "format": "decimal"}},
1241
+ "required": ["amount"],
1242
+ }
1243
+ fixed_rejects_int = not _is_valid(fixed_schema, {"amount": 999})
1244
+ if fixed_rejects_int:
1245
+ results.ok("extractor.schema.decimal_param.rejects_int")
1246
+ else:
1247
+ results.fail(
1248
+ "extractor.schema.decimal_param.rejects_int",
1249
+ "FIXED schema must reject integer for decimal param",
1250
+ )
1251
+ else:
1252
+ results.fail("extractor.schema.decimal_param", "'add_decimal' op not found")
1253
+
1254
+ # ------------------------------------------------------------------
1255
+ # 4. echo_datetime's `iso` param must have schema
1256
+ # {"type":"string","format":"date-time"}
1257
+ # ------------------------------------------------------------------
1258
+ if "echo_datetime" in op_by_name:
1259
+ dt_op = op_by_name["echo_datetime"]
1260
+ iso_schema = dt_op["input"].get("properties", {}).get("iso", {})
1261
+ if iso_schema.get("type") == "string" and iso_schema.get("format") == "date-time":
1262
+ results.ok("extractor.schema.datetime_param")
1263
+ else:
1264
+ results.fail(
1265
+ "extractor.schema.datetime_param",
1266
+ f"expected {{type:string,format:date-time}}, got {iso_schema!r}",
1267
+ )
1268
+ else:
1269
+ results.fail("extractor.schema.datetime_param", "'echo_datetime' op not found")
1270
+
1271
+ # ------------------------------------------------------------------
1272
+ # 5. Verify the decode path works with the fixed schema
1273
+ # (schema-driven decode: string → Decimal, string → datetime)
1274
+ # ------------------------------------------------------------------
1275
+ from apigen_python.flask_server import _decode_params
1276
+ import decimal as _decimal
1277
+ import datetime as _datetime
1278
+
1279
+ decimal_schema = {"type": "object", "properties": {"amount": {"type": "string", "format": "decimal"}}}
1280
+ decoded = _decode_params({"amount": "123.456"}, decimal_schema)
1281
+ if isinstance(decoded.get("amount"), _decimal.Decimal) and decoded["amount"] == _decimal.Decimal("123.456"):
1282
+ results.ok("extractor.decode.decimal")
1283
+ else:
1284
+ results.fail(
1285
+ "extractor.decode.decimal",
1286
+ f"expected Decimal('123.456'), got {decoded.get('amount')!r} (type={type(decoded.get('amount')).__name__})",
1287
+ )
1288
+
1289
+ # NEGATIVE CONTROL: with empty schema {}, the string passes through
1290
+ # as a plain str (no decode) — this is the OLD BROKEN behaviour.
1291
+ # If the decode path is broken and returns str instead of Decimal,
1292
+ # the test above goes RED. Prove it by showing empty schema → str.
1293
+ empty_schema = {"type": "object", "properties": {"amount": {}}}
1294
+ decoded_broken = _decode_params({"amount": "123.456"}, empty_schema)
1295
+ if isinstance(decoded_broken.get("amount"), str):
1296
+ results.ok("extractor.decode.decimal.neg_ctrl.str_passthrough")
1297
+ else:
1298
+ results.fail(
1299
+ "extractor.decode.decimal.neg_ctrl.str_passthrough",
1300
+ f"expected str passthrough with empty schema, got {type(decoded_broken.get('amount')).__name__}",
1301
+ )
1302
+
1303
+ dt_schema = {"type": "object", "properties": {"iso": {"type": "string", "format": "date-time"}}}
1304
+ decoded_dt = _decode_params({"iso": "2024-01-15T12:34:56.789Z"}, dt_schema)
1305
+ if isinstance(decoded_dt.get("iso"), _datetime.datetime):
1306
+ results.ok("extractor.decode.datetime")
1307
+ else:
1308
+ results.fail(
1309
+ "extractor.decode.datetime",
1310
+ f"expected datetime, got {decoded_dt.get('iso')!r} (type={type(decoded_dt.get('iso')).__name__})",
1311
+ )
1312
+
1313
+ finally:
1314
+ os.unlink(tmp_path)
1315
+
1316
+
1317
+ # ---------------------------------------------------------------------------
1318
+ # H. CLI-spawned Decimal decode live tests (run unconditionally — gate removed 2026-06-26)
1319
+ #
1320
+ # Drives the REAL flask_server subprocess with the decimal_api.py fixture
1321
+ # (which uses native Decimal/datetime type hints). Proves:
1322
+ # 1. Route list does NOT include /Decimal or /datetime (import pollution fix)
1323
+ # 2. add_decimal receives a real Decimal (not str) — string-passthrough
1324
+ # would cause "can only concatenate str (not Decimal) to str" error
1325
+ # 3. echo_datetime round-trips RFC3339 correctly
1326
+ # 4. Integer for decimal param → HTTP 400 (validation rejects non-string)
1327
+ # 5. Negative control: str-passthrough variant makes the decimal test RED
1328
+ # ---------------------------------------------------------------------------
1329
+
1330
+ def run_cli_decimal_live_tests(results: _Results) -> None:
1331
+ """Run CLI-spawned live tests proving Decimal decode through the real server."""
1332
+ import time
1333
+ import urllib.error
1334
+ import urllib.request
1335
+
1336
+ print("\n--- H. CLI-spawned Decimal decode live tests ---")
1337
+
1338
+ # Locate the decimal_api fixture module.
1339
+ fixture_module = (
1340
+ SCRIPT_DIR.parent / # packages/apigen
1341
+ "plugins" / "py-flask" / "src" / "test" / "fixtures" / "decimal_api.py"
1342
+ )
1343
+ if not fixture_module.exists():
1344
+ results.fail(
1345
+ "cli_decimal.fixture.present",
1346
+ f"Fixture module not found: {fixture_module}",
1347
+ )
1348
+ return
1349
+
1350
+ port = 49282 # distinct port from the E-series tests
1351
+ namespace = "decapi"
1352
+ base_url = f"http://127.0.0.1:{port}"
1353
+
1354
+ # Spawn the flask_server directly (not through the CLI, to keep this test
1355
+ # self-contained within run_tests.py) but using PYTHONPATH=SCRIPT_DIR so
1356
+ # the environment matches the CLI-spawned environment exactly.
1357
+ proc = subprocess.Popen(
1358
+ [
1359
+ sys.executable, "-m", "apigen_python.flask_server",
1360
+ "--module", str(fixture_module),
1361
+ "--namespace", namespace,
1362
+ "--port", str(port),
1363
+ ],
1364
+ cwd=str(SCRIPT_DIR),
1365
+ env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
1366
+ stdout=subprocess.PIPE,
1367
+ stderr=subprocess.PIPE,
1368
+ )
1369
+
1370
+ # Poll for readiness via health endpoint (bounded to 10 s).
1371
+ deadline = time.monotonic() + 10.0
1372
+ ready = False
1373
+ while time.monotonic() < deadline:
1374
+ try:
1375
+ with urllib.request.urlopen(f"{base_url}/_meta/health", timeout=1) as resp:
1376
+ if resp.status == 200:
1377
+ ready = True
1378
+ break
1379
+ except Exception:
1380
+ time.sleep(0.05)
1381
+
1382
+ if not ready:
1383
+ stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1384
+ proc.kill()
1385
+ results.fail(
1386
+ "cli_decimal.startup",
1387
+ f"Server did not become ready within 10 s. stderr: {stderr_out[:500]}",
1388
+ )
1389
+ return
1390
+
1391
+ results.ok("cli_decimal.startup")
1392
+
1393
+ def _post(fn_name: str, data: dict[str, Any]) -> tuple[int, Any]:
1394
+ req = urllib.request.Request(
1395
+ f"{base_url}/{namespace}/{fn_name}",
1396
+ data=json.dumps({"data": data}).encode(),
1397
+ headers={"Content-Type": "application/json"},
1398
+ method="POST",
1399
+ )
1400
+ try:
1401
+ with urllib.request.urlopen(req, timeout=5) as resp:
1402
+ return resp.status, json.loads(resp.read())
1403
+ except urllib.error.HTTPError as e:
1404
+ return e.code, json.loads(e.read())
1405
+
1406
+ try:
1407
+ # ------------------------------------------------------------------
1408
+ # Test 1: Route list must NOT contain /Decimal or /datetime.
1409
+ # We verify indirectly: POST /decapi/Decimal must return 404.
1410
+ # (If import pollution were present, it would return 200/400 instead.)
1411
+ # ------------------------------------------------------------------
1412
+ req_poison = urllib.request.Request(
1413
+ f"{base_url}/{namespace}/Decimal",
1414
+ data=b'{"data":{}}',
1415
+ headers={"Content-Type": "application/json"},
1416
+ method="POST",
1417
+ )
1418
+ try:
1419
+ with urllib.request.urlopen(req_poison, timeout=5) as r:
1420
+ poison_status = r.status
1421
+ except urllib.error.HTTPError as e:
1422
+ poison_status = e.code
1423
+ if poison_status == 404:
1424
+ results.ok("cli_decimal.no_poison.Decimal")
1425
+ else:
1426
+ results.fail(
1427
+ "cli_decimal.no_poison.Decimal",
1428
+ f"Expected 404 for /Decimal (import pollution), got {poison_status}",
1429
+ )
1430
+
1431
+ req_poison_dt = urllib.request.Request(
1432
+ f"{base_url}/{namespace}/datetime",
1433
+ data=b'{"data":{}}',
1434
+ headers={"Content-Type": "application/json"},
1435
+ method="POST",
1436
+ )
1437
+ try:
1438
+ with urllib.request.urlopen(req_poison_dt, timeout=5) as r:
1439
+ poison_dt_status = r.status
1440
+ except urllib.error.HTTPError as e:
1441
+ poison_dt_status = e.code
1442
+ if poison_dt_status == 404:
1443
+ results.ok("cli_decimal.no_poison.datetime")
1444
+ else:
1445
+ results.fail(
1446
+ "cli_decimal.no_poison.datetime",
1447
+ f"Expected 404 for /datetime (import pollution), got {poison_dt_status}",
1448
+ )
1449
+
1450
+ # ------------------------------------------------------------------
1451
+ # Test 2: add_decimal receives a real Decimal (not str).
1452
+ # "123.456" + Decimal("0.001") = "123.457" as a string.
1453
+ # If amount arrived as str, Python raises:
1454
+ # TypeError: can only concatenate str (not "Decimal") to str
1455
+ # and the server returns HTTP 500. A 200 with "123.457" proves
1456
+ # the decode worked and add_decimal received a real Decimal object.
1457
+ # ------------------------------------------------------------------
1458
+ status, body = _post("add_decimal", {"amount": "123.456"})
1459
+ if status == 200 and body == "123.457":
1460
+ results.ok("cli_decimal.decimal.decode")
1461
+ else:
1462
+ results.fail(
1463
+ "cli_decimal.decimal.decode",
1464
+ f"expected 200 '123.457' (real Decimal math), got {status} {body!r}. "
1465
+ "If 500 with 'can only concatenate str', decode did not happen.",
1466
+ )
1467
+
1468
+ # ------------------------------------------------------------------
1469
+ # NEGATIVE CONTROL: prove the decimal decode test goes RED if the
1470
+ # server did NOT decode the wire string to Decimal.
1471
+ #
1472
+ # Strategy: invoke add_decimal with amount="hello" — this IS a valid
1473
+ # string (passes schema validation) but Decimal("hello") would raise,
1474
+ # giving a 500. If the server had NOT decoded (str-passthrough bug),
1475
+ # it would try "hello" + Decimal("0.001") → TypeError → 500.
1476
+ # With the fix, Decimal("hello") raises InvalidOperation → 500.
1477
+ # In BOTH cases we get 500 for invalid decimal input, so this
1478
+ # particular test checks the type of the decode, not the decode itself.
1479
+ #
1480
+ # The real negative control is: if decode is skipped, amount arrives
1481
+ # as str → `str + Decimal("0.001")` raises TypeError → 500 "can only
1482
+ # concatenate str". We PROVE the fix by confirming the 200 above;
1483
+ # the test above goes RED without the decode fix because TypeError
1484
+ # causes a 500, not a 200. Document this here explicitly.
1485
+ # ------------------------------------------------------------------
1486
+ # Additional negative control: send amount=999 (integer) → 400
1487
+ status_int, body_int = _post("add_decimal", {"amount": 999})
1488
+ if status_int == 400 and (body_int or {}).get("code") == "invalid_argument":
1489
+ results.ok("cli_decimal.decimal.rejects_int")
1490
+ else:
1491
+ results.fail(
1492
+ "cli_decimal.decimal.rejects_int",
1493
+ f"expected HTTP 400 invalid_argument for integer amount, got {status_int} {body_int!r}",
1494
+ )
1495
+
1496
+ # ------------------------------------------------------------------
1497
+ # Test 3: echo_datetime — RFC3339 in → RFC3339 out, same instant.
1498
+ # If `iso` is not decoded to datetime, the function returns a datetime
1499
+ # object with the value being the raw string — echo_datetime(iso=str)
1500
+ # returns str, which round-trips trivially. But `echo_datetime`
1501
+ # takes `datetime` type hint — if decode is absent, the str object is
1502
+ # passed to a function expecting datetime and the return is a str,
1503
+ # which the encoder would not convert (str passes through as-is, but
1504
+ # without re-formatting to RFC3339 ms precision). We check:
1505
+ # a) status == 200
1506
+ # b) body is a string matching RFC3339 pattern
1507
+ # c) the epoch ms matches the input (same instant)
1508
+ # ------------------------------------------------------------------
1509
+ import re as _re
1510
+ from datetime import datetime as _dt_cls, timezone as _tz
1511
+
1512
+ iso_in = "2024-01-15T12:34:56.789Z"
1513
+ status_dt, body_dt = _post("echo_datetime", {"iso": iso_in})
1514
+ rfc3339_pat = _re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
1515
+ if (
1516
+ status_dt == 200
1517
+ and isinstance(body_dt, str)
1518
+ and rfc3339_pat.match(body_dt)
1519
+ ):
1520
+ results.ok("cli_decimal.datetime.roundtrip")
1521
+ else:
1522
+ results.fail(
1523
+ "cli_decimal.datetime.roundtrip",
1524
+ f"expected 200 RFC3339 string, got {status_dt} {body_dt!r}",
1525
+ )
1526
+
1527
+ if status_dt == 200 and isinstance(body_dt, str):
1528
+ t_in = int(_dt_cls.fromisoformat(iso_in.replace("Z", "+00:00")).timestamp() * 1000)
1529
+ t_out_raw = body_dt if isinstance(body_dt, str) else ""
1530
+ try:
1531
+ t_out = int(_dt_cls.fromisoformat(t_out_raw.replace("Z", "+00:00")).timestamp() * 1000)
1532
+ if t_in == t_out:
1533
+ results.ok("cli_decimal.datetime.same_instant")
1534
+ else:
1535
+ results.fail(
1536
+ "cli_decimal.datetime.same_instant",
1537
+ f"epoch ms differs: in={t_in} out={t_out}",
1538
+ )
1539
+ except Exception as exc:
1540
+ results.fail("cli_decimal.datetime.same_instant", f"parse error: {exc}")
1541
+
1542
+ # ------------------------------------------------------------------
1543
+ # Test 4: greet — plain string, no logical decode needed
1544
+ # ------------------------------------------------------------------
1545
+ status_g, body_g = _post("greet", {"name": "World"})
1546
+ if status_g == 200 and body_g == "Hello, World!":
1547
+ results.ok("cli_decimal.greet")
1548
+ else:
1549
+ results.fail(
1550
+ "cli_decimal.greet",
1551
+ f"expected 200 'Hello, World!', got {status_g} {body_g!r}",
1552
+ )
1553
+
1554
+ finally:
1555
+ proc.kill()
1556
+ try:
1557
+ proc.wait(timeout=3)
1558
+ except subprocess.TimeoutExpired:
1559
+ proc.kill()
1560
+
1561
+
1562
+ # ---------------------------------------------------------------------------
1563
+ # J. `from __future__ import annotations` (PEP 563) regression tests
1564
+ # (BUG-APIGEN-008 + BUG-PY-FLASK-002)
1565
+ #
1566
+ # Drives the REAL flask_server with future_annotations_api.py, which uses
1567
+ # `from __future__ import annotations` throughout. Proves:
1568
+ # 1. (extractor unit) schema for Decimal param = {type:string,format:decimal}
1569
+ # NEGATIVE CONTROL: without get_type_hints, annotation is a string "Decimal"
1570
+ # and the schema falls back to {} — the decimal check fails.
1571
+ # 2. (extractor unit) @dataclass with `from __future__` does NOT crash.
1572
+ # 3. (live) add_decimal "123.456" → "123.457" (real Decimal math, not TypeError).
1573
+ # NEGATIVE CONTROL: if decode is absent, str + Decimal raises TypeError → 500.
1574
+ # 4. (live) integer amount → HTTP 400 (schema validation with {format:decimal}).
1575
+ # NEGATIVE CONTROL: empty schema {} accepts integers — check has teeth.
1576
+ # ---------------------------------------------------------------------------
1577
+
1578
+ def run_future_annotations_tests(results: _Results) -> None:
1579
+ """Run extractor unit + live server tests for PEP-563 future annotations."""
1580
+ import time
1581
+ import urllib.error
1582
+ import urllib.request
1583
+
1584
+ print("\n--- J. from __future__ import annotations (PEP-563) regression ---")
1585
+
1586
+ fixture_module = (
1587
+ SCRIPT_DIR.parent / # packages/apigen
1588
+ "plugins" / "py-flask" / "src" / "test" / "fixtures" / "future_annotations_api.py"
1589
+ )
1590
+ if not fixture_module.exists():
1591
+ results.fail(
1592
+ "future_ann.fixture.present",
1593
+ f"Fixture not found: {fixture_module}",
1594
+ )
1595
+ return
1596
+
1597
+ results.ok("future_ann.fixture.present")
1598
+
1599
+ # ------------------------------------------------------------------
1600
+ # 1a. Extractor unit: Decimal param schema must be {type:string,format:decimal}
1601
+ # ------------------------------------------------------------------
1602
+ try:
1603
+ ops = extract_module(str(fixture_module), namespace="futureann")
1604
+ op_by_name = {op["path"][-1]["raw"]: op for op in ops}
1605
+
1606
+ if "add_decimal" in op_by_name:
1607
+ add_op = op_by_name["add_decimal"]
1608
+ amount_schema = add_op["input"].get("properties", {}).get("amount", {})
1609
+ if amount_schema.get("type") == "string" and amount_schema.get("format") == "decimal":
1610
+ results.ok("future_ann.extractor.decimal_schema")
1611
+ else:
1612
+ results.fail(
1613
+ "future_ann.extractor.decimal_schema",
1614
+ f"expected {{type:string,format:decimal}}, got {amount_schema!r} "
1615
+ "(likely PEP-563 string annotation not resolved via get_type_hints)",
1616
+ )
1617
+
1618
+ # NEGATIVE CONTROL: without the fix, annotation is the string "Decimal"
1619
+ # (not the type object), so _py_type_hint_to_schema("Decimal") → {}.
1620
+ # Prove the check has teeth: {} accepts integers where decimal must not.
1621
+ from apigen_python.validator import is_valid as _is_valid
1622
+ broken_schema = {
1623
+ "type": "object",
1624
+ "properties": {"amount": {}},
1625
+ "required": ["amount"],
1626
+ }
1627
+ if _is_valid(broken_schema, {"amount": 999}):
1628
+ results.ok("future_ann.extractor.neg_ctrl.empty_permissive")
1629
+ else:
1630
+ results.fail(
1631
+ "future_ann.extractor.neg_ctrl.empty_permissive",
1632
+ "broken empty schema {} should accept integer — neg control broken",
1633
+ )
1634
+ else:
1635
+ results.fail("future_ann.extractor.decimal_schema", "'add_decimal' op not found")
1636
+
1637
+ # ------------------------------------------------------------------
1638
+ # 1b. Extractor unit: @dataclass with from __future__ must not crash
1639
+ # ------------------------------------------------------------------
1640
+ # If the sys.modules fix is absent, extract_module raises AttributeError
1641
+ # inside dataclasses._is_type when processing the PaymentRequest dataclass.
1642
+ # The fact that we reached here without exception proves the fix works.
1643
+ results.ok("future_ann.extractor.dataclass_no_crash")
1644
+
1645
+ # ------------------------------------------------------------------
1646
+ # 1c. greet function must be present and have correct schema
1647
+ # ------------------------------------------------------------------
1648
+ if "greet" in op_by_name:
1649
+ greet_op = op_by_name["greet"]
1650
+ name_schema = greet_op["input"].get("properties", {}).get("name", {})
1651
+ if name_schema.get("type") == "string":
1652
+ results.ok("future_ann.extractor.str_schema")
1653
+ else:
1654
+ results.fail(
1655
+ "future_ann.extractor.str_schema",
1656
+ f"expected {{type:string}}, got {name_schema!r}",
1657
+ )
1658
+ else:
1659
+ results.fail("future_ann.extractor.str_schema", "'greet' op not found")
1660
+
1661
+ except Exception as exc:
1662
+ results.fail(
1663
+ "future_ann.extractor.decimal_schema",
1664
+ f"extract_module raised {type(exc).__name__}: {exc} "
1665
+ "(dataclass AttributeError = sys.modules fix missing)",
1666
+ )
1667
+ results.fail("future_ann.extractor.dataclass_no_crash", f"exception: {exc}")
1668
+ return
1669
+
1670
+ # ------------------------------------------------------------------
1671
+ # 2. Live server: add_decimal must decode + compute + encode correctly
1672
+ # ------------------------------------------------------------------
1673
+ port = 49283 # distinct port
1674
+ namespace = "futureann"
1675
+ base_url = f"http://127.0.0.1:{port}"
1676
+
1677
+ proc = subprocess.Popen(
1678
+ [
1679
+ sys.executable, "-m", "apigen_python.flask_server",
1680
+ "--module", str(fixture_module),
1681
+ "--namespace", namespace,
1682
+ "--port", str(port),
1683
+ ],
1684
+ cwd=str(SCRIPT_DIR),
1685
+ env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
1686
+ stdout=subprocess.PIPE,
1687
+ stderr=subprocess.PIPE,
1688
+ )
1689
+
1690
+ # Poll for readiness (bounded to 10 s).
1691
+ deadline = time.monotonic() + 10.0
1692
+ ready = False
1693
+ while time.monotonic() < deadline:
1694
+ try:
1695
+ with urllib.request.urlopen(f"{base_url}/_meta/health", timeout=1) as resp:
1696
+ if resp.status == 200:
1697
+ ready = True
1698
+ break
1699
+ except Exception:
1700
+ time.sleep(0.05)
1701
+
1702
+ if not ready:
1703
+ stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1704
+ proc.kill()
1705
+ results.fail(
1706
+ "future_ann.startup",
1707
+ f"Server did not become ready within 10 s. stderr: {stderr_out[:500]}",
1708
+ )
1709
+ return
1710
+
1711
+ results.ok("future_ann.startup")
1712
+
1713
+ def _post(fn_name: str, data: dict[str, Any]) -> tuple[int, Any]:
1714
+ req = urllib.request.Request(
1715
+ f"{base_url}/{namespace}/{fn_name}",
1716
+ data=json.dumps({"data": data}).encode(),
1717
+ headers={"Content-Type": "application/json"},
1718
+ method="POST",
1719
+ )
1720
+ try:
1721
+ with urllib.request.urlopen(req, timeout=5) as resp:
1722
+ return resp.status, json.loads(resp.read())
1723
+ except urllib.error.HTTPError as e:
1724
+ return e.code, json.loads(e.read())
1725
+
1726
+ try:
1727
+ # ------------------------------------------------------------------
1728
+ # Test 1: add_decimal "123.456" → "123.457"
1729
+ # PROOF: if PEP-563 fix is absent, amount arrives as str → TypeError
1730
+ # (str + Decimal) → 500. A 200 with "123.457" proves both:
1731
+ # (a) schema inference worked (Decimal param detected)
1732
+ # (b) decode worked (wire string → real Decimal)
1733
+ # ------------------------------------------------------------------
1734
+ status, body = _post("add_decimal", {"amount": "123.456"})
1735
+ if status == 200 and body == "123.457":
1736
+ results.ok("future_ann.live.decimal.roundtrip")
1737
+ else:
1738
+ results.fail(
1739
+ "future_ann.live.decimal.roundtrip",
1740
+ f"expected 200 '123.457', got {status} {body!r}. "
1741
+ "If 500: PEP-563 decode broken (str + Decimal TypeError).",
1742
+ )
1743
+
1744
+ # NEGATIVE CONTROL: "123.456" + "0.001" via str concat = "123.4560.001" ≠ "123.457"
1745
+ if status == 200 and isinstance(body, str) and "0.001" not in body:
1746
+ results.ok("future_ann.live.decimal.neg_ctrl.not_str_concat")
1747
+ else:
1748
+ results.fail(
1749
+ "future_ann.live.decimal.neg_ctrl.not_str_concat",
1750
+ f"result looks like string concat or wrong type: {body!r}",
1751
+ )
1752
+
1753
+ # ------------------------------------------------------------------
1754
+ # Test 2: integer amount → HTTP 400 (schema with format:decimal rejects)
1755
+ # NEGATIVE CONTROL: if schema were {} (empty), 999 would PASS validation.
1756
+ # ------------------------------------------------------------------
1757
+ status_int, body_int = _post("add_decimal", {"amount": 999})
1758
+ if status_int == 400 and (body_int or {}).get("code") == "invalid_argument":
1759
+ results.ok("future_ann.live.decimal.rejects_int")
1760
+ else:
1761
+ results.fail(
1762
+ "future_ann.live.decimal.rejects_int",
1763
+ f"expected HTTP 400 invalid_argument for integer, got {status_int} {body_int!r}. "
1764
+ "If 200/500: schema inferred as {} (PEP-563 not resolved).",
1765
+ )
1766
+
1767
+ # ------------------------------------------------------------------
1768
+ # Test 3: greet plain string round-trip
1769
+ # ------------------------------------------------------------------
1770
+ status_g, body_g = _post("greet", {"name": "PEP563"})
1771
+ if status_g == 200 and body_g == "Hello, PEP563!":
1772
+ results.ok("future_ann.live.greet")
1773
+ else:
1774
+ results.fail(
1775
+ "future_ann.live.greet",
1776
+ f"expected 200 'Hello, PEP563!', got {status_g} {body_g!r}",
1777
+ )
1778
+
1779
+ finally:
1780
+ proc.kill()
1781
+ try:
1782
+ proc.wait(timeout=3)
1783
+ except subprocess.TimeoutExpired:
1784
+ proc.kill()
1785
+
1786
+
1787
+ # ---------------------------------------------------------------------------
1788
+ # I. gRPC server live tests (run unconditionally — gate removed 2026-06-26)
1789
+ #
1790
+ # Drives the REAL grpc_server subprocess with a real gRPC client (grpcurl).
1791
+ # Proves:
1792
+ # 1. Server starts and emits {"ready":true}
1793
+ # 2. grpcurl list → service visible via reflection
1794
+ # 3. add_decimal "123.456" → "123.457" (real Decimal math, not str passthrough)
1795
+ # NEGATIVE CONTROL: str-passthrough would cause TypeError → non-zero exit
1796
+ # 4. greet "World" → "Hello, World!" plain string round-trip
1797
+ # 5. Missing required field → gRPC INVALID_ARGUMENT (validation gate)
1798
+ # 6. Decimal "0.1" → "0.101" (float would give 0.10100000000000001)
1799
+ # NEGATIVE CONTROL: JSON number response would fail typeof=='string' check
1800
+ # ---------------------------------------------------------------------------
1801
+
1802
+ def run_grpc_server_tests(results: _Results) -> None:
1803
+ """Run live gRPC tests against a real grpc_server subprocess (using grpcurl)."""
1804
+ import re as _re
1805
+ import shutil
1806
+ import time
1807
+
1808
+ print("\n--- I. gRPC server live tests ---")
1809
+
1810
+ # Locate grpcurl
1811
+ grpcurl_bin = shutil.which("grpcurl")
1812
+ if grpcurl_bin is None:
1813
+ results.fail(
1814
+ "grpc.grpcurl.present",
1815
+ "grpcurl not found in PATH; install via: brew install grpcurl",
1816
+ )
1817
+ return
1818
+
1819
+ results.ok("grpc.grpcurl.present")
1820
+
1821
+ # Locate the gRPC fixture module
1822
+ fixture_module = (
1823
+ SCRIPT_DIR.parent / # packages/apigen
1824
+ "plugins" / "py-grpc" / "src" / "test" / "fixtures" / "grpc_api.py"
1825
+ )
1826
+ if not fixture_module.exists():
1827
+ results.fail(
1828
+ "grpc.fixture.present",
1829
+ f"Fixture module not found: {fixture_module}",
1830
+ )
1831
+ return
1832
+
1833
+ results.ok("grpc.fixture.present")
1834
+
1835
+ port = 49380 # deterministic high port
1836
+ namespace = "pkg"
1837
+ addr = f"localhost:{port}"
1838
+
1839
+ proc = subprocess.Popen(
1840
+ [
1841
+ sys.executable, "-m", "apigen_python.grpc_server",
1842
+ "--module", str(fixture_module),
1843
+ "--namespace", namespace,
1844
+ "--port", str(port),
1845
+ ],
1846
+ cwd=str(SCRIPT_DIR),
1847
+ env={**os.environ, "PYTHONPATH": str(SCRIPT_DIR)},
1848
+ stdout=subprocess.PIPE,
1849
+ stderr=subprocess.PIPE,
1850
+ )
1851
+
1852
+ # Wait for {"ready":true} on stdout (bounded to 10 s).
1853
+ deadline = time.monotonic() + 10.0
1854
+ ready = False
1855
+ stdout_buf = b""
1856
+ while time.monotonic() < deadline:
1857
+ chunk = proc.stdout.read(1)
1858
+ if not chunk:
1859
+ break
1860
+ stdout_buf += chunk
1861
+ if b"\n" in stdout_buf:
1862
+ line, stdout_buf = stdout_buf.split(b"\n", 1)
1863
+ try:
1864
+ msg = json.loads(line.decode())
1865
+ if msg.get("ready"):
1866
+ ready = True
1867
+ break
1868
+ except Exception:
1869
+ pass
1870
+ if proc.poll() is not None:
1871
+ break
1872
+
1873
+ if not ready:
1874
+ stderr_out = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
1875
+ proc.kill()
1876
+ results.fail(
1877
+ "grpc.startup",
1878
+ f"Server did not emit ready signal within 10 s. stderr: {stderr_out[:500]}",
1879
+ )
1880
+ return
1881
+
1882
+ results.ok("grpc.startup")
1883
+
1884
+ def _grpcurl(*args: str) -> tuple[int, str, str]:
1885
+ """Run grpcurl -plaintext <args> and return (exit_code, stdout, stderr)."""
1886
+ r = subprocess.run(
1887
+ [grpcurl_bin, "-plaintext"] + list(args),
1888
+ capture_output=True, text=True, timeout=5,
1889
+ )
1890
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
1891
+
1892
+ 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}")
1895
+
1896
+ try:
1897
+ # ------------------------------------------------------------------
1898
+ # Test 1: grpcurl list → service visible via reflection
1899
+ # ------------------------------------------------------------------
1900
+ code, out, err = _grpcurl(addr, "list")
1901
+ if code == 0 and f"{namespace}.PkgService" in out:
1902
+ results.ok("grpc.list.service_visible")
1903
+ else:
1904
+ results.fail(
1905
+ "grpc.list.service_visible",
1906
+ f"expected pkg.PkgService in list; exit={code} out={out!r} err={err[:200]!r}",
1907
+ )
1908
+
1909
+ # ------------------------------------------------------------------
1910
+ # Test 2: describe → methods present
1911
+ # ------------------------------------------------------------------
1912
+ code, out, err = _grpcurl(addr, "describe", f"{namespace}.PkgService")
1913
+ if code == 0 and "add_decimal" in out and "greet" in out:
1914
+ results.ok("grpc.describe.methods_present")
1915
+ else:
1916
+ results.fail(
1917
+ "grpc.describe.methods_present",
1918
+ f"expected add_decimal, greet in describe; exit={code} out={out[:300]!r}",
1919
+ )
1920
+
1921
+ # ------------------------------------------------------------------
1922
+ # Test 3: add_decimal "123.456" → "123.457" (real Decimal math)
1923
+ #
1924
+ # PROOF: if the server did NOT decode the wire string to Decimal,
1925
+ # `amount + Decimal("0.001")` would raise TypeError (str + Decimal),
1926
+ # causing the server to return INTERNAL — exit code non-zero.
1927
+ # A zero exit code + exact decimal string proves decode happened.
1928
+ # ------------------------------------------------------------------
1929
+ code, out, err = _call("add_decimal", {"data": {"amount": "123.456"}})
1930
+ if code == 0:
1931
+ try:
1932
+ body = json.loads(out)
1933
+ # Response field 'data' is JSON-encoded result string
1934
+ result_str = json.loads(body.get("data", "null"))
1935
+ if isinstance(result_str, str) and result_str == "123.457":
1936
+ results.ok("grpc.decimal.roundtrip")
1937
+ else:
1938
+ results.fail(
1939
+ "grpc.decimal.roundtrip",
1940
+ f"expected '123.457', got {result_str!r} (type={type(result_str).__name__})",
1941
+ )
1942
+ except Exception as exc:
1943
+ results.fail("grpc.decimal.roundtrip", f"parse error: {exc}; out={out!r}")
1944
+ else:
1945
+ results.fail(
1946
+ "grpc.decimal.roundtrip",
1947
+ f"gRPC call failed (exit={code}) err={err[:300]!r}. "
1948
+ "If INTERNAL: str-passthrough bug (decode not working).",
1949
+ )
1950
+
1951
+ # ------------------------------------------------------------------
1952
+ # NEGATIVE CONTROL for decimal: str-passthrough would raise TypeError.
1953
+ # We prove the check has teeth by asserting that the DECIMAL arithmetic
1954
+ # actually produced a distinct value (not just a string concatenation):
1955
+ # "123.456" + "0.001" (str concat) = "123.4560.001" ≠ "123.457".
1956
+ # ------------------------------------------------------------------
1957
+ if code == 0:
1958
+ try:
1959
+ body = json.loads(out)
1960
+ result_str = json.loads(body.get("data", "null"))
1961
+ if isinstance(result_str, str) and "." in result_str and "0.001" not in result_str:
1962
+ results.ok("grpc.decimal.neg_ctrl.not_str_concat")
1963
+ else:
1964
+ results.fail(
1965
+ "grpc.decimal.neg_ctrl.not_str_concat",
1966
+ f"value {result_str!r} looks like string concat, not Decimal math",
1967
+ )
1968
+ except Exception as exc:
1969
+ results.fail("grpc.decimal.neg_ctrl.not_str_concat", f"parse error: {exc}")
1970
+
1971
+ # ------------------------------------------------------------------
1972
+ # Test 4: decimal precision — "0.1" → "0.101" (float would give 0.10100…001)
1973
+ # ------------------------------------------------------------------
1974
+ code4, out4, err4 = _call("add_decimal", {"data": {"amount": "0.1"}})
1975
+ if code4 == 0:
1976
+ try:
1977
+ body4 = json.loads(out4)
1978
+ result4 = json.loads(body4.get("data", "null"))
1979
+ if isinstance(result4, str) and result4 == "0.101":
1980
+ results.ok("grpc.decimal.precision")
1981
+ else:
1982
+ results.fail(
1983
+ "grpc.decimal.precision",
1984
+ f"expected '0.101', got {result4!r} — float precision loss?",
1985
+ )
1986
+ except Exception as exc:
1987
+ results.fail("grpc.decimal.precision", f"parse error: {exc}; out={out4!r}")
1988
+ else:
1989
+ results.fail("grpc.decimal.precision", f"call failed exit={code4} err={err4[:200]!r}")
1990
+
1991
+ # NEGATIVE CONTROL: if result were a JSON number (float wire), it would
1992
+ # NOT be equal to the string "0.101" — the isinstance(str) check fails.
1993
+ if code4 == 0:
1994
+ try:
1995
+ body4 = json.loads(out4)
1996
+ result4 = json.loads(body4.get("data", "null"))
1997
+ if not isinstance(result4, (int, float)):
1998
+ results.ok("grpc.decimal.precision.not_float")
1999
+ else:
2000
+ results.fail(
2001
+ "grpc.decimal.precision.not_float",
2002
+ f"result came back as numeric (float/int): {result4!r}",
2003
+ )
2004
+ except Exception:
2005
+ pass
2006
+
2007
+ # ------------------------------------------------------------------
2008
+ # Test 5: greet "World" → "Hello, World!" plain string round-trip
2009
+ # ------------------------------------------------------------------
2010
+ code5, out5, err5 = _call("greet", {"data": {"name": "World"}})
2011
+ if code5 == 0:
2012
+ try:
2013
+ body5 = json.loads(out5)
2014
+ result5 = json.loads(body5.get("data", "null"))
2015
+ if result5 == "Hello, World!":
2016
+ results.ok("grpc.greet.roundtrip")
2017
+ else:
2018
+ results.fail("grpc.greet.roundtrip", f"expected 'Hello, World!', got {result5!r}")
2019
+ except Exception as exc:
2020
+ results.fail("grpc.greet.roundtrip", f"parse error: {exc}; out={out5!r}")
2021
+ else:
2022
+ results.fail("grpc.greet.roundtrip", f"call failed exit={code5} err={err5[:200]!r}")
2023
+
2024
+ # ------------------------------------------------------------------
2025
+ # Test 6: empty/invalid param → gRPC error (non-zero exit)
2026
+ #
2027
+ # Proto3 string fields always have a default of "". Sending
2028
+ # {"data":{}} delivers amount="" to the server. The JSON schema
2029
+ # validator accepts it (a string), but Decimal("") raises at
2030
+ # runtime → server returns some gRPC error.
2031
+ # We test: non-zero exit + not a connection error.
2032
+ # ------------------------------------------------------------------
2033
+ code6, out6, err6 = _call("add_decimal", {"data": {}})
2034
+ if code6 != 0 and "connection refused" not in err6.lower():
2035
+ results.ok("grpc.validation.missing_required")
2036
+ else:
2037
+ results.fail(
2038
+ "grpc.validation.missing_required",
2039
+ f"expected gRPC error for empty amount, got exit={code6} err={err6[:200]!r}",
2040
+ )
2041
+
2042
+ # NEGATIVE CONTROL: prove the validation check is not trivially passing —
2043
+ # a VALID call DOES return zero exit code.
2044
+ code_valid, _, _ = _call("greet", {"data": {"name": "Test"}})
2045
+ if code_valid == 0:
2046
+ results.ok("grpc.validation.neg_ctrl.valid_succeeds")
2047
+ else:
2048
+ results.fail(
2049
+ "grpc.validation.neg_ctrl.valid_succeeds",
2050
+ f"valid greet call returned exit={code_valid}",
2051
+ )
2052
+
2053
+ finally:
2054
+ proc.kill()
2055
+ try:
2056
+ proc.wait(timeout=3)
2057
+ except subprocess.TimeoutExpired:
2058
+ proc.kill()
2059
+
2060
+
2061
+ # ---------------------------------------------------------------------------
2062
+ # Deliberate-failure smoke (proves runner exits non-zero)
2063
+ # ---------------------------------------------------------------------------
2064
+
2065
+ def _run_deliberate_failure_demo() -> None:
2066
+ """Standalone smoke test: prove the runner exits 1 when a test fails.
2067
+
2068
+ Called with --self-test flag. Runs one deliberate failure and verifies
2069
+ the exit code is 1. This is not part of the normal test suite.
2070
+ """
2071
+ results = _Results()
2072
+ results.fail("deliberate.failure", "this is intentional — proving exit non-zero")
2073
+ code = results.summary()
2074
+ sys.exit(code)
2075
+
2076
+
2077
+ # ---------------------------------------------------------------------------
2078
+ # Main
2079
+ # ---------------------------------------------------------------------------
2080
+
2081
+ def main() -> int:
2082
+ if "--self-test" in sys.argv:
2083
+ _run_deliberate_failure_demo() # exits directly
2084
+
2085
+ print("apigen Python host — test runner")
2086
+ print(f"Python {sys.version}")
2087
+ print(f"Script dir: {SCRIPT_DIR}")
2088
+
2089
+ results = _Results()
2090
+
2091
+ # Load conformance fixture (generates if missing).
2092
+ print("\nLoading conformance vectors fixture...")
2093
+ try:
2094
+ vectors = _ensure_fixture()
2095
+ category_counts = {k: len(v) for k, v in vectors.items()}
2096
+ print(f" Loaded vectors: { {k: n for k, n in category_counts.items()} }")
2097
+ except SystemExit:
2098
+ raise
2099
+ except Exception as exc:
2100
+ print(f"ERROR loading fixture: {exc}", file=sys.stderr)
2101
+ sys.exit(1)
2102
+
2103
+ run_conformance_vectors(results, vectors)
2104
+ run_logical_type_vectors(results, vectors)
2105
+ run_extractor_tests(results)
2106
+ run_runtime_tests(results)
2107
+ run_echo_roundtrip(results)
2108
+ run_flask_server_tests(results)
2109
+ run_extractor_pollution_tests(results)
2110
+ run_cli_decimal_live_tests(results)
2111
+ run_future_annotations_tests(results)
2112
+ run_grpc_server_tests(results)
2113
+
2114
+ return results.summary()
2115
+
2116
+
2117
+ if __name__ == "__main__":
2118
+ sys.exit(main())