@aws/nx-plugin 1.0.0 → 1.0.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.
Files changed (124) hide show
  1. package/LICENSE-THIRD-PARTY +11686 -23398
  2. package/README.md +18 -0
  3. package/generators.json +7 -0
  4. package/migrations.json +39 -1
  5. package/package.json +6 -6
  6. package/src/agentcore-gateway/agent-connection/generator.js +6 -0
  7. package/src/agentcore-gateway/agent-connection/generator.js.map +1 -1
  8. package/src/agentcore-gateway/schema.json +1 -0
  9. package/src/agentcore-harness/generator.d.ts +1 -1
  10. package/src/connection/scaffold-catalog.d.ts +21 -0
  11. package/src/connection/scaffold-catalog.js +14 -6
  12. package/src/connection/scaffold-catalog.js.map +1 -1
  13. package/src/infra/app/generator.d.ts +1 -1
  14. package/src/init/generator.d.ts +1 -1
  15. package/src/mcp-server/guide-pipeline.d.ts +2 -1
  16. package/src/mcp-server/guide-pipeline.js +6 -0
  17. package/src/mcp-server/guide-pipeline.js.map +1 -1
  18. package/src/mcp-server/mdx-ast.d.ts +7 -0
  19. package/src/mcp-server/mdx-ast.js +15 -0
  20. package/src/mcp-server/mdx-ast.js.map +1 -1
  21. package/src/mcp-server/schema-registry.d.ts +2 -2
  22. package/src/mcp-server/schema-registry.js +2 -2
  23. package/src/mcp-server/schema-registry.js.map +1 -1
  24. package/src/migrations/latest/terraform-harness-environment-variables/metadata.json +3 -0
  25. package/src/migrations/latest/terraform-harness-environment-variables/migration.d.ts +6 -0
  26. package/src/migrations/latest/terraform-harness-environment-variables/migration.js +54 -0
  27. package/src/migrations/latest/terraform-harness-environment-variables/migration.js.map +1 -0
  28. package/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap +34 -0
  29. package/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +1750 -0
  30. package/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap +82 -0
  31. package/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +447 -0
  32. package/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +404 -0
  33. package/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +430 -0
  34. package/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +447 -0
  35. package/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +3488 -0
  36. package/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +1444 -0
  37. package/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +390 -0
  38. package/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +364 -0
  39. package/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +739 -0
  40. package/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +384 -0
  41. package/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +414 -0
  42. package/src/open-api/py-client/files/client/__clientModuleName__.py.template +1076 -0
  43. package/src/open-api/py-client/files/shared/__init__.py.template +28 -0
  44. package/src/open-api/py-client/files/shared/errors.py.template +37 -0
  45. package/src/open-api/py-client/files/shared/types.py.template +319 -0
  46. package/src/open-api/py-client/generator.d.ts +53 -0
  47. package/src/open-api/py-client/generator.js +119 -0
  48. package/src/open-api/py-client/generator.js.map +1 -0
  49. package/src/open-api/py-client/schema.d.js +6 -0
  50. package/src/open-api/py-client/schema.d.js.map +1 -0
  51. package/src/open-api/py-client/schema.d.ts +12 -0
  52. package/src/open-api/py-client/schema.json +28 -0
  53. package/src/open-api/py-client/vessel-registry-spec.json +2716 -0
  54. package/src/open-api/ts-client/__snapshots__/generator.edge-cases.spec.ts.snap +274 -0
  55. package/src/open-api/ts-client/petstore-spec.d.ts +6 -0
  56. package/src/open-api/ts-client/petstore-spec.js +1275 -0
  57. package/src/open-api/ts-client/petstore-spec.js.map +1 -0
  58. package/src/open-api/ts-hooks/generator.spec.tsx +1 -1
  59. package/src/open-api/utils/codegen-data/languages.d.ts +68 -2
  60. package/src/open-api/utils/codegen-data/languages.js +433 -21
  61. package/src/open-api/utils/codegen-data/languages.js.map +1 -1
  62. package/src/open-api/utils/codegen-data/types.d.ts +187 -0
  63. package/src/open-api/utils/codegen-data/types.js +10 -1
  64. package/src/open-api/utils/codegen-data/types.js.map +1 -1
  65. package/src/open-api/utils/codegen-data.d.ts +40 -1
  66. package/src/open-api/utils/codegen-data.js +622 -17
  67. package/src/open-api/utils/codegen-data.js.map +1 -1
  68. package/src/open-api/utils/normalise.js +175 -41
  69. package/src/open-api/utils/normalise.js.map +1 -1
  70. package/src/open-api/utils/parser.js +1 -1
  71. package/src/open-api/utils/parser.js.map +1 -1
  72. package/src/preset/__snapshots__/generator.spec.ts.snap +3 -3
  73. package/src/preset/generator.d.ts +1 -1
  74. package/src/py/agent/gateway-connection/generator.js +0 -3
  75. package/src/py/agent/gateway-connection/generator.js.map +1 -1
  76. package/src/py/agent/react-connection/generator.d.ts +1 -1
  77. package/src/py/agent/schema.json +2 -0
  78. package/src/py/fast-api/react/generator.d.ts +1 -1
  79. package/src/py/mcp-server/schema.json +1 -0
  80. package/src/sdk/open-api.d.ts +2 -0
  81. package/src/sdk/open-api.js +1 -0
  82. package/src/sdk/open-api.js.map +1 -1
  83. package/src/smithy/project/generator.d.ts +1 -1
  84. package/src/smithy/react-connection/generator.d.ts +1 -1
  85. package/src/terraform/project/generator.d.ts +1 -1
  86. package/src/trpc/react/generator.d.ts +1 -1
  87. package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
  88. package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
  89. package/src/ts/agent/gateway-connection/generator.js +0 -3
  90. package/src/ts/agent/gateway-connection/generator.js.map +1 -1
  91. package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
  92. package/src/ts/agent/react-connection/generator.d.ts +1 -1
  93. package/src/ts/agent/schema.json +1 -0
  94. package/src/ts/api/schema.json +2 -0
  95. package/src/ts/astro-docs/generator.d.ts +1 -1
  96. package/src/ts/dcr-proxy/generator.d.ts +1 -1
  97. package/src/ts/dynamodb/generator.d.ts +1 -1
  98. package/src/ts/lambda-function/generator.d.ts +1 -1
  99. package/src/ts/lib/generator.d.ts +1 -1
  100. package/src/ts/mcp-server/schema.json +1 -0
  101. package/src/ts/nx-generator/__snapshots__/generator.spec.ts.snap +1 -1
  102. package/src/ts/nx-generator/files/nx-plugin-for-aws/generator/generator.spec.ts.template +1 -1
  103. package/src/ts/nx-generator/generator.d.ts +1 -1
  104. package/src/ts/nx-migration/generator.d.ts +1 -1
  105. package/src/ts/nx-plugin/generator.d.ts +1 -1
  106. package/src/ts/rdb/generator.d.ts +1 -1
  107. package/src/ts/react-website/agui/generator.d.ts +1 -1
  108. package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +10 -10
  109. package/src/ts/react-website/app/schema.json +1 -0
  110. package/src/ts/website/app/schema.json +1 -0
  111. package/src/utils/agent-core-constructs/files/terraform/app/agentcore-harness/__nameKebabCase__/__nameKebabCase__.tf.template +1 -1
  112. package/src/utils/metrics-assertions.d.ts +18 -0
  113. package/src/utils/metrics-assertions.js +42 -0
  114. package/src/utils/metrics-assertions.js.map +1 -0
  115. package/src/utils/test/python-dependencies.d.ts +8 -0
  116. package/src/utils/test/python-dependencies.js +14 -0
  117. package/src/utils/test/python-dependencies.js.map +1 -0
  118. package/src/utils/test/python-worker/worker.py +703 -0
  119. package/src/utils/test/warm-python-cache.d.ts +18 -0
  120. package/src/utils/test/warm-python-cache.js +46 -0
  121. package/src/utils/test/warm-python-cache.js.map +1 -0
  122. package/src/utils/versions.d.ts +10 -9
  123. package/src/utils/versions.js +9 -8
  124. package/src/utils/versions.js.map +1 -1
