@lovrozagar/honey 0.1.0
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/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- package/src/yaml.ts +80 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
/** Python SDK code generator. Mirrors TS SDK emitter in codegen.ts. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs"
|
|
4
|
+
import { extractOpenApiPathParams, isSSEOperation } from "./codegen.ts"
|
|
5
|
+
import { methodsOf, namespacesOf, toIR } from "./codegen-ir.ts"
|
|
6
|
+
import type { IRNamespace } from "./codegen-ir.ts"
|
|
7
|
+
import { jsonSchemaToPy, pyIdent } from "./python-type-emitter.ts"
|
|
8
|
+
|
|
9
|
+
interface PySDKOptions {
|
|
10
|
+
name?: string
|
|
11
|
+
throwOnError?: boolean
|
|
12
|
+
staleTime?: number
|
|
13
|
+
staleMaxEntries?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ServiceMapEntry {
|
|
17
|
+
method: string
|
|
18
|
+
path: string
|
|
19
|
+
operationId: string
|
|
20
|
+
invalidate?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PySDKResult {
|
|
24
|
+
files: Record<string, string>
|
|
25
|
+
serviceMap: Record<string, ServiceMapEntry>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface OperationInfo {
|
|
29
|
+
operationId: string
|
|
30
|
+
method: string
|
|
31
|
+
path: string
|
|
32
|
+
summary?: string
|
|
33
|
+
description?: string
|
|
34
|
+
pathParams: string[]
|
|
35
|
+
queryParams: ParamInfo[]
|
|
36
|
+
bodySchema: Record<string, unknown> | undefined
|
|
37
|
+
bodyRequired: boolean
|
|
38
|
+
bodyIsStream: boolean
|
|
39
|
+
successType: string
|
|
40
|
+
errorStatuses: number[]
|
|
41
|
+
isSSE: boolean
|
|
42
|
+
isWS: boolean
|
|
43
|
+
isRealtime: boolean
|
|
44
|
+
isIdempotent: boolean
|
|
45
|
+
invalidate: string[]
|
|
46
|
+
parameters: Array<{ in: string; name: string; required: boolean; schema: Record<string, unknown> }>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ParamInfo {
|
|
50
|
+
name: string
|
|
51
|
+
required: boolean
|
|
52
|
+
schema: Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PYTHON_FUTURE = "from __future__ import annotations"
|
|
56
|
+
const STATIC_FILE_NAMES = ["_runtime.py", "_errors.py", "_invalidation.py", "_sse.py", "_ws.py", "_transport.py", "_realtime.py"] as const
|
|
57
|
+
const RUNTIME_TEMPLATE_CACHE = new Map<string, string>()
|
|
58
|
+
const WS_EXTENSION = "x-websocket"
|
|
59
|
+
const REALTIME_EXTENSION = "x-realtime"
|
|
60
|
+
const INVALIDATE_EXTENSION = "x-invalidate"
|
|
61
|
+
const IDEMPOTENCY_EXTENSION = "x-idempotency-key"
|
|
62
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const
|
|
63
|
+
|
|
64
|
+
const ERROR_NAMES: Record<number, string> = {
|
|
65
|
+
400: "BadRequestError",
|
|
66
|
+
401: "UnauthorizedError",
|
|
67
|
+
403: "ForbiddenError",
|
|
68
|
+
404: "NotFoundError",
|
|
69
|
+
409: "ConflictError",
|
|
70
|
+
422: "UnprocessableEntityError",
|
|
71
|
+
429: "RateLimitError",
|
|
72
|
+
500: "InternalServerError",
|
|
73
|
+
502: "BadGatewayError",
|
|
74
|
+
503: "ServiceUnavailableError",
|
|
75
|
+
504: "GatewayTimeoutError",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadPythonRuntimeTemplates(): Map<string, string> {
|
|
79
|
+
if (RUNTIME_TEMPLATE_CACHE.size > 0) return RUNTIME_TEMPLATE_CACHE
|
|
80
|
+
for (const name of STATIC_FILE_NAMES) {
|
|
81
|
+
const url = new URL(`./client-python/${name}`, import.meta.url)
|
|
82
|
+
RUNTIME_TEMPLATE_CACHE.set(name, readFileSync(url, "utf8"))
|
|
83
|
+
}
|
|
84
|
+
RUNTIME_TEMPLATE_CACHE.set("py.typed", "")
|
|
85
|
+
return RUNTIME_TEMPLATE_CACHE
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveSchema(
|
|
89
|
+
spec: Record<string, unknown>,
|
|
90
|
+
schema: Record<string, unknown> | undefined,
|
|
91
|
+
): Record<string, unknown> | undefined {
|
|
92
|
+
if (!schema) return undefined
|
|
93
|
+
if (!schema.$ref) return schema
|
|
94
|
+
const ref = schema.$ref as string
|
|
95
|
+
const parts = ref.replace(/^#\//, "").split("/")
|
|
96
|
+
let cur: unknown = spec
|
|
97
|
+
for (const part of parts) {
|
|
98
|
+
cur = (cur as Record<string, unknown>)[part]
|
|
99
|
+
}
|
|
100
|
+
return cur as Record<string, unknown> | undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getSuccessType(
|
|
104
|
+
spec: Record<string, unknown>,
|
|
105
|
+
operation: Record<string, unknown>,
|
|
106
|
+
): string {
|
|
107
|
+
const responses = operation.responses as Record<string, Record<string, unknown>> | undefined
|
|
108
|
+
if (!responses) return "None"
|
|
109
|
+
const successCodes = ["200", "201", "202", "204"]
|
|
110
|
+
for (const code of successCodes) {
|
|
111
|
+
const resp = responses[code]
|
|
112
|
+
if (!resp) continue
|
|
113
|
+
if (code === "204") return "None"
|
|
114
|
+
const content = resp.content as Record<string, Record<string, unknown>> | undefined
|
|
115
|
+
if (!content) continue
|
|
116
|
+
const jsonContent = content["application/json"]
|
|
117
|
+
if (!jsonContent) continue
|
|
118
|
+
const schema = resolveSchema(spec, jsonContent.schema as Record<string, unknown> | undefined)
|
|
119
|
+
if (!schema) continue
|
|
120
|
+
const rawRef = (jsonContent.schema as Record<string, unknown> | undefined)?.$ref as string | undefined
|
|
121
|
+
if (rawRef) {
|
|
122
|
+
const parts = rawRef.split("/")
|
|
123
|
+
return parts[parts.length - 1]
|
|
124
|
+
}
|
|
125
|
+
return jsonSchemaToPy(schema, 0)
|
|
126
|
+
}
|
|
127
|
+
return "None"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getErrorStatuses(operation: Record<string, unknown>): number[] {
|
|
131
|
+
const responses = operation.responses as Record<string, Record<string, unknown>> | undefined
|
|
132
|
+
if (!responses) return []
|
|
133
|
+
return Object.keys(responses)
|
|
134
|
+
.map(Number)
|
|
135
|
+
.filter((n) => n >= 400)
|
|
136
|
+
.sort()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectOperations(spec: Record<string, unknown>): OperationInfo[] {
|
|
140
|
+
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined
|
|
141
|
+
if (!paths) return []
|
|
142
|
+
const ops: OperationInfo[] = []
|
|
143
|
+
|
|
144
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
145
|
+
for (const httpMethod of HTTP_METHODS) {
|
|
146
|
+
const operation = pathItem[httpMethod] as Record<string, unknown> | undefined
|
|
147
|
+
if (!operation) continue
|
|
148
|
+
const operationId = operation.operationId as string | undefined
|
|
149
|
+
if (!operationId) continue
|
|
150
|
+
|
|
151
|
+
const rawParams = (operation.parameters ?? []) as Array<Record<string, unknown>>
|
|
152
|
+
const pathParams = extractOpenApiPathParams(path)
|
|
153
|
+
const queryParams: ParamInfo[] = rawParams
|
|
154
|
+
.filter((p) => p.in === "query")
|
|
155
|
+
.map((p) => ({
|
|
156
|
+
name: p.name as string,
|
|
157
|
+
required: p.required === true,
|
|
158
|
+
schema: (p.schema ?? {}) as Record<string, unknown>,
|
|
159
|
+
}))
|
|
160
|
+
|
|
161
|
+
const requestBody = operation.requestBody as Record<string, unknown> | undefined
|
|
162
|
+
let bodySchema: Record<string, unknown> | undefined
|
|
163
|
+
let bodyIsStream = false
|
|
164
|
+
const bodyRequired = requestBody?.required === true
|
|
165
|
+
if (requestBody) {
|
|
166
|
+
const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
|
|
167
|
+
const jsonContent = content?.["application/json"]
|
|
168
|
+
const octetContent = content?.["application/octet-stream"]
|
|
169
|
+
if (octetContent) {
|
|
170
|
+
bodyIsStream = true
|
|
171
|
+
} else if (jsonContent) {
|
|
172
|
+
const rawSchema = jsonContent.schema as Record<string, unknown> | undefined
|
|
173
|
+
bodySchema = resolveSchema(spec, rawSchema)
|
|
174
|
+
if (rawSchema?.$ref) {
|
|
175
|
+
const refParts = (rawSchema.$ref as string).split("/")
|
|
176
|
+
bodySchema = { $ref: rawSchema.$ref, _refName: refParts[refParts.length - 1] }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const isRealtime = operation[REALTIME_EXTENSION] === true
|
|
182
|
+
ops.push({
|
|
183
|
+
bodyIsStream,
|
|
184
|
+
bodyRequired,
|
|
185
|
+
bodySchema,
|
|
186
|
+
description: operation.description as string | undefined,
|
|
187
|
+
errorStatuses: getErrorStatuses(operation),
|
|
188
|
+
invalidate: (operation[INVALIDATE_EXTENSION] as string[] | undefined) ?? [],
|
|
189
|
+
isIdempotent: operation[IDEMPOTENCY_EXTENSION] === true,
|
|
190
|
+
/* realtime wins over SSE / WS when extensions conflict — higher-level protocol */
|
|
191
|
+
isRealtime,
|
|
192
|
+
isSSE: !isRealtime && isSSEOperation(operation),
|
|
193
|
+
isWS: !isRealtime && operation[WS_EXTENSION] === true,
|
|
194
|
+
method: httpMethod.toUpperCase(),
|
|
195
|
+
operationId,
|
|
196
|
+
parameters: rawParams as Array<{ in: string; name: string; required: boolean; schema: Record<string, unknown> }>,
|
|
197
|
+
path,
|
|
198
|
+
pathParams,
|
|
199
|
+
queryParams,
|
|
200
|
+
successType: getSuccessType(spec, operation),
|
|
201
|
+
summary: operation.summary as string | undefined,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return ops
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function nestedClassName(path: string[]): string {
|
|
210
|
+
const pascal = path.map((seg) => {
|
|
211
|
+
const safe = pyIdent(seg)
|
|
212
|
+
return safe.charAt(0).toUpperCase() + safe.slice(1)
|
|
213
|
+
}).join("")
|
|
214
|
+
return `_${pascal}Resource`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function collectSDKMethods(spec: Record<string, unknown>): {
|
|
218
|
+
tree: IRNamespace
|
|
219
|
+
topLevel: OperationInfo[]
|
|
220
|
+
opsByOpId: Map<string, OperationInfo>
|
|
221
|
+
} {
|
|
222
|
+
const ops = collectOperations(spec)
|
|
223
|
+
const ir = toIR(spec as Parameters<typeof toIR>[0])
|
|
224
|
+
const topLevel: OperationInfo[] = []
|
|
225
|
+
const opsByOpId = new Map<string, OperationInfo>()
|
|
226
|
+
|
|
227
|
+
for (const op of ops) {
|
|
228
|
+
opsByOpId.set(op.operationId, op)
|
|
229
|
+
if (!op.operationId.includes(".")) {
|
|
230
|
+
topLevel.push(op)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { opsByOpId, topLevel, tree: ir.tree }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function pyParamName(name: string): string {
|
|
238
|
+
const safe = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^([0-9])/, "_$1")
|
|
239
|
+
return pyIdent(safe)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function bodyTypeName(bodySchema: Record<string, unknown> | undefined): string {
|
|
243
|
+
if (!bodySchema) return "Any"
|
|
244
|
+
const refName = bodySchema._refName as string | undefined
|
|
245
|
+
if (refName) return refName
|
|
246
|
+
return "Any"
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildMethodSignature(op: OperationInfo, throwOnError: boolean, isAsync: boolean): string[] {
|
|
250
|
+
const lines: string[] = []
|
|
251
|
+
/* WS methods just construct the _TypedWebSocket context-manager — no async
|
|
252
|
+
* work happens until __aenter__; emit as sync `def` matching TS / Go / Rust
|
|
253
|
+
* surface (caller awaits __aenter__ via `async with`, not the constructor). */
|
|
254
|
+
let defKw: string
|
|
255
|
+
if (op.isWS) defKw = "def"
|
|
256
|
+
else if (isAsync) defKw = "async def"
|
|
257
|
+
else defKw = "def"
|
|
258
|
+
const segments = op.operationId.split(".")
|
|
259
|
+
const actionPart = segments[segments.length - 1] ?? op.operationId
|
|
260
|
+
/* convert camelCase to snake_case for Python naming convention */
|
|
261
|
+
const snakeAction = actionPart.replace(/([A-Z])/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, "")
|
|
262
|
+
const ident = pyIdent(snakeAction)
|
|
263
|
+
/* realtime method always named `connect<X>` — follow camelCase convention used elsewhere
|
|
264
|
+
* in the emitted SDK (connectWs, createUser). If operationId already begins with "connect",
|
|
265
|
+
* use verbatim; otherwise prepend "connect" + capitalize first char of action. */
|
|
266
|
+
const alreadyConnectPrefixed = /^connect([A-Z_]|$)/.test(ident)
|
|
267
|
+
const realtimeName = alreadyConnectPrefixed ? ident : `connect${ident.charAt(0).toUpperCase()}${ident.slice(1)}`
|
|
268
|
+
const methodName = op.isRealtime ? realtimeName : ident
|
|
269
|
+
|
|
270
|
+
const posArgs: string[] = ["self"]
|
|
271
|
+
|
|
272
|
+
for (const pp of op.pathParams) {
|
|
273
|
+
posArgs.push(`${pyParamName(pp)}: str`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (op.isRealtime) {
|
|
277
|
+
const kwArgsRt: string[] = [
|
|
278
|
+
`reconnect_token: str | None = None`,
|
|
279
|
+
`last_event_id: str | None = None`,
|
|
280
|
+
`headers: dict[str, str] | None = None`,
|
|
281
|
+
]
|
|
282
|
+
const posLineRt = posArgs.join(", ")
|
|
283
|
+
/* realtime method is sync — returns ResumableConnection (async-iterable).
|
|
284
|
+
* matches TS: `sdk.connectRealtime()` returns object, not a Promise. */
|
|
285
|
+
lines.push(` def ${methodName}(${posLineRt},`)
|
|
286
|
+
lines.push(` *,`)
|
|
287
|
+
for (let i = 0; i < kwArgsRt.length; i++) {
|
|
288
|
+
const comma = i < kwArgsRt.length - 1 ? "," : ""
|
|
289
|
+
lines.push(` ${kwArgsRt[i]}${comma}`)
|
|
290
|
+
}
|
|
291
|
+
lines.push(` ) -> ResumableConnection:`)
|
|
292
|
+
return lines
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (op.bodyIsStream) {
|
|
296
|
+
posArgs.push(`body: AsyncIterator[bytes] | bytes | bytearray`)
|
|
297
|
+
} else if (op.bodySchema && op.bodyRequired) {
|
|
298
|
+
posArgs.push(`body: ${bodyTypeName(op.bodySchema)}`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const kwArgs: string[] = []
|
|
302
|
+
|
|
303
|
+
if (!op.bodyIsStream && op.bodySchema && !op.bodyRequired) {
|
|
304
|
+
kwArgs.push(`body: ${bodyTypeName(op.bodySchema)} | None = None`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (op.isSSE) {
|
|
308
|
+
kwArgs.push(`last_event_id: str | None = None`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (op.isWS) {
|
|
312
|
+
kwArgs.push(`protocols: list[str] | None = None`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const qp of op.queryParams) {
|
|
316
|
+
const pname = pyParamName(qp.name)
|
|
317
|
+
const pyType = jsonSchemaToPy(qp.schema, 0)
|
|
318
|
+
if (qp.required) {
|
|
319
|
+
posArgs.push(`${pname}: ${pyType}`)
|
|
320
|
+
} else {
|
|
321
|
+
/* skip last-event-id as already handled above */
|
|
322
|
+
if (op.isSSE && (qp.name === "last-event-id" || qp.name === "last_event_id")) continue
|
|
323
|
+
kwArgs.push(`${pname}: ${pyType} | None = None`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* SSE/WS use config-level timeout/headers, not per-call */
|
|
328
|
+
if (!op.isSSE && !op.isWS) {
|
|
329
|
+
if (op.isIdempotent) kwArgs.push(`idempotency_key: str | None = None`)
|
|
330
|
+
kwArgs.push(`timeout: float | None = None`)
|
|
331
|
+
kwArgs.push(`headers: dict[str, str] | None = None`)
|
|
332
|
+
kwArgs.push(`cancel_token: "threading.Event | None" = None`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let returnType: string
|
|
336
|
+
if (op.isSSE) {
|
|
337
|
+
returnType = "AsyncIterator[SSEEvent]"
|
|
338
|
+
} else if (op.isWS) {
|
|
339
|
+
returnType = "_TypedWebSocket"
|
|
340
|
+
} else if (throwOnError) {
|
|
341
|
+
returnType = op.successType
|
|
342
|
+
} else {
|
|
343
|
+
returnType = `SDKResult[${op.successType}]`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (kwArgs.length === 0) {
|
|
347
|
+
lines.push(` ${defKw} ${methodName}(${posArgs.join(", ")}) -> ${returnType}:`)
|
|
348
|
+
} else {
|
|
349
|
+
const posLine = posArgs.join(", ")
|
|
350
|
+
lines.push(` ${defKw} ${methodName}(${posLine},`)
|
|
351
|
+
lines.push(` *,`)
|
|
352
|
+
for (let i = 0; i < kwArgs.length; i++) {
|
|
353
|
+
const comma = i < kwArgs.length - 1 ? "," : ""
|
|
354
|
+
lines.push(` ${kwArgs[i]}${comma}`)
|
|
355
|
+
}
|
|
356
|
+
lines.push(` ) -> ${returnType}:`)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return lines
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildMethodDocstring(op: OperationInfo): string[] {
|
|
363
|
+
const lines: string[] = []
|
|
364
|
+
const text = op.summary ?? op.description ?? ""
|
|
365
|
+
if (!text && op.errorStatuses.length === 0) return lines
|
|
366
|
+
|
|
367
|
+
if (op.errorStatuses.length === 0) {
|
|
368
|
+
/* single-line docstring when no Raises section */
|
|
369
|
+
lines.push(` """${text}"""`)
|
|
370
|
+
return lines
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
lines.push(` """${text}`)
|
|
374
|
+
lines.push(``)
|
|
375
|
+
lines.push(` Raises:`)
|
|
376
|
+
for (const status of op.errorStatuses) {
|
|
377
|
+
const name = ERROR_NAMES[status] ?? "APIStatusError"
|
|
378
|
+
lines.push(` ${name}: HTTP ${status}`)
|
|
379
|
+
}
|
|
380
|
+
lines.push(` """`)
|
|
381
|
+
return lines
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function buildUrlLine(op: OperationInfo, isTopLevel: boolean): string {
|
|
385
|
+
const configRef = isTopLevel ? "self._config" : "self._client._config"
|
|
386
|
+
if (op.pathParams.length > 0) {
|
|
387
|
+
const urlExpr = op.path.replace(/\{(\w+)\}/g, (_, p) => `{quote(${pyParamName(p)}, safe='')}`)
|
|
388
|
+
return ` url = _build_url(${configRef}.base_url, f"${urlExpr}")`
|
|
389
|
+
}
|
|
390
|
+
return ` url = _build_url(${configRef}.base_url, "${op.path}")`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildResponseLines(throwOnError: boolean): string[] {
|
|
394
|
+
if (throwOnError) {
|
|
395
|
+
return [
|
|
396
|
+
` _raise_for_status(_response)`,
|
|
397
|
+
` return _parse_body(_response)`,
|
|
398
|
+
]
|
|
399
|
+
}
|
|
400
|
+
return [
|
|
401
|
+
` _body = _parse_body(_response)`,
|
|
402
|
+
` if _response.status_code >= 400:`,
|
|
403
|
+
` return SDKResult(data=None, error=_body, status=_response.status_code, response=_response)`,
|
|
404
|
+
` return SDKResult(data=_body, error=None, status=_response.status_code, response=_response)`,
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* Build path-params dict literal string used by mark_stale / interpolate_path:
|
|
409
|
+
* { "id": id, "orgId": org_id } — keys are the OpenAPI placeholder names,
|
|
410
|
+
* values are the python-safe local identifiers from the method signature. */
|
|
411
|
+
function buildPathParamsDict(op: OperationInfo): string {
|
|
412
|
+
if (op.pathParams.length === 0) return "None"
|
|
413
|
+
const entries = op.pathParams.map((p) => `"${p}": ${pyParamName(p)}`)
|
|
414
|
+
return `{${entries.join(", ")}}`
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function buildInvalidateListLiteral(op: OperationInfo): string {
|
|
418
|
+
if (op.invalidate.length === 0) return "[]"
|
|
419
|
+
return `[${op.invalidate.map((s) => `"${s}"`).join(", ")}]`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* Emit Idempotency-Key injection block. Precedence:
|
|
423
|
+
* explicit header (any casing) > idempotency_key kwarg > uuid.uuid4().
|
|
424
|
+
* Runs after _headers is built so config.headers + per-call headers merge is visible. */
|
|
425
|
+
function buildIdempotencyLines(op: OperationInfo): string[] {
|
|
426
|
+
if (!op.isIdempotent || op.isSSE || op.isWS || op.isRealtime) return []
|
|
427
|
+
return [
|
|
428
|
+
` _idem_existing = None`,
|
|
429
|
+
` for _k in list(_headers.keys()):`,
|
|
430
|
+
` if _k.lower() == "idempotency-key":`,
|
|
431
|
+
` _idem_existing = _headers[_k]`,
|
|
432
|
+
` break`,
|
|
433
|
+
` if _idem_existing is None:`,
|
|
434
|
+
` _headers["Idempotency-Key"] = idempotency_key if idempotency_key is not None else str(uuid.uuid4())`,
|
|
435
|
+
]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function buildAsyncMethodBody(op: OperationInfo, throwOnError: boolean, isTopLevel = false): string[] {
|
|
439
|
+
const lines: string[] = []
|
|
440
|
+
const configRef = isTopLevel ? "self._config" : "self._client._config"
|
|
441
|
+
const asyncClientRef = isTopLevel ? "self._async_client" : "self._client._async_client"
|
|
442
|
+
const trackerRef = isTopLevel ? "self._stale_tracker" : "self._client._stale_tracker"
|
|
443
|
+
|
|
444
|
+
if (op.isRealtime) {
|
|
445
|
+
lines.push(` _transports = [WsAdapter(), SseAdapter(), LongpollAdapter()]`)
|
|
446
|
+
lines.push(` _opts = TransportOpts(`)
|
|
447
|
+
lines.push(` reconnect_token=reconnect_token,`)
|
|
448
|
+
lines.push(` last_event_id=last_event_id,`)
|
|
449
|
+
lines.push(` headers=headers,`)
|
|
450
|
+
lines.push(` )`)
|
|
451
|
+
lines.push(` _url = _build_url(${configRef}.base_url, "${op.path}")`)
|
|
452
|
+
lines.push(` return ResumableConnection(_url, _transports, _opts)`)
|
|
453
|
+
return lines
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
lines.push(buildUrlLine(op, isTopLevel))
|
|
457
|
+
|
|
458
|
+
if (op.isSSE) {
|
|
459
|
+
lines.push(` _headers = _build_headers(${configRef}, extra={"Accept": "text/event-stream"})`)
|
|
460
|
+
lines.push(` if last_event_id is not None: _headers["Last-Event-ID"] = last_event_id`)
|
|
461
|
+
} else if (op.isWS) {
|
|
462
|
+
lines.push(` _headers = _build_headers(${configRef}, extra=None)`)
|
|
463
|
+
} else if (op.bodyIsStream) {
|
|
464
|
+
lines.push(` _headers = _build_headers(${configRef}, extra={"Content-Type": "application/octet-stream", **(headers or {})})`)
|
|
465
|
+
} else {
|
|
466
|
+
lines.push(` _headers = _build_headers(${configRef}, extra=headers)`)
|
|
467
|
+
}
|
|
468
|
+
lines.push(...buildIdempotencyLines(op))
|
|
469
|
+
|
|
470
|
+
/* SSE skips last-event-id query param — sent as header instead */
|
|
471
|
+
const qpNames = op.queryParams
|
|
472
|
+
.filter((qp) => !(op.isSSE && (qp.name === "last-event-id" || qp.name === "last_event_id")))
|
|
473
|
+
.map((qp) => `"${qp.name}": ${pyParamName(qp.name)}`)
|
|
474
|
+
if (qpNames.length > 0) {
|
|
475
|
+
lines.push(` _params: dict[str, Any] = {${qpNames.join(", ")}}`)
|
|
476
|
+
} else {
|
|
477
|
+
lines.push(` _params: dict[str, Any] = {}`)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (op.isWS) {
|
|
481
|
+
lines.push(` ws_url = url.replace("https://", "wss://").replace("http://", "ws://")`)
|
|
482
|
+
if (op.queryParams.some((qp) => qp.name === "reconnect_token")) {
|
|
483
|
+
lines.push(` if reconnect_token is not None:`)
|
|
484
|
+
lines.push(` _params["reconnect_token"] = reconnect_token`)
|
|
485
|
+
}
|
|
486
|
+
lines.push(` if _params:`)
|
|
487
|
+
lines.push(` from urllib.parse import urlencode`)
|
|
488
|
+
lines.push(` ws_url = f"{ws_url}?{urlencode({k: v for k, v in _params.items() if v is not None})}"`)
|
|
489
|
+
lines.push(` return _TypedWebSocket(ws_url, protocols=protocols, extra_headers=_headers)`)
|
|
490
|
+
return lines
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (op.isSSE) {
|
|
494
|
+
lines.push(` async with ${asyncClientRef}.stream(`)
|
|
495
|
+
lines.push(` "${op.method}", url, headers=_headers,`)
|
|
496
|
+
lines.push(` params={k: v for k, v in _params.items() if v is not None} or None,`)
|
|
497
|
+
lines.push(` ) as _response:`)
|
|
498
|
+
lines.push(` _raise_for_status(_response)`)
|
|
499
|
+
lines.push(` async for _event in parse_sse_stream(_response):`)
|
|
500
|
+
lines.push(` yield _event`)
|
|
501
|
+
return lines
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const pathParamsDict = buildPathParamsDict(op)
|
|
505
|
+
const invalidateLiteral = buildInvalidateListLiteral(op)
|
|
506
|
+
lines.push(` _path_params: dict[str, str] | None = ${pathParamsDict}`)
|
|
507
|
+
lines.push(` _concrete_path = interpolate_path("${op.path}", _path_params)`)
|
|
508
|
+
lines.push(` _concrete_selector = "${op.method} " + _concrete_path`)
|
|
509
|
+
lines.push(` _request_meta = await ${trackerRef}.build_request_meta(`)
|
|
510
|
+
lines.push(` _concrete_selector, _concrete_path, "${op.method}",`)
|
|
511
|
+
lines.push(` )`)
|
|
512
|
+
|
|
513
|
+
let bodyArg: string
|
|
514
|
+
if (op.bodyIsStream) bodyArg = "json=None, content=body"
|
|
515
|
+
else if (op.bodySchema) bodyArg = "json=body"
|
|
516
|
+
else bodyArg = "json=None"
|
|
517
|
+
lines.push(` _response = await _do_request_async(`)
|
|
518
|
+
lines.push(` ${asyncClientRef}, ${configRef}, "${op.method}", url,`)
|
|
519
|
+
lines.push(` headers=_headers, ${bodyArg}, params=_params, timeout=timeout,`)
|
|
520
|
+
lines.push(` cancel_token=cancel_token, operation="${op.method} ${op.path}",`)
|
|
521
|
+
lines.push(` request_meta=_request_meta,`)
|
|
522
|
+
lines.push(` )`)
|
|
523
|
+
lines.push(` if _response.status_code < 400:`)
|
|
524
|
+
lines.push(` await ${trackerRef}.mark_stale(`)
|
|
525
|
+
lines.push(` ${invalidateLiteral}, _path_params, _concrete_selector,`)
|
|
526
|
+
lines.push(` )`)
|
|
527
|
+
lines.push(` if _request_meta is not None and _request_meta.is_stale:`)
|
|
528
|
+
lines.push(` await ${trackerRef}.clear_stale(`)
|
|
529
|
+
lines.push(` _concrete_selector, _concrete_path, "${op.method}",`)
|
|
530
|
+
lines.push(` _request_meta.seq_snapshot,`)
|
|
531
|
+
lines.push(` )`)
|
|
532
|
+
lines.push(...buildResponseLines(throwOnError))
|
|
533
|
+
|
|
534
|
+
return lines
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function buildSyncMethodBody(op: OperationInfo, throwOnError: boolean, isTopLevel = false): string[] {
|
|
538
|
+
const lines: string[] = []
|
|
539
|
+
const configRef = isTopLevel ? "self._config" : "self._client._config"
|
|
540
|
+
const syncClientRef = isTopLevel ? "self._sync_client" : "self._client._sync_client"
|
|
541
|
+
const trackerRef = isTopLevel ? "self._stale_tracker" : "self._client._stale_tracker"
|
|
542
|
+
|
|
543
|
+
lines.push(buildUrlLine(op, isTopLevel))
|
|
544
|
+
lines.push(` _headers = _build_headers(${configRef}, extra=headers)`)
|
|
545
|
+
lines.push(...buildIdempotencyLines(op))
|
|
546
|
+
|
|
547
|
+
const qpNames = op.queryParams.map((qp) => `"${qp.name}": ${pyParamName(qp.name)}`)
|
|
548
|
+
if (qpNames.length > 0) {
|
|
549
|
+
lines.push(` _params: dict[str, Any] = {${qpNames.join(", ")}}`)
|
|
550
|
+
} else {
|
|
551
|
+
lines.push(` _params: dict[str, Any] = {}`)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const pathParamsDict = buildPathParamsDict(op)
|
|
555
|
+
const invalidateLiteral = buildInvalidateListLiteral(op)
|
|
556
|
+
lines.push(` _path_params: dict[str, str] | None = ${pathParamsDict}`)
|
|
557
|
+
lines.push(` _concrete_path = interpolate_path("${op.path}", _path_params)`)
|
|
558
|
+
lines.push(` _concrete_selector = "${op.method} " + _concrete_path`)
|
|
559
|
+
lines.push(` _request_meta = ${trackerRef}.build_request_meta(`)
|
|
560
|
+
lines.push(` _concrete_selector, _concrete_path, "${op.method}",`)
|
|
561
|
+
lines.push(` )`)
|
|
562
|
+
|
|
563
|
+
const jsonArg = op.bodySchema ? "json=body" : "json=None"
|
|
564
|
+
lines.push(` _response = _do_request_sync(`)
|
|
565
|
+
lines.push(` ${syncClientRef}, ${configRef}, "${op.method}", url,`)
|
|
566
|
+
lines.push(` headers=_headers, ${jsonArg}, params=_params, timeout=timeout,`)
|
|
567
|
+
lines.push(` cancel_token=cancel_token, operation="${op.method} ${op.path}",`)
|
|
568
|
+
lines.push(` request_meta=_request_meta,`)
|
|
569
|
+
lines.push(` )`)
|
|
570
|
+
lines.push(` if _response.status_code < 400:`)
|
|
571
|
+
lines.push(` ${trackerRef}.mark_stale(`)
|
|
572
|
+
lines.push(` ${invalidateLiteral}, _path_params, _concrete_selector,`)
|
|
573
|
+
lines.push(` )`)
|
|
574
|
+
lines.push(` if _request_meta is not None and _request_meta.is_stale:`)
|
|
575
|
+
lines.push(` ${trackerRef}.clear_stale(`)
|
|
576
|
+
lines.push(` _concrete_selector, _concrete_path, "${op.method}",`)
|
|
577
|
+
lines.push(` _request_meta.seq_snapshot,`)
|
|
578
|
+
lines.push(` )`)
|
|
579
|
+
lines.push(...buildResponseLines(throwOnError))
|
|
580
|
+
|
|
581
|
+
return lines
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/* ── buildPyTypes ── */
|
|
585
|
+
|
|
586
|
+
function buildPyTypes(spec: Record<string, unknown>): string {
|
|
587
|
+
const schemas = (spec.components as Record<string, unknown> | undefined)?.schemas as
|
|
588
|
+
Record<string, Record<string, unknown>> | undefined
|
|
589
|
+
|
|
590
|
+
const body: string[] = []
|
|
591
|
+
const allNames: string[] = []
|
|
592
|
+
|
|
593
|
+
if (schemas) {
|
|
594
|
+
for (const [name, schema] of Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))) {
|
|
595
|
+
allNames.push(name)
|
|
596
|
+
const props = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
597
|
+
const required = new Set((schema.required as string[] | undefined) ?? [])
|
|
598
|
+
|
|
599
|
+
if (props && Object.keys(props).length > 0) {
|
|
600
|
+
body.push(`class ${name}(TypedDict, total=False):`)
|
|
601
|
+
for (const [key, val] of Object.entries(props).sort(([a], [b]) => a.localeCompare(b))) {
|
|
602
|
+
const pyType = jsonSchemaToPy(val, 0)
|
|
603
|
+
if (required.has(key)) {
|
|
604
|
+
body.push(` ${key}: ${pyType}`)
|
|
605
|
+
} else {
|
|
606
|
+
body.push(` ${key}: NotRequired[${pyType}]`)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
body.push("")
|
|
610
|
+
} else if (schema.type === "string" && schema.format === "uuid") {
|
|
611
|
+
body.push(`${name} = NewType("${name}", str)`)
|
|
612
|
+
body.push("")
|
|
613
|
+
} else {
|
|
614
|
+
body.push(`${name} = NewType("${name}", Any) # type: ignore[misc]`)
|
|
615
|
+
body.push("")
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const bodyText = body.join("\n")
|
|
621
|
+
const typingImports = new Set<string>(["TypedDict", "Literal", "NotRequired", "NewType"])
|
|
622
|
+
if (!bodyText.includes("NewType(")) typingImports.delete("NewType")
|
|
623
|
+
if (!/\bAny\b/.test(bodyText)) typingImports.delete("Any")
|
|
624
|
+
else typingImports.add("Any")
|
|
625
|
+
if (!/\bLiteral\b/.test(bodyText)) typingImports.delete("Literal")
|
|
626
|
+
if (!/\bNotRequired\b/.test(bodyText)) typingImports.delete("NotRequired")
|
|
627
|
+
if (!/\bTypedDict\b/.test(bodyText)) typingImports.delete("TypedDict")
|
|
628
|
+
|
|
629
|
+
const l: string[] = []
|
|
630
|
+
l.push("# ruff: noqa: E501")
|
|
631
|
+
l.push(PYTHON_FUTURE)
|
|
632
|
+
l.push("")
|
|
633
|
+
if (typingImports.size > 0) {
|
|
634
|
+
const sorted = [...typingImports].sort()
|
|
635
|
+
l.push(`from typing import ${sorted.join(", ")}`)
|
|
636
|
+
l.push("")
|
|
637
|
+
}
|
|
638
|
+
if (bodyText.length > 0) {
|
|
639
|
+
l.push(bodyText)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (allNames.length === 0) {
|
|
643
|
+
l.push("__all__: list[str] = []")
|
|
644
|
+
} else {
|
|
645
|
+
l.push(`__all__ = [${allNames.map((n) => `"${n}"`).join(", ")}]`)
|
|
646
|
+
}
|
|
647
|
+
return l.join("\n")
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function buildPyClient(
|
|
651
|
+
spec: Record<string, unknown>,
|
|
652
|
+
options: PySDKOptions,
|
|
653
|
+
serviceMap: Record<string, ServiceMapEntry>,
|
|
654
|
+
): string {
|
|
655
|
+
const throwOnError = options.throwOnError !== false
|
|
656
|
+
const { opsByOpId, topLevel, tree } = collectSDKMethods(spec)
|
|
657
|
+
|
|
658
|
+
const schemas = (spec.components as Record<string, unknown> | undefined)?.schemas as
|
|
659
|
+
Record<string, Record<string, unknown>> | undefined
|
|
660
|
+
const schemaNames = schemas ? Object.keys(schemas) : []
|
|
661
|
+
|
|
662
|
+
const body: string[] = []
|
|
663
|
+
|
|
664
|
+
const invEntries = Object.entries(serviceMap)
|
|
665
|
+
.filter(([, v]) => v.invalidate && v.invalidate.length > 0)
|
|
666
|
+
|
|
667
|
+
body.push("_INVALIDATION_MAP: dict[str, dict[str, Any]] = {")
|
|
668
|
+
for (const [opId, entry] of invEntries) {
|
|
669
|
+
const invItems = (entry.invalidate ?? []).map((s) => `"${s}"`).join(", ")
|
|
670
|
+
body.push(` "${opId}": {`)
|
|
671
|
+
body.push(` "method": "${entry.method}",`)
|
|
672
|
+
body.push(` "path": "${entry.path}",`)
|
|
673
|
+
body.push(` "invalidate": [${invItems}],`)
|
|
674
|
+
body.push(` },`)
|
|
675
|
+
}
|
|
676
|
+
body.push("}")
|
|
677
|
+
body.push("")
|
|
678
|
+
|
|
679
|
+
/* emit async resource classes in post-order (children before parents) */
|
|
680
|
+
function emitAsyncResourceClasses(ns: IRNamespace, path: string[]): void {
|
|
681
|
+
/* recurse into sub-namespaces first (post-order so children declared before parents) */
|
|
682
|
+
for (const [seg, childNs] of namespacesOf(ns)) {
|
|
683
|
+
emitAsyncResourceClasses(childNs, [...path, seg])
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (path.length === 0) return
|
|
687
|
+
|
|
688
|
+
const className = nestedClassName(path)
|
|
689
|
+
const parentType = path.length === 1 ? '"AsyncSDK"' : nestedClassName(path.slice(0, -1))
|
|
690
|
+
body.push(`class ${className}:`)
|
|
691
|
+
body.push(` def __init__(self, _client: ${parentType}) -> None:`)
|
|
692
|
+
body.push(` self._client = _client`)
|
|
693
|
+
|
|
694
|
+
for (const [seg] of namespacesOf(ns)) {
|
|
695
|
+
const childClass = nestedClassName([...path, seg])
|
|
696
|
+
body.push(` self.${pyIdent(seg)} = ${childClass}(self)`)
|
|
697
|
+
}
|
|
698
|
+
body.push("")
|
|
699
|
+
|
|
700
|
+
for (const [, op] of methodsOf(ns)) {
|
|
701
|
+
const opInfo = opsByOpId.get(op.id)
|
|
702
|
+
if (!opInfo) continue
|
|
703
|
+
body.push(...buildMethodSignature(opInfo, throwOnError, true))
|
|
704
|
+
body.push(...buildMethodDocstring(opInfo))
|
|
705
|
+
body.push(...buildAsyncMethodBody(opInfo, throwOnError))
|
|
706
|
+
body.push("")
|
|
707
|
+
}
|
|
708
|
+
body.push("")
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/* emit sync resource classes in post-order */
|
|
712
|
+
function emitSyncResourceClasses(ns: IRNamespace, path: string[]): void {
|
|
713
|
+
for (const [seg, childNs] of namespacesOf(ns)) {
|
|
714
|
+
emitSyncResourceClasses(childNs, [...path, seg])
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (path.length === 0) return
|
|
718
|
+
|
|
719
|
+
const syncMethods = methodsOf(ns).filter(([, op]) => {
|
|
720
|
+
const opInfo = opsByOpId.get(op.id)
|
|
721
|
+
return opInfo && !opInfo.isSSE && !opInfo.isWS && !opInfo.isRealtime && !opInfo.bodyIsStream
|
|
722
|
+
})
|
|
723
|
+
/* check if any sub-namespace has sync methods */
|
|
724
|
+
const hasAnySyncContent = syncMethods.length > 0 || namespacesOf(ns).length > 0
|
|
725
|
+
if (!hasAnySyncContent) return
|
|
726
|
+
|
|
727
|
+
const className = nestedClassName(path)
|
|
728
|
+
const syncClassName = `_Sync${className.slice(1)}`
|
|
729
|
+
const parentType = path.length === 1 ? '"SDK"' : `_Sync${nestedClassName(path.slice(0, -1)).slice(1)}`
|
|
730
|
+
body.push(`class ${syncClassName}:`)
|
|
731
|
+
body.push(` def __init__(self, _client: ${parentType}) -> None:`)
|
|
732
|
+
body.push(` self._client = _client`)
|
|
733
|
+
|
|
734
|
+
for (const [seg, childNs] of namespacesOf(ns)) {
|
|
735
|
+
const childClass = `_Sync${nestedClassName([...path, seg]).slice(1)}`
|
|
736
|
+
const childSyncMethods = methodsOf(childNs).filter(([, op]) => {
|
|
737
|
+
const opInfo = opsByOpId.get(op.id)
|
|
738
|
+
return opInfo && !opInfo.isSSE && !opInfo.isWS && !opInfo.isRealtime && !opInfo.bodyIsStream
|
|
739
|
+
})
|
|
740
|
+
if (childSyncMethods.length > 0 || namespacesOf(childNs).length > 0) {
|
|
741
|
+
body.push(` self.${pyIdent(seg)} = ${childClass}(self)`)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
body.push("")
|
|
745
|
+
|
|
746
|
+
for (const [, op] of syncMethods) {
|
|
747
|
+
const opInfo = opsByOpId.get(op.id)
|
|
748
|
+
if (!opInfo) continue
|
|
749
|
+
body.push(...buildMethodSignature(opInfo, throwOnError, false))
|
|
750
|
+
body.push(...buildMethodDocstring(opInfo))
|
|
751
|
+
body.push(...buildSyncMethodBody(opInfo, throwOnError))
|
|
752
|
+
body.push("")
|
|
753
|
+
}
|
|
754
|
+
body.push("")
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
emitAsyncResourceClasses(tree, [])
|
|
758
|
+
emitSyncResourceClasses(tree, [])
|
|
759
|
+
|
|
760
|
+
body.push("class SDK:")
|
|
761
|
+
body.push(" def __init__(self, config: ClientConfig) -> None:")
|
|
762
|
+
body.push(" self._config = config")
|
|
763
|
+
body.push(" self._sync_client = httpx.Client(")
|
|
764
|
+
body.push(" transport=config.sync_transport,")
|
|
765
|
+
body.push(" )")
|
|
766
|
+
body.push(" _inv = config.invalidation")
|
|
767
|
+
body.push(" self._stale_tracker = _StaleTrackerSync(")
|
|
768
|
+
body.push(" stale_time=_inv.stale_time if _inv is not None else 0.0,")
|
|
769
|
+
body.push(" stale_max_entries=(")
|
|
770
|
+
body.push(" _inv.stale_max_entries if _inv is not None else 1000")
|
|
771
|
+
body.push(" ),")
|
|
772
|
+
body.push(" max_sources_per_target=(")
|
|
773
|
+
body.push(" _inv.max_sources_per_target if _inv is not None else 16")
|
|
774
|
+
body.push(" ),")
|
|
775
|
+
body.push(" )")
|
|
776
|
+
for (const [seg] of namespacesOf(tree)) {
|
|
777
|
+
const className = `_Sync${nestedClassName([seg]).slice(1)}`
|
|
778
|
+
body.push(` self.${pyIdent(seg)} = ${className}(self)`)
|
|
779
|
+
}
|
|
780
|
+
body.push("")
|
|
781
|
+
body.push(" def is_stale(self, method: str, path: str) -> bool:")
|
|
782
|
+
body.push(" \"\"\"Returns True when (method, path) sits inside an active stale window.\"\"\"")
|
|
783
|
+
body.push(" return self._stale_tracker.is_stale(method, path)")
|
|
784
|
+
body.push("")
|
|
785
|
+
|
|
786
|
+
const syncTopLevel = topLevel.filter((op) => !op.isSSE && !op.isWS && !op.isRealtime && !op.bodyIsStream)
|
|
787
|
+
for (const op of syncTopLevel.sort((a, b) => a.operationId.localeCompare(b.operationId))) {
|
|
788
|
+
body.push(...buildMethodSignature(op, throwOnError, false))
|
|
789
|
+
body.push(...buildMethodDocstring(op))
|
|
790
|
+
const bodyLines = buildSyncMethodBody(op, throwOnError, true)
|
|
791
|
+
body.push(...bodyLines)
|
|
792
|
+
body.push("")
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
body.push("")
|
|
796
|
+
|
|
797
|
+
body.push("class AsyncSDK:")
|
|
798
|
+
body.push(" def __init__(self, config: ClientConfig) -> None:")
|
|
799
|
+
body.push(" self._config = config")
|
|
800
|
+
body.push(" self._async_client = httpx.AsyncClient(")
|
|
801
|
+
body.push(" transport=config.transport,")
|
|
802
|
+
body.push(" )")
|
|
803
|
+
body.push(" _inv = config.invalidation")
|
|
804
|
+
body.push(" self._stale_tracker = _StaleTracker(")
|
|
805
|
+
body.push(" stale_time=_inv.stale_time if _inv is not None else 0.0,")
|
|
806
|
+
body.push(" stale_max_entries=(")
|
|
807
|
+
body.push(" _inv.stale_max_entries if _inv is not None else 1000")
|
|
808
|
+
body.push(" ),")
|
|
809
|
+
body.push(" max_sources_per_target=(")
|
|
810
|
+
body.push(" _inv.max_sources_per_target if _inv is not None else 16")
|
|
811
|
+
body.push(" ),")
|
|
812
|
+
body.push(" )")
|
|
813
|
+
for (const [seg] of namespacesOf(tree)) {
|
|
814
|
+
const className = nestedClassName([seg])
|
|
815
|
+
body.push(` self.${pyIdent(seg)} = ${className}(self)`)
|
|
816
|
+
}
|
|
817
|
+
body.push("")
|
|
818
|
+
body.push(" async def is_stale(self, method: str, path: str) -> bool:")
|
|
819
|
+
body.push(" \"\"\"Returns True when (method, path) sits inside an active stale window.\"\"\"")
|
|
820
|
+
body.push(" return await self._stale_tracker.is_stale(method, path)")
|
|
821
|
+
body.push("")
|
|
822
|
+
|
|
823
|
+
for (const op of topLevel.sort((a, b) => a.operationId.localeCompare(b.operationId))) {
|
|
824
|
+
body.push(...buildMethodSignature(op, throwOnError, true))
|
|
825
|
+
body.push(...buildMethodDocstring(op))
|
|
826
|
+
const bodyLines = buildAsyncMethodBody(op, throwOnError, true)
|
|
827
|
+
body.push(...bodyLines)
|
|
828
|
+
body.push("")
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const bodyText = body.join("\n")
|
|
832
|
+
const imports: string[] = []
|
|
833
|
+
if (/\basyncio\b/.test(bodyText)) imports.push("import asyncio")
|
|
834
|
+
if (/\bthreading\b/.test(bodyText)) imports.push("import threading")
|
|
835
|
+
if (/\buuid\.uuid4\b/.test(bodyText)) imports.push("import uuid")
|
|
836
|
+
imports.push("import httpx")
|
|
837
|
+
if (/\bquote\b/.test(bodyText)) imports.push("from urllib.parse import quote")
|
|
838
|
+
|
|
839
|
+
const typingUsed: string[] = []
|
|
840
|
+
if (/\bAny\b/.test(bodyText)) typingUsed.push("Any")
|
|
841
|
+
if (/\bAsyncIterator\b/.test(bodyText)) typingUsed.push("AsyncIterator")
|
|
842
|
+
if (/\bLiteral\b/.test(bodyText)) typingUsed.push("Literal")
|
|
843
|
+
if (/\bNotRequired\b/.test(bodyText)) typingUsed.push("NotRequired")
|
|
844
|
+
if (/\bTypedDict\b/.test(bodyText)) typingUsed.push("TypedDict")
|
|
845
|
+
if (typingUsed.length > 0) imports.push(`from typing import ${typingUsed.join(", ")}`)
|
|
846
|
+
|
|
847
|
+
const runtimeUsed: string[] = []
|
|
848
|
+
for (const sym of ["ClientConfig", "InvalidationConfig", "SDKResult", "_build_headers", "_build_url", "_do_request_async", "_do_request_sync", "_parse_body", "_raise_for_status"]) {
|
|
849
|
+
if (new RegExp(`\\b${sym}\\b`).test(bodyText)) runtimeUsed.push(sym)
|
|
850
|
+
}
|
|
851
|
+
if (runtimeUsed.length > 0) {
|
|
852
|
+
const line = `from ._runtime import ${runtimeUsed.join(", ")}`
|
|
853
|
+
if (line.length <= 88) {
|
|
854
|
+
imports.push(line)
|
|
855
|
+
} else {
|
|
856
|
+
imports.push("from ._runtime import (")
|
|
857
|
+
for (const sym of runtimeUsed) imports.push(` ${sym},`)
|
|
858
|
+
imports.push(")")
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const invalidationUsed: string[] = []
|
|
863
|
+
for (const sym of ["_StaleTracker", "_StaleTrackerSync", "interpolate_path"]) {
|
|
864
|
+
if (new RegExp(`\\b${sym}\\b`).test(bodyText)) invalidationUsed.push(sym)
|
|
865
|
+
}
|
|
866
|
+
if (invalidationUsed.length > 0) {
|
|
867
|
+
imports.push(`from ._invalidation import ${invalidationUsed.join(", ")}`)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/* APIStatusError only appears in docstring Raises sections, never as a code symbol */
|
|
871
|
+
const errorsUsed: string[] = []
|
|
872
|
+
for (const sym of ["APIError", "_STATUS_ERROR_MAP"]) {
|
|
873
|
+
if (new RegExp(`\\b${sym}\\b`).test(bodyText)) errorsUsed.push(sym)
|
|
874
|
+
}
|
|
875
|
+
if (errorsUsed.length > 0) {
|
|
876
|
+
imports.push(`from ._errors import ${errorsUsed.join(", ")}`)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const sseUsed: string[] = []
|
|
880
|
+
for (const sym of ["SSEEvent", "parse_sse_stream"]) {
|
|
881
|
+
if (new RegExp(`\\b${sym}\\b`).test(bodyText)) sseUsed.push(sym)
|
|
882
|
+
}
|
|
883
|
+
if (sseUsed.length > 0) {
|
|
884
|
+
imports.push(`from ._sse import ${sseUsed.join(", ")}`)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (/\b_TypedWebSocket\b/.test(bodyText)) {
|
|
888
|
+
imports.push("from ._ws import _TypedWebSocket")
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const transportUsed: string[] = []
|
|
892
|
+
for (const sym of ["LongpollAdapter", "SseAdapter", "TransportOpts", "WsAdapter"]) {
|
|
893
|
+
if (new RegExp(`\\b${sym}\\b`).test(bodyText)) transportUsed.push(sym)
|
|
894
|
+
}
|
|
895
|
+
if (transportUsed.length > 0) {
|
|
896
|
+
imports.push(`from ._transport import ${transportUsed.join(", ")}`)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (/\bResumableConnection\b/.test(bodyText)) {
|
|
900
|
+
imports.push("from ._realtime import ResumableConnection")
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const usedSchemas = schemaNames.filter((n) => new RegExp(`\\b${n}\\b`).test(bodyText))
|
|
904
|
+
if (usedSchemas.length > 0) {
|
|
905
|
+
const typesLine = `from .types import ${usedSchemas.join(", ")}`
|
|
906
|
+
if (typesLine.length <= 88) {
|
|
907
|
+
imports.push(typesLine)
|
|
908
|
+
} else {
|
|
909
|
+
imports.push("from .types import (")
|
|
910
|
+
for (const name of usedSchemas) imports.push(` ${name},`)
|
|
911
|
+
imports.push(")")
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const out: string[] = []
|
|
916
|
+
out.push("# ruff: noqa: E501")
|
|
917
|
+
out.push(PYTHON_FUTURE)
|
|
918
|
+
out.push("")
|
|
919
|
+
out.push(...imports)
|
|
920
|
+
out.push("")
|
|
921
|
+
out.push(bodyText)
|
|
922
|
+
return out.join("\n")
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function buildPyInit(spec: Record<string, unknown>): string {
|
|
926
|
+
const errorClasses = [
|
|
927
|
+
"APIError",
|
|
928
|
+
"APIStatusError",
|
|
929
|
+
"BadGatewayError",
|
|
930
|
+
"BadRequestError",
|
|
931
|
+
"ConflictError",
|
|
932
|
+
"ForbiddenError",
|
|
933
|
+
"GatewayTimeoutError",
|
|
934
|
+
"InternalServerError",
|
|
935
|
+
"NotFoundError",
|
|
936
|
+
"RateLimitError",
|
|
937
|
+
"ServiceUnavailableError",
|
|
938
|
+
"UnauthorizedError",
|
|
939
|
+
"UnprocessableEntityError",
|
|
940
|
+
]
|
|
941
|
+
|
|
942
|
+
const transportExports = [
|
|
943
|
+
"LongpollAdapter",
|
|
944
|
+
"SseAdapter",
|
|
945
|
+
"TransportAdapter",
|
|
946
|
+
"TransportConn",
|
|
947
|
+
"TransportKind",
|
|
948
|
+
"TransportOpts",
|
|
949
|
+
"WsAdapter",
|
|
950
|
+
]
|
|
951
|
+
|
|
952
|
+
const realtimeExports = ["ConnectionState", "ResumableConnection"]
|
|
953
|
+
|
|
954
|
+
const l: string[] = []
|
|
955
|
+
l.push(PYTHON_FUTURE)
|
|
956
|
+
l.push("")
|
|
957
|
+
l.push("from .client import AsyncSDK, SDK")
|
|
958
|
+
l.push("from ._errors import (")
|
|
959
|
+
for (const name of errorClasses) {
|
|
960
|
+
l.push(` ${name},`)
|
|
961
|
+
}
|
|
962
|
+
l.push(")")
|
|
963
|
+
l.push("from ._transport import (")
|
|
964
|
+
for (const name of transportExports) {
|
|
965
|
+
l.push(` ${name},`)
|
|
966
|
+
}
|
|
967
|
+
l.push(")")
|
|
968
|
+
l.push("from ._realtime import (")
|
|
969
|
+
for (const name of realtimeExports) {
|
|
970
|
+
l.push(` ${name},`)
|
|
971
|
+
}
|
|
972
|
+
l.push(")")
|
|
973
|
+
|
|
974
|
+
const schemas = (spec.components as Record<string, unknown> | undefined)?.schemas as
|
|
975
|
+
Record<string, Record<string, unknown>> | undefined
|
|
976
|
+
const schemaNames = schemas ? Object.keys(schemas).sort() : []
|
|
977
|
+
if (schemaNames.length > 0) {
|
|
978
|
+
l.push("from .types import (")
|
|
979
|
+
for (const name of schemaNames) {
|
|
980
|
+
l.push(` ${name},`)
|
|
981
|
+
}
|
|
982
|
+
l.push(")")
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
l.push("")
|
|
986
|
+
const allExports = ["AsyncSDK", "SDK", ...errorClasses, ...transportExports, ...realtimeExports, ...schemaNames]
|
|
987
|
+
l.push("__all__ = [")
|
|
988
|
+
for (const name of allExports) {
|
|
989
|
+
l.push(` "${name}",`)
|
|
990
|
+
}
|
|
991
|
+
l.push("]")
|
|
992
|
+
|
|
993
|
+
return l.join("\n")
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function buildServiceMap(spec: Record<string, unknown>): Record<string, ServiceMapEntry> {
|
|
997
|
+
const ops = collectOperations(spec)
|
|
998
|
+
const result: Record<string, ServiceMapEntry> = {}
|
|
999
|
+
for (const op of ops) {
|
|
1000
|
+
result[op.operationId] = {
|
|
1001
|
+
invalidate: op.invalidate.length > 0 ? op.invalidate : undefined,
|
|
1002
|
+
method: op.method,
|
|
1003
|
+
operationId: op.operationId,
|
|
1004
|
+
path: op.path,
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return result
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function generatePythonSDK(
|
|
1011
|
+
spec: Record<string, unknown>,
|
|
1012
|
+
options: PySDKOptions = {},
|
|
1013
|
+
): PySDKResult {
|
|
1014
|
+
const templates = loadPythonRuntimeTemplates()
|
|
1015
|
+
const serviceMap = buildServiceMap(spec)
|
|
1016
|
+
|
|
1017
|
+
const files: Record<string, string> = {}
|
|
1018
|
+
|
|
1019
|
+
for (const [name, content] of templates) {
|
|
1020
|
+
files[name] = content
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
files["types.py"] = buildPyTypes(spec)
|
|
1024
|
+
files["client.py"] = buildPyClient(spec, options, serviceMap)
|
|
1025
|
+
files["__init__.py"] = buildPyInit(spec)
|
|
1026
|
+
|
|
1027
|
+
return { files, serviceMap }
|
|
1028
|
+
}
|