@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.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. 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
+ }