@@ -0,0 +1,703 @@
1
+ #
2
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import atexit
10
+ import glob
11
+ import importlib
12
+ import io
13
+ import itertools
14
+ import json
15
+ import os
16
+ import py_compile
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import traceback
23
+ import types
24
+ import warnings
25
+ from typing import Any, Callable
26
+
27
+ import httpx
28
+ import pydantic
29
+
30
+
31
+ # ─── Mock httpx transport ────────────────────────────────────────────────
32
+ class NoMockMatched(Exception):
33
+ """Raised when a request matches no mock entry, so the test fails loudly."""
34
+
35
+
36
+ class OffSpecRequest(Exception):
37
+ """Raised when a request targets no route the OpenAPI spec declares.
38
+
39
+ A mock that matched any request would let a client send the wrong method or
40
+ URL and still satisfy every assertion about what came back, so the routes
41
+ the spec declares are checked independently of mock matching.
42
+ """
43
+
44
+
45
+ def _wire_path(request: httpx.Request) -> str:
46
+ """The request path as sent, keeping percent-encoding intact.
47
+
48
+ `request.url.path` decodes, which would turn an encoded `%2F` inside a path
49
+ parameter back into a separator and split one segment into two.
50
+ """
51
+ return request.url.raw_path.decode("ascii").split("?", 1)[0]
52
+
53
+
54
+ class _RouteSpec:
55
+ """One `method` + path template the spec declares, as a regex."""
56
+
57
+ def __init__(self, method: str, path_template: str) -> None:
58
+ self.method = method.upper()
59
+ self.path_template = path_template
60
+ # A path parameter matches any single segment, whatever style it uses:
61
+ # `simple` renders a bare value, `matrix` a `;name=` prefix and `label` a
62
+ # `.` prefix, all within the one segment the template declares.
63
+ pattern = "".join(
64
+ "[^/]*" if part.startswith("{") and part.endswith("}") else re.escape(part)
65
+ for part in re.split(r"(\{[^}]*\})", path_template)
66
+ )
67
+ self.pattern = re.compile(f"^{pattern}$")
68
+
69
+ def matches(self, request: httpx.Request) -> bool:
70
+ return request.method.upper() == self.method and bool(
71
+ self.pattern.match(_wire_path(request))
72
+ )
73
+
74
+ def __str__(self) -> str:
75
+ return f"{self.method} {self.path_template}"
76
+
77
+
78
+ class _MockMatch:
79
+ """Matches a mock entry against a live httpx.Request."""
80
+
81
+ def __init__(self, entry: dict) -> None:
82
+ self.method = entry.get("method", "").upper() if entry.get("method") else None
83
+ self.url_contains = entry.get("url_contains")
84
+ self.url_equals = entry.get("url_equals")
85
+ self.path = entry.get("path")
86
+ self.response = entry["response"]
87
+ self.used = False
88
+
89
+ def matches(self, request: httpx.Request) -> bool:
90
+ if self.method and request.method.upper() != self.method:
91
+ return False
92
+ url = str(request.url)
93
+ if self.url_contains and self.url_contains not in url:
94
+ return False
95
+ if self.url_equals and url != self.url_equals:
96
+ return False
97
+ if self.path and request.url.path != self.path:
98
+ return False
99
+ return True
100
+
101
+
102
+ class _ChunkedStream(httpx.SyncByteStream, httpx.AsyncByteStream):
103
+ """Serves a body as a fixed list of chunks, sync or async."""
104
+
105
+ def __init__(self, chunks: list[bytes]) -> None:
106
+ self.chunks = chunks
107
+
108
+ def __iter__(self):
109
+ yield from self.chunks
110
+
111
+ async def __aiter__(self):
112
+ for chunk in self.chunks:
113
+ yield chunk
114
+
115
+
116
+ class _MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
117
+ """Deterministic transport for both sync and async httpx clients."""
118
+
119
+ def __init__(self, entries: list[dict], routes: list[dict] | None = None) -> None:
120
+ self.entries = [_MockMatch(e) for e in entries]
121
+ self.routes = [_RouteSpec(r["method"], r["path"]) for r in routes or []]
122
+ self.calls: list[dict] = []
123
+ # Streamed responses handed out, and the ones whose body was released.
124
+ # A test asks whether closing a stream reached the response.
125
+ self.responses: list[httpx.Response] = []
126
+ self.released: list[httpx.Response] = []
127
+
128
+ def _record(self, request: httpx.Request) -> None:
129
+ try:
130
+ # Multipart bodies are streamed; read() materialises request.content.
131
+ request.read()
132
+ body = request.content.decode("utf-8") if request.content else None
133
+ except Exception:
134
+ body = None
135
+ self.calls.append(
136
+ {
137
+ "method": request.method,
138
+ "url": str(request.url),
139
+ "headers": dict(request.headers),
140
+ "body": body,
141
+ }
142
+ )
143
+
144
+ def _build_response(self, spec: dict) -> httpx.Response:
145
+ # A transport-level failure a real network produces, so a test can check
146
+ # it reaches the caller rather than being turned into an API error.
147
+ if spec.get("raise_timeout"):
148
+ raise httpx.TimeoutException("mock timeout")
149
+ if spec.get("raise_connect_error"):
150
+ raise httpx.ConnectError("mock connect error")
151
+ status = spec.get("status", 200)
152
+ # Copied because the defaults applied below would otherwise persist onto
153
+ # the caller's spec and leak into a later replay of the same entry.
154
+ headers = dict(spec.get("headers", {}))
155
+ if "jsonl_lines" in spec:
156
+ newline = spec.get("newline", "\n")
157
+ body = (newline.join(spec["jsonl_lines"]) + newline).encode("utf-8")
158
+ headers.setdefault("content-type", "application/jsonl")
159
+ if "chunk_size" in spec:
160
+ # Served in fixed-size pieces so a line spanning two chunks
161
+ # exercises the client's buffering. A single chunk would let a
162
+ # client that discards the buffer between reads still pass.
163
+ size = spec["chunk_size"]
164
+ chunks = [body[i : i + size] for i in range(0, len(body), size)]
165
+ return httpx.Response(
166
+ status_code=status,
167
+ headers=headers,
168
+ stream=_ChunkedStream(chunks),
169
+ )
170
+ elif "json" in spec:
171
+ body = json.dumps(spec["json"]).encode("utf-8")
172
+ headers.setdefault("content-type", "application/json")
173
+ elif "text" in spec:
174
+ body = spec["text"].encode("utf-8")
175
+ elif "bytes_b64" in spec:
176
+ import base64
177
+
178
+ body = base64.b64decode(spec["bytes_b64"])
179
+ else:
180
+ body = b""
181
+ return httpx.Response(status_code=status, headers=headers, content=body)
182
+
183
+ def _track_release(self, response: httpx.Response) -> None:
184
+ """Record the response once it is closed.
185
+
186
+ A streamed response is closed by the `with client.stream(...)` block
187
+ inside the generated method exiting, so this reports whether a caller's
188
+ `close()`/`aclose()` on the stream reached that far.
189
+ """
190
+ original_close = response.close
191
+ original_aclose = response.aclose
192
+ released = self.released
193
+
194
+ def close() -> None:
195
+ released.append(response)
196
+ original_close()
197
+
198
+ async def aclose() -> None:
199
+ released.append(response)
200
+ await original_aclose()
201
+
202
+ response.close = close # type: ignore[method-assign]
203
+ response.aclose = aclose # type: ignore[method-assign]
204
+
205
+ def _match(self, request: httpx.Request) -> httpx.Response:
206
+ self._record(request)
207
+ # Checked before the mocks: a client requesting a route the spec never
208
+ # declared is wrong regardless of which mock would have answered it.
209
+ if self.routes and not any(route.matches(request) for route in self.routes):
210
+ raise OffSpecRequest(
211
+ f"{request.method} {_wire_path(request)} matches no route the spec "
212
+ f"declares: {', '.join(str(r) for r in self.routes)}"
213
+ )
214
+ for entry in self.entries:
215
+ if entry.matches(request):
216
+ entry.used = True
217
+ response = self._build_response(entry.response)
218
+ self.responses.append(response)
219
+ self._track_release(response)
220
+ return response
221
+ # A request no mock describes is a mistake in the test, not a response
222
+ # the client should interpret: raise so it can't be mistaken for one.
223
+ raise NoMockMatched(f"No mock matched {request.method} {request.url}")
224
+
225
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
226
+ return self._match(request)
227
+
228
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
229
+ try:
230
+ # Async multipart bodies stream; aread() materialises request.content.
231
+ await request.aread()
232
+ except Exception:
233
+ pass
234
+ return self._match(request)
235
+
236
+
237
+ # ─── Module loading ──────────────────────────────────────────────────────
238
+ _PACKAGE_DIR: str | None = None
239
+
240
+
241
+ def _cleanup_package_dir() -> None:
242
+ """Remove this worker's package directory on exit.
243
+
244
+ Each compile replaces the previous directory, but the final one would
245
+ otherwise outlive the worker.
246
+ """
247
+ if _PACKAGE_DIR and os.path.isdir(_PACKAGE_DIR):
248
+ shutil.rmtree(_PACKAGE_DIR, ignore_errors=True)
249
+
250
+
251
+ atexit.register(_cleanup_package_dir)
252
+
253
+
254
+ def _reap_orphaned_dirs() -> None:
255
+ """Delete package directories left by workers that are no longer running.
256
+
257
+ `atexit` doesn't run when a worker is killed rather than asked to stop, so a
258
+ crashed run leaves its directories behind. Each is named after the process
259
+ that owns it, which lets a later worker tell an orphan from a live one.
260
+ """
261
+ for entry in glob.glob(os.path.join(tempfile.gettempdir(), "pyclient-*")):
262
+ # `pyclient-<pid>-<random>`; anything else predates this naming.
263
+ parts = os.path.basename(entry).split("-")
264
+ if len(parts) < 3 or not parts[1].isdigit():
265
+ continue
266
+ pid_part = parts[1]
267
+ if int(pid_part) == os.getpid():
268
+ continue
269
+ try:
270
+ os.kill(int(pid_part), 0)
271
+ except ProcessLookupError:
272
+ shutil.rmtree(entry, ignore_errors=True)
273
+ except OSError:
274
+ # Owned by another user, or otherwise not ours to remove.
275
+ continue
276
+
277
+
278
+ _reap_orphaned_dirs()
279
+
280
+
281
+ def _write_files(files: dict[str, str], pkg_name: str = "generated") -> str:
282
+ """Lay files out as `<tmpdir>/<pkg_name>/...` so the parent dir is on
283
+ sys.path and the package imports as `pkg_name`."""
284
+ global _PACKAGE_DIR
285
+ if _PACKAGE_DIR and os.path.isdir(_PACKAGE_DIR):
286
+ shutil.rmtree(_PACKAGE_DIR)
287
+ # Named after this process, so a later worker can reap it if we are killed.
288
+ _PACKAGE_DIR = tempfile.mkdtemp(prefix=f"pyclient-{os.getpid()}-")
289
+ pkg_dir = os.path.join(_PACKAGE_DIR, pkg_name)
290
+ os.makedirs(pkg_dir, exist_ok=True)
291
+ for rel, content in files.items():
292
+ abs_path = os.path.join(pkg_dir, rel)
293
+ parent = os.path.dirname(abs_path)
294
+ if parent:
295
+ os.makedirs(parent, exist_ok=True)
296
+ with open(abs_path, "w", encoding="utf-8") as f:
297
+ f.write(content)
298
+ return _PACKAGE_DIR
299
+
300
+
301
+ def _compile_all(root: str) -> list[str]:
302
+ """Return a list of error messages for any .py file that fails to parse."""
303
+ errors: list[str] = []
304
+ for dirpath, _, filenames in os.walk(root):
305
+ for name in filenames:
306
+ if name.endswith(".py"):
307
+ path = os.path.join(dirpath, name)
308
+ try:
309
+ py_compile.compile(path, doraise=True)
310
+ except py_compile.PyCompileError as exc:
311
+ errors.append(f"{path}: {exc.msg or exc}")
312
+ return errors
313
+
314
+
315
+ _previous_roots: list[str] = []
316
+
317
+
318
+ def _load_generated(root: str, pkg_name: str = "generated") -> types.ModuleType:
319
+ """Import `<root>/<pkg_name>` as a fresh module."""
320
+ for key in list(sys.modules):
321
+ if key == pkg_name or key.startswith(pkg_name + "."):
322
+ del sys.modules[key]
323
+ # Drop previously-inserted roots so Python can't resolve the package
324
+ # against a stale (possibly deleted) directory.
325
+ for stale in _previous_roots:
326
+ while stale in sys.path:
327
+ sys.path.remove(stale)
328
+ _previous_roots.clear()
329
+ sys.path.insert(0, root)
330
+ _previous_roots.append(root)
331
+ # Drop bytecode cache for a clean reimport.
332
+ importlib.invalidate_caches()
333
+ return importlib.import_module(pkg_name)
334
+
335
+
336
+ def _site_packages_paths() -> list[str]:
337
+ """The directories holding this worker's third-party packages.
338
+
339
+ `uv run --with` layers packages in from its cache rather than installing
340
+ them under `sys.prefix`, so the directories are derived from the imported
341
+ modules themselves — that way `ty` resolves the same versions the code
342
+ under test runs against.
343
+ """
344
+ paths: list[str] = []
345
+ for module in (httpx, pydantic):
346
+ directory = os.path.dirname(os.path.dirname(module.__file__ or ""))
347
+ if directory and directory not in paths:
348
+ paths.append(directory)
349
+ return paths
350
+
351
+
352
+ def _type_check(root: str, pkg_name: str) -> list[str]:
353
+ """Type check the package with `ty`, returning one message per diagnostic.
354
+
355
+ `ty` is the checker the plugin's Python projects run, so a generated client
356
+ is held to what a consumer's own `typecheck` target would report.
357
+ """
358
+ ty = shutil.which("ty")
359
+ if not ty:
360
+ return ["ty is not available on PATH"]
361
+ search_paths: list[str] = []
362
+ for directory in _site_packages_paths():
363
+ search_paths += ["--extra-search-path", directory]
364
+ result = subprocess.run( # noqa: S603
365
+ [
366
+ ty,
367
+ "check",
368
+ "--python",
369
+ sys.prefix,
370
+ *search_paths,
371
+ "--output-format",
372
+ "concise",
373
+ pkg_name,
374
+ ],
375
+ capture_output=True,
376
+ text=True,
377
+ cwd=root,
378
+ )
379
+ if result.returncode == 0:
380
+ return []
381
+ output = (result.stdout + result.stderr).strip().splitlines()
382
+ return [
383
+ line for line in output if ": error[" in line or ": warning[" in line
384
+ ] or output
385
+
386
+
387
+ # ─── Command handlers ────────────────────────────────────────────────────
388
+ # The files and package name last type checked. A suite that regenerates the
389
+ # same client for each of its tests would otherwise re-run `ty` over identical
390
+ # input every time, which is the bulk of its cost.
391
+ _last_type_checked: tuple[str, str] | None = None
392
+
393
+
394
+ def handle_compile(req: dict) -> dict:
395
+ pkg_name = req.get("package", "generated")
396
+ files = req["files"]
397
+ root = _write_files(files, pkg_name)
398
+ errors = _compile_all(root)
399
+ if errors:
400
+ return {"ok": False, "error": "compile_failed", "details": errors}
401
+ try:
402
+ _load_generated(root, pkg_name)
403
+ except Exception as exc: # noqa: BLE001
404
+ return {
405
+ "ok": False,
406
+ "error": f"{type(exc).__name__}: {exc}",
407
+ "traceback": traceback.format_exc(),
408
+ }
409
+ if req.get("type_check"):
410
+ global _last_type_checked
411
+ fingerprint = (pkg_name, json.dumps(files, sort_keys=True))
412
+ if fingerprint != _last_type_checked:
413
+ diagnostics = _type_check(root, pkg_name)
414
+ if diagnostics:
415
+ return {
416
+ "ok": False,
417
+ "error": "type_check_failed",
418
+ "details": diagnostics,
419
+ }
420
+ _last_type_checked = fingerprint
421
+ return {"ok": True, "value": None}
422
+
423
+
424
+ def handle_type_check_usage(req: dict) -> dict:
425
+ """Type check a caller-supplied module against the compiled package.
426
+
427
+ Returns the diagnostics rather than failing, so a test can assert that
428
+ intentionally-wrong usage is rejected as well as that valid usage is not.
429
+ """
430
+ if not _PACKAGE_DIR:
431
+ raise RuntimeError("no files loaded — compile first")
432
+ pkg_name = req.get("package", "generated")
433
+ usage_path = os.path.join(_PACKAGE_DIR, pkg_name, "_usage_probe.py")
434
+ with open(usage_path, "w", encoding="utf-8") as f:
435
+ f.write(req["usage"])
436
+ try:
437
+ return {"ok": True, "diagnostics": _type_check(_PACKAGE_DIR, pkg_name)}
438
+ finally:
439
+ os.remove(usage_path)
440
+
441
+
442
+ def _resolve_invoke(module_kind: str, mod: types.ModuleType) -> tuple[Any, str]:
443
+ """Locate the generated client class and its Config inside the package.
444
+
445
+ Selection is by defining module rather than by name, so a spec whose title
446
+ starts with "Async" still resolves to the right client.
447
+ """
448
+ is_async = module_kind == "async"
449
+ wanted_module = "async_client" if is_async else "client"
450
+ for name in getattr(mod, "__all__", []) or dir(mod):
451
+ if not isinstance(name, str):
452
+ continue
453
+ obj = getattr(mod, name, None)
454
+ if not isinstance(obj, type) or name.endswith("Config"):
455
+ continue
456
+ defining_module = getattr(obj, "__module__", "")
457
+ if not defining_module.endswith(wanted_module):
458
+ continue
459
+ # `client` also ends `async_client`, so the sync client is the
460
+ # one whose module isn't the async module.
461
+ if not is_async and defining_module.endswith("async_client"):
462
+ continue
463
+ return obj, name
464
+ raise RuntimeError(
465
+ f"Could not locate {'async ' if is_async else ''}client class in {mod.__name__}"
466
+ )
467
+
468
+
469
+ class _BodyDigestAuth(httpx.Auth):
470
+ """Signs each request with a digest of its body.
471
+
472
+ Standing in for a real signer (SigV4, say): a generated client that bypassed
473
+ the auth flow, or that sent a body other than the one asserted on, could not
474
+ produce the expected header.
475
+ """
476
+
477
+ requires_request_body = True
478
+
479
+ def auth_flow(self, request: httpx.Request):
480
+ request.headers["x-body-digest"] = (
481
+ request.content.decode("utf-8") if request.content else ""
482
+ )
483
+ yield request
484
+
485
+
486
+ def _build_mock_client(
487
+ kind: str,
488
+ entries: list[dict],
489
+ client_kwargs: dict | None = None,
490
+ auth: str | None = None,
491
+ event_hook_header: str | None = None,
492
+ routes: list[dict] | None = None,
493
+ ) -> tuple[Any, _MockTransport]:
494
+ transport = _MockTransport(entries, routes)
495
+ kwargs: dict[str, Any] = {
496
+ "transport": transport,
497
+ "base_url": "http://mock",
498
+ **(client_kwargs or {}),
499
+ }
500
+ if auth == "body-digest":
501
+ kwargs["auth"] = _BodyDigestAuth()
502
+ if event_hook_header:
503
+ # An AsyncClient only awaits its hooks, so each flavour needs its own.
504
+ def _hook(request: httpx.Request) -> None:
505
+ request.headers[event_hook_header] = "yes"
506
+
507
+ async def _async_hook(request: httpx.Request) -> None:
508
+ request.headers[event_hook_header] = "yes"
509
+
510
+ kwargs["event_hooks"] = {
511
+ "request": [_hook if kind == "sync" else _async_hook]
512
+ }
513
+ if kind == "sync":
514
+ return httpx.Client(**kwargs), transport
515
+ return httpx.AsyncClient(**kwargs), transport
516
+
517
+
518
+ def _to_jsonable(value: Any) -> Any:
519
+ if value is None or isinstance(value, (str, int, float, bool)):
520
+ return value
521
+ if isinstance(value, (list, tuple)):
522
+ return [_to_jsonable(v) for v in value]
523
+ if isinstance(value, dict):
524
+ return {str(k): _to_jsonable(v) for k, v in value.items()}
525
+ if hasattr(value, "model_dump"):
526
+ return value.model_dump(mode="json", by_alias=True, exclude_unset=False)
527
+ return repr(value)
528
+
529
+
530
+ def handle_invoke(req: dict) -> dict:
531
+ if not _PACKAGE_DIR:
532
+ raise RuntimeError("no files loaded — compile first")
533
+ pkg = _load_generated(_PACKAGE_DIR, req.get("package", "generated"))
534
+ method_name = req["method"]
535
+ module_kind = req["module"] # "sync" | "async"
536
+ is_stream = bool(req.get("stream"))
537
+ # When set, the stream is abandoned after this many items and then closed.
538
+ stream_take = req.get("stream_take")
539
+
540
+ client_cls, cls_name = _resolve_invoke(module_kind, pkg)
541
+ config_cls = getattr(pkg, f"{cls_name}Config")
542
+
543
+ httpx_client, transport = _build_mock_client(
544
+ module_kind,
545
+ req.get("mock", []),
546
+ req.get("httpx_client_kwargs") or {},
547
+ req.get("auth"),
548
+ req.get("event_hook_header"),
549
+ req.get("routes") or [],
550
+ )
551
+ client = client_cls(
552
+ config_cls(
553
+ url=req.get("base_url", "http://mock"),
554
+ httpx_client=httpx_client,
555
+ **req.get("client_kwargs", {}),
556
+ )
557
+ )
558
+
559
+ args = req.get("args", [])
560
+ kwargs = req.get("kwargs", {})
561
+ # The client doesn't own the httpx client here, so closing it must leave the
562
+ # caller's client usable for the call that follows.
563
+ if req.get("close_then_reuse"):
564
+ closer = getattr(client, "close", None)
565
+ if callable(closer):
566
+ closer()
567
+
568
+ def _stream_released() -> bool:
569
+ # Whether closing the stream reached the response body. The release runs
570
+ # when the `with client.stream(...)` block inside the generated method
571
+ # exits, so a delegate that re-yields rather than forwarding close()
572
+ # never gets there and holds the connection until GC.
573
+ return len(transport.released) == len(transport.responses) > 0
574
+
575
+ def _resolve_method(obj: Any, dotted: str) -> Callable[..., Any]:
576
+ for part in dotted.split("."):
577
+ obj = getattr(obj, part)
578
+ return obj
579
+
580
+ def _invoke_sync() -> Any:
581
+ fn = _resolve_method(client, method_name)
582
+ try:
583
+ if is_stream:
584
+ if stream_take is not None:
585
+ # Stop early and close, so a delegate that doesn't forward
586
+ # close() to the generator actually producing the items
587
+ # leaves the response open and is caught below.
588
+ stream = fn(*args, **kwargs)
589
+ taken = list(itertools.islice(stream, stream_take))
590
+ stream.close()
591
+ return {"items": taken, "closed": _stream_released()}
592
+ return list(fn(*args, **kwargs))
593
+ return fn(*args, **kwargs)
594
+ finally:
595
+ try:
596
+ httpx_client.close()
597
+ except Exception:
598
+ pass
599
+
600
+ async def _invoke_async() -> Any:
601
+ fn = _resolve_method(client, method_name)
602
+ try:
603
+ if is_stream:
604
+ if stream_take is not None:
605
+ stream = fn(*args, **kwargs)
606
+ taken: list[Any] = []
607
+ async for item in stream:
608
+ taken.append(item)
609
+ if len(taken) >= stream_take:
610
+ break
611
+ await stream.aclose()
612
+ return {"items": taken, "closed": _stream_released()}
613
+ collected: list[Any] = []
614
+ async for item in fn(*args, **kwargs):
615
+ collected.append(item)
616
+ return collected
617
+ return await fn(*args, **kwargs)
618
+ finally:
619
+ try:
620
+ await httpx_client.aclose()
621
+ except Exception:
622
+ pass
623
+
624
+ try:
625
+ with warnings.catch_warnings():
626
+ # A deprecation the generated code triggers is a defect callers would
627
+ # see, so a test can ask for it to fail the call.
628
+ if req.get("error_on_warning"):
629
+ warnings.simplefilter("error")
630
+ if module_kind == "sync":
631
+ result = _invoke_sync()
632
+ else:
633
+ result = asyncio.run(_invoke_async())
634
+ except Exception as exc: # noqa: BLE001
635
+ exc_info: dict[str, Any] = {
636
+ "type": type(exc).__name__,
637
+ }
638
+ error_payload = getattr(exc, "error", None)
639
+ if error_payload is not None:
640
+ exc_info["error_type"] = type(error_payload).__name__
641
+ exc_info["error"] = _to_jsonable(error_payload)
642
+ if hasattr(exc, "status"):
643
+ exc_info["status"] = getattr(exc, "status")
644
+ catch_as = req.get("catch_as")
645
+ if catch_as:
646
+ expected = getattr(pkg, catch_as, None)
647
+ exc_info["caught_as"] = isinstance(expected, type) and isinstance(
648
+ exc, expected
649
+ )
650
+ return {
651
+ "ok": False,
652
+ "error": f"{type(exc).__name__}: {exc}",
653
+ "exception": exc_info,
654
+ "traceback": traceback.format_exc(),
655
+ "calls": transport.calls,
656
+ }
657
+
658
+ response = {
659
+ "ok": True,
660
+ "value": _to_jsonable(result),
661
+ "py_type": type(result).__name__,
662
+ "calls": transport.calls,
663
+ }
664
+ # The Python type of each element, so a list parsed as a single scalar (or
665
+ # with the wrong element type) is distinguishable from a correct one.
666
+ if isinstance(result, (list, tuple)):
667
+ response["py_element_types"] = [type(item).__name__ for item in result]
668
+ return response
669
+
670
+
671
+ # ─── Dispatch loop ───────────────────────────────────────────────────────
672
+ HANDLERS: dict[str, Callable[[dict], dict]] = {
673
+ "compile": handle_compile,
674
+ "invoke": handle_invoke,
675
+ "type_check_usage": handle_type_check_usage,
676
+ }
677
+
678
+
679
+ def _main() -> None:
680
+ # Line-buffered stdout so parent sees responses immediately.
681
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
682
+ for raw in sys.stdin:
683
+ raw = raw.strip()
684
+ if not raw:
685
+ continue
686
+ try:
687
+ req = json.loads(raw)
688
+ handler = HANDLERS.get(req.get("cmd"))
689
+ if not handler:
690
+ resp = {"ok": False, "error": f"unknown command: {req.get('cmd')}"}
691
+ else:
692
+ resp = handler(req)
693
+ except Exception as exc: # noqa: BLE001
694
+ resp = {
695
+ "ok": False,
696
+ "error": f"{type(exc).__name__}: {exc}",
697
+ "traceback": traceback.format_exc(),
698
+ }
699
+ sys.stdout.write(json.dumps(resp) + "\n")
700
+
701
+
702
+ if __name__ == "__main__":
703
+ _main()