@aws/nx-plugin 1.0.0 → 1.0.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.
- package/LICENSE-THIRD-PARTY +11686 -23398
- package/README.md +18 -0
- package/generators.json +7 -0
- package/migrations.json +34 -1
- package/package.json +6 -6
- package/src/agentcore-gateway/schema.json +1 -0
- package/src/agentcore-harness/generator.d.ts +1 -1
- package/src/connection/scaffold-catalog.d.ts +4 -0
- package/src/connection/scaffold-catalog.js +3 -6
- package/src/connection/scaffold-catalog.js.map +1 -1
- package/src/infra/app/generator.d.ts +1 -1
- package/src/init/generator.d.ts +1 -1
- package/src/mcp-server/guide-pipeline.d.ts +2 -1
- package/src/mcp-server/guide-pipeline.js +6 -0
- package/src/mcp-server/guide-pipeline.js.map +1 -1
- package/src/mcp-server/mdx-ast.d.ts +7 -0
- package/src/mcp-server/mdx-ast.js +15 -0
- package/src/mcp-server/mdx-ast.js.map +1 -1
- package/src/mcp-server/schema-registry.d.ts +2 -2
- package/src/mcp-server/schema-registry.js +2 -2
- package/src/mcp-server/schema-registry.js.map +1 -1
- package/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap +34 -0
- package/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +1750 -0
- package/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap +82 -0
- package/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +447 -0
- package/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +404 -0
- package/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +430 -0
- package/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +447 -0
- package/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +3488 -0
- package/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +1444 -0
- package/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +390 -0
- package/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +364 -0
- package/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +739 -0
- package/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +384 -0
- package/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +414 -0
- package/src/open-api/py-client/files/client/__clientModuleName__.py.template +1076 -0
- package/src/open-api/py-client/files/shared/__init__.py.template +28 -0
- package/src/open-api/py-client/files/shared/errors.py.template +37 -0
- package/src/open-api/py-client/files/shared/types.py.template +319 -0
- package/src/open-api/py-client/generator.d.ts +53 -0
- package/src/open-api/py-client/generator.js +119 -0
- package/src/open-api/py-client/generator.js.map +1 -0
- package/src/open-api/py-client/schema.d.js +6 -0
- package/src/open-api/py-client/schema.d.js.map +1 -0
- package/src/open-api/py-client/schema.d.ts +12 -0
- package/src/open-api/py-client/schema.json +28 -0
- package/src/open-api/py-client/vessel-registry-spec.json +2716 -0
- package/src/open-api/ts-client/__snapshots__/generator.edge-cases.spec.ts.snap +274 -0
- package/src/open-api/ts-client/petstore-spec.d.ts +6 -0
- package/src/open-api/ts-client/petstore-spec.js +1275 -0
- package/src/open-api/ts-client/petstore-spec.js.map +1 -0
- package/src/open-api/ts-hooks/generator.spec.tsx +1 -1
- package/src/open-api/utils/codegen-data/languages.d.ts +68 -2
- package/src/open-api/utils/codegen-data/languages.js +433 -21
- package/src/open-api/utils/codegen-data/languages.js.map +1 -1
- package/src/open-api/utils/codegen-data/types.d.ts +187 -0
- package/src/open-api/utils/codegen-data/types.js +10 -1
- package/src/open-api/utils/codegen-data/types.js.map +1 -1
- package/src/open-api/utils/codegen-data.d.ts +40 -1
- package/src/open-api/utils/codegen-data.js +622 -17
- package/src/open-api/utils/codegen-data.js.map +1 -1
- package/src/open-api/utils/normalise.js +175 -41
- package/src/open-api/utils/normalise.js.map +1 -1
- package/src/open-api/utils/parser.js +1 -1
- package/src/open-api/utils/parser.js.map +1 -1
- package/src/preset/__snapshots__/generator.spec.ts.snap +3 -3
- package/src/preset/generator.d.ts +1 -1
- package/src/py/agent/gateway-connection/generator.js +0 -3
- package/src/py/agent/gateway-connection/generator.js.map +1 -1
- package/src/py/agent/react-connection/generator.d.ts +1 -1
- package/src/py/agent/schema.json +2 -0
- package/src/py/fast-api/react/generator.d.ts +1 -1
- package/src/py/mcp-server/schema.json +1 -0
- package/src/sdk/open-api.d.ts +2 -0
- package/src/sdk/open-api.js +1 -0
- package/src/sdk/open-api.js.map +1 -1
- package/src/smithy/project/generator.d.ts +1 -1
- package/src/smithy/react-connection/generator.d.ts +1 -1
- package/src/terraform/project/generator.d.ts +1 -1
- package/src/trpc/react/generator.d.ts +1 -1
- package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
- package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
- package/src/ts/agent/gateway-connection/generator.js +0 -3
- package/src/ts/agent/gateway-connection/generator.js.map +1 -1
- package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
- package/src/ts/agent/react-connection/generator.d.ts +1 -1
- package/src/ts/agent/schema.json +1 -0
- package/src/ts/api/schema.json +2 -0
- package/src/ts/astro-docs/generator.d.ts +1 -1
- package/src/ts/dcr-proxy/generator.d.ts +1 -1
- package/src/ts/dynamodb/generator.d.ts +1 -1
- package/src/ts/lambda-function/generator.d.ts +1 -1
- package/src/ts/lib/generator.d.ts +1 -1
- package/src/ts/mcp-server/schema.json +1 -0
- package/src/ts/nx-generator/__snapshots__/generator.spec.ts.snap +1 -1
- package/src/ts/nx-generator/files/nx-plugin-for-aws/generator/generator.spec.ts.template +1 -1
- package/src/ts/nx-generator/generator.d.ts +1 -1
- package/src/ts/nx-migration/generator.d.ts +1 -1
- package/src/ts/nx-plugin/generator.d.ts +1 -1
- package/src/ts/rdb/generator.d.ts +1 -1
- package/src/ts/react-website/agui/generator.d.ts +1 -1
- package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +10 -10
- package/src/ts/react-website/app/schema.json +1 -0
- package/src/ts/website/app/schema.json +1 -0
- package/src/utils/metrics-assertions.d.ts +18 -0
- package/src/utils/metrics-assertions.js +42 -0
- package/src/utils/metrics-assertions.js.map +1 -0
- package/src/utils/test/python-dependencies.d.ts +8 -0
- package/src/utils/test/python-dependencies.js +14 -0
- package/src/utils/test/python-dependencies.js.map +1 -0
- package/src/utils/test/python-worker/worker.py +703 -0
- package/src/utils/test/warm-python-cache.d.ts +18 -0
- package/src/utils/test/warm-python-cache.js +46 -0
- package/src/utils/test/warm-python-cache.js.map +1 -0
- package/src/utils/versions.d.ts +10 -9
- package/src/utils/versions.js +9 -8
- 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()
|