@kernlang/python 3.5.4-canary.156.1.0389000c → 3.5.4-canary.161.2.d4dbfea4

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.
@@ -7,7 +7,7 @@
7
7
  * addRespondImports — add necessary imports for respond node
8
8
  */
9
9
  import { getProps } from '@kernlang/core';
10
- import { escapePyStr } from './fastapi-utils.js';
10
+ import { escapePyStr, quoteObjectKeysOutsideStrings } from './fastapi-utils.js';
11
11
  import { toSnakeCase } from './type-map.js';
12
12
  export function generateRespondFastAPI(respondNode, indent) {
13
13
  const p = getProps(respondNode);
@@ -87,15 +87,584 @@ function lowerJsArrayMethods(expr) {
87
87
  }
88
88
  return next;
89
89
  }
90
- export function rewriteFastAPIExpr(expr, pathParams) {
91
- let result = expr;
90
+ // Index of the bracket that closes the one at `openIdx`, tracking ()[]{} depth
91
+ // and skipping string/template literals. -1 if unbalanced.
92
+ function matchBalancedParen(expr, openIdx) {
93
+ let depth = 0;
94
+ let quote = null;
95
+ for (let i = openIdx; i < expr.length; i++) {
96
+ const c = expr[i];
97
+ if (quote) {
98
+ if (c === '\\')
99
+ i += 1;
100
+ else if (c === quote)
101
+ quote = null;
102
+ continue;
103
+ }
104
+ if (c === '"' || c === "'" || c === '`')
105
+ quote = c;
106
+ else if (c === '(' || c === '[' || c === '{')
107
+ depth += 1;
108
+ else if (c === ')' || c === ']' || c === '}') {
109
+ depth -= 1;
110
+ if (depth === 0)
111
+ return i;
112
+ }
113
+ }
114
+ return -1;
115
+ }
116
+ // Split a call's inner argument text on top-level commas, ignoring commas
117
+ // inside nested ()[]{} or string literals.
118
+ function splitTopLevelArgs(inner) {
119
+ const args = [];
120
+ let depth = 0;
121
+ let quote = null;
122
+ let start = 0;
123
+ for (let i = 0; i < inner.length; i++) {
124
+ const c = inner[i];
125
+ if (quote) {
126
+ if (c === '\\')
127
+ i += 1;
128
+ else if (c === quote)
129
+ quote = null;
130
+ continue;
131
+ }
132
+ if (c === '"' || c === "'" || c === '`')
133
+ quote = c;
134
+ else if (c === '(' || c === '[' || c === '{')
135
+ depth += 1;
136
+ else if (c === ')' || c === ']' || c === '}')
137
+ depth -= 1;
138
+ else if (c === ',' && depth === 0) {
139
+ args.push(inner.slice(start, i).trim());
140
+ start = i + 1;
141
+ }
142
+ }
143
+ args.push(inner.slice(start).trim());
144
+ return args;
145
+ }
146
+ // Lower JSON.stringify(...) / JSON.parse(...) to json.dumps/loads. Uses a
147
+ // balanced, string-aware scan because the single argument can itself contain
148
+ // commas, nested parens, brackets, braces, or string literals — which regex
149
+ // cannot reliably capture (three regex iterations were each holed by review).
150
+ // Skips occurrences inside string literals and those that are a property of
151
+ // another receiver (e.g. `myJSON.stringify`). Handles the pretty-print form
152
+ // `JSON.stringify(x, null, n)` → `json.dumps(x, indent=n)`.
153
+ function lowerJsonBuiltinCalls(expr, imports) {
154
+ let out = '';
155
+ let i = 0;
156
+ let quote = null;
157
+ while (i < expr.length) {
158
+ const c = expr[i];
159
+ if (quote) {
160
+ out += c;
161
+ if (c === '\\') {
162
+ out += expr[i + 1] ?? '';
163
+ i += 2;
164
+ continue;
165
+ }
166
+ if (c === quote)
167
+ quote = null;
168
+ i += 1;
169
+ continue;
170
+ }
171
+ if (c === '"' || c === "'" || c === '`') {
172
+ quote = c;
173
+ out += c;
174
+ i += 1;
175
+ continue;
176
+ }
177
+ const m = expr.slice(i).match(/^JSON\.(stringify|parse)\(/);
178
+ const prev = expr[i - 1];
179
+ if (m && !(prev && /[\w.]/.test(prev))) {
180
+ const method = m[1];
181
+ const openIdx = i + m[0].length - 1;
182
+ const closeIdx = matchBalancedParen(expr, openIdx);
183
+ if (closeIdx !== -1) {
184
+ const args = splitTopLevelArgs(expr.slice(openIdx + 1, closeIdx));
185
+ // Recurse so a nested builtin in the argument is lowered too, e.g.
186
+ // JSON.stringify(JSON.parse(x)) → json.dumps(json.loads(x)) (Codex
187
+ // review on 9d8ed8d0). Terminates: the argument is strictly shorter.
188
+ const a0 = lowerJsonBuiltinCalls(args[0] ?? '', imports);
189
+ imports?.add('import json');
190
+ if (method === 'parse') {
191
+ out += `json.loads(${a0})`;
192
+ }
193
+ else if (args.length >= 3 && /^(None|null)$/.test(args[1]) && /^\d+$/.test(args[2])) {
194
+ out += `json.dumps(${a0}, indent=${args[2]})`;
195
+ }
196
+ else {
197
+ out += `json.dumps(${a0})`;
198
+ }
199
+ i = closeIdx + 1;
200
+ continue;
201
+ }
202
+ }
203
+ out += c;
204
+ i += 1;
205
+ }
206
+ return out;
207
+ }
208
+ // Build the Python comprehension for one `Array.from(...)` call's argument list,
209
+ // or return null if the call isn't a lowerable length-form. Uses the balanced
210
+ // helpers (not regex) so a length value or arrow params containing braces/parens
211
+ // don't desync (codex/gemini review of cd7c40ae).
212
+ function tryLowerArrayFrom(args) {
213
+ if (args.length < 2)
214
+ return null;
215
+ // arg0 must be an object literal whose `length` property gives the count.
216
+ const arg0 = args[0].trim();
217
+ if (!arg0.startsWith('{') || matchBalancedParen(arg0, 0) !== arg0.length - 1)
218
+ return null;
219
+ let count = null;
220
+ for (const prop of splitTopLevelArgs(arg0.slice(1, -1))) {
221
+ const mm = prop.match(/^(?:length|["']length["'])\s*:\s*([\s\S]+)$/);
222
+ if (mm) {
223
+ count = mm[1].trim();
224
+ break;
225
+ }
226
+ }
227
+ if (count === null)
228
+ return null;
229
+ // arg1 must be an arrow `(params) => body` or `param => body`.
230
+ const arrowStr = args[1].trim();
231
+ let params;
232
+ let body;
233
+ if (arrowStr.startsWith('(')) {
234
+ const pClose = matchBalancedParen(arrowStr, 0);
235
+ if (pClose === -1)
236
+ return null;
237
+ const after = arrowStr.slice(pClose + 1).trim();
238
+ if (!after.startsWith('=>'))
239
+ return null;
240
+ params = splitTopLevelArgs(arrowStr.slice(1, pClose))
241
+ .map((s) => s.trim())
242
+ .filter(Boolean);
243
+ body = after.slice(2).trim();
244
+ }
245
+ else {
246
+ const am = arrowStr.match(/^([A-Za-z_$][\w$]*)\s*=>\s*([\s\S]+)$/);
247
+ if (!am)
248
+ return null;
249
+ params = [am[1]];
250
+ body = am[2].trim();
251
+ }
252
+ // Loop var = the INDEX (2nd param). The 1st param is the element, which is
253
+ // undefined for the length form, so it is NOT promoted to the loop variable
254
+ // (doing so would diverge from JS — `(x) => x` is [undefined…], not [0,1,…]).
255
+ // A non-simple index (destructuring) isn't a valid Python loop target → bail.
256
+ const idxVar = params[1] || '_';
257
+ if (!/^[A-Za-z_$][\w$]*$/.test(idxVar))
258
+ return null;
259
+ // `(_, i) => ({...})` parenthesizes the object body to disambiguate it from a
260
+ // block; unwrap ONLY when the enclosed body is an object literal, so a comma
261
+ // operator `(1, 2)` or grouped expr isn't mis-stripped (codex review).
262
+ if (body.startsWith('(') && matchBalancedParen(body, 0) === body.length - 1) {
263
+ const inner = body.slice(1, -1).trim();
264
+ if (inner.startsWith('{'))
265
+ body = inner;
266
+ }
267
+ // Recurse so a nested Array.from in the count or body is lowered too.
268
+ return `[${lowerArrayFromCalls(body)} for ${idxVar} in range(${lowerArrayFromCalls(count)})]`;
269
+ }
270
+ // Expand JS object-literal shorthand properties to explicit `key: key` so the
271
+ // dict-key quoting pass can quote them: `{ items, page }` → `{ items: items,
272
+ // page: page }`. Bracket/string-aware: only an object-literal entry that is a
273
+ // bare identifier is expanded; `key: value`, `**spread`, computed keys, and
274
+ // array/comprehension contents (`[]`) are left alone, and nested objects are
275
+ // handled by recursing into each entry. Runs just before key quoting.
276
+ function expandObjectShorthand(expr) {
277
+ let out = '';
278
+ let i = 0;
279
+ let quote = null;
280
+ while (i < expr.length) {
281
+ const c = expr[i];
282
+ if (quote) {
283
+ out += c;
284
+ if (c === '\\') {
285
+ out += expr[i + 1] ?? '';
286
+ i += 2;
287
+ continue;
288
+ }
289
+ if (c === quote)
290
+ quote = null;
291
+ i += 1;
292
+ continue;
293
+ }
294
+ if (c === '"' || c === "'" || c === '`') {
295
+ quote = c;
296
+ out += c;
297
+ i += 1;
298
+ continue;
299
+ }
300
+ if (c === '{') {
301
+ const close = matchBalancedParen(expr, i);
302
+ if (close !== -1) {
303
+ const rebuilt = splitTopLevelArgs(expr.slice(i + 1, close)).map((entry) => {
304
+ const t = entry.trim();
305
+ if (t === '')
306
+ return entry;
307
+ if (/^[A-Za-z_$][\w$]*$/.test(t))
308
+ return `${t}: ${t}`;
309
+ return expandObjectShorthand(entry);
310
+ });
311
+ out += `{${rebuilt.join(', ')}}`;
312
+ i = close + 1;
313
+ continue;
314
+ }
315
+ }
316
+ out += c;
317
+ i += 1;
318
+ }
319
+ return out;
320
+ }
321
+ // Lower `Array.from({ length: N }, (_, i) => BODY)` to a Python list
322
+ // comprehension `[BODY for i in range(N)]` (Express keeps Array.from — valid
323
+ // JS). Balanced, string-aware scan; runs BEFORE the ref/key/template passes so
324
+ // they lower N and BODY in place. Only the `{ length: N }` form is handled;
325
+ // `Array.from(iterable, fn)` (map form) is left untouched. A call immediately
326
+ // followed by a method chain (`.map`, `.filter`, …) is left raw rather than
327
+ // lowered, because the array-method pass cannot consume a comprehension
328
+ // receiver and would emit malformed Python (codex review of cd7c40ae).
329
+ function lowerArrayFromCalls(expr) {
330
+ let out = '';
331
+ let i = 0;
332
+ let quote = null;
333
+ while (i < expr.length) {
334
+ const c = expr[i];
335
+ if (quote) {
336
+ out += c;
337
+ if (c === '\\') {
338
+ out += expr[i + 1] ?? '';
339
+ i += 2;
340
+ continue;
341
+ }
342
+ if (c === quote)
343
+ quote = null;
344
+ i += 1;
345
+ continue;
346
+ }
347
+ if (c === '"' || c === "'" || c === '`') {
348
+ quote = c;
349
+ out += c;
350
+ i += 1;
351
+ continue;
352
+ }
353
+ const m = expr.slice(i).match(/^Array\.from\(/);
354
+ const prev = expr[i - 1];
355
+ if (m && !(prev && /[\w.]/.test(prev))) {
356
+ const openIdx = i + m[0].length - 1;
357
+ const closeIdx = matchBalancedParen(expr, openIdx);
358
+ if (closeIdx !== -1 && expr[closeIdx + 1] !== '.') {
359
+ const lowered = tryLowerArrayFrom(splitTopLevelArgs(expr.slice(openIdx + 1, closeIdx)));
360
+ if (lowered !== null) {
361
+ out += lowered;
362
+ i = closeIdx + 1;
363
+ continue;
364
+ }
365
+ }
366
+ }
367
+ out += c;
368
+ i += 1;
369
+ }
370
+ return out;
371
+ }
372
+ function scanQuotedString(expr, startIndex, quote) {
373
+ for (let i = startIndex + 1; i < expr.length; i++) {
374
+ if (expr[i] === '\\') {
375
+ i += 1;
376
+ continue;
377
+ }
378
+ if (expr[i] === quote)
379
+ return i;
380
+ }
381
+ return -1;
382
+ }
383
+ function scanTemplateInterpolationEnd(expr, startIndex) {
384
+ let depth = 1;
385
+ for (let i = startIndex; i < expr.length; i++) {
386
+ const c = expr[i];
387
+ if (c === '"' || c === "'") {
388
+ const quotedEnd = scanQuotedString(expr, i, c);
389
+ if (quotedEnd === -1)
390
+ return -1;
391
+ i = quotedEnd;
392
+ continue;
393
+ }
394
+ if (c === '`') {
395
+ const templateEnd = scanTemplateLiteralEnd(expr, i);
396
+ if (templateEnd === -1)
397
+ return -1;
398
+ i = templateEnd;
399
+ continue;
400
+ }
401
+ if (c === '{')
402
+ depth += 1;
403
+ else if (c === '}') {
404
+ depth -= 1;
405
+ if (depth === 0)
406
+ return i;
407
+ }
408
+ }
409
+ return -1;
410
+ }
411
+ function scanTemplateLiteralEnd(expr, startIndex) {
412
+ for (let i = startIndex + 1; i < expr.length; i++) {
413
+ const c = expr[i];
414
+ if (c === '\\') {
415
+ i += 1;
416
+ continue;
417
+ }
418
+ if (c === '`')
419
+ return i;
420
+ if (c === '$' && expr[i + 1] === '{') {
421
+ const interpolationEnd = scanTemplateInterpolationEnd(expr, i + 2);
422
+ if (interpolationEnd === -1)
423
+ return -1;
424
+ i = interpolationEnd;
425
+ }
426
+ }
427
+ return -1;
428
+ }
429
+ function parseTemplateLiteral(expr, startIndex) {
430
+ const textParts = [];
431
+ const interpolationParts = [];
432
+ let text = '';
433
+ for (let i = startIndex + 1; i < expr.length;) {
434
+ const c = expr[i];
435
+ if (c === '\\') {
436
+ text += c;
437
+ if (i + 1 < expr.length)
438
+ text += expr[i + 1];
439
+ i += 2;
440
+ continue;
441
+ }
442
+ if (c === '`') {
443
+ textParts.push(text);
444
+ return { endIndex: i, textParts, interpolationParts };
445
+ }
446
+ if (c === '$' && expr[i + 1] === '{') {
447
+ textParts.push(text);
448
+ text = '';
449
+ const interpolationEnd = scanTemplateInterpolationEnd(expr, i + 2);
450
+ if (interpolationEnd === -1)
451
+ return undefined;
452
+ interpolationParts.push(expr.slice(i + 2, interpolationEnd));
453
+ i = interpolationEnd + 1;
454
+ continue;
455
+ }
456
+ text += c;
457
+ i += 1;
458
+ }
459
+ return undefined;
460
+ }
461
+ // Re-encode JS-template literal text (kept raw by parseTemplateLiteral, with `\x`
462
+ // as two characters) for a Python double-quoted string. Most JS escapes are
463
+ // ALSO valid Python escapes (`\n \t \r \b \f \v \\ \" \uXXXX \xXX \0`), so they
464
+ // are preserved verbatim — decoding then re-encoding them only risks corrupting
465
+ // the exotic ones (Codex reviews on 678e6bc1 and the escape-decoder commit).
466
+ // Only the JS-specific escapes that Python does not recognise are converted to
467
+ // the bare character: `\`` → backtick, `\$` → `$`, `\'` → `'`. A bare `"` (or a
468
+ // bare trailing backslash, or raw control char) is escaped so the literal stays
469
+ // valid.
470
+ function escapeJsTemplateTextForPy(raw) {
471
+ let out = '';
472
+ for (let i = 0; i < raw.length; i++) {
473
+ const c = raw[i];
474
+ if (c === '\\' && i + 1 < raw.length) {
475
+ const next = raw[i + 1];
476
+ if (next === '`' || next === '$' || next === "'") {
477
+ out += next; // JS-only escape → bare char (Python has no such escape)
478
+ }
479
+ else {
480
+ out += `\\${next}`; // valid Python escape (\n, \uXXXX, \0, ...) — keep
481
+ }
482
+ i += 1;
483
+ continue;
484
+ }
485
+ if (c === '\\')
486
+ out += '\\\\'; // lone trailing backslash
487
+ else if (c === '"')
488
+ out += '\\"';
489
+ else if (c === '\n')
490
+ out += '\\n';
491
+ else if (c === '\r')
492
+ out += '\\r';
493
+ else if (c === '\t')
494
+ out += '\\t';
495
+ else
496
+ out += c;
497
+ }
498
+ return out;
499
+ }
500
+ function escapePythonTemplateText(text, forFormatTemplate) {
501
+ const escaped = escapeJsTemplateTextForPy(text);
502
+ if (!forFormatTemplate)
503
+ return escaped;
504
+ // str.format treats { } as field markers, so literal braces must be doubled.
505
+ return escaped.replace(/{/g, '{{').replace(/}/g, '}}');
506
+ }
507
+ function lowerTemplateLiteralToPython(parsed, pathParams, bodyFields, authUser, imports) {
508
+ if (parsed.interpolationParts.length === 0) {
509
+ return `"${escapePythonTemplateText(parsed.textParts.join(''), false)}"`;
510
+ }
511
+ const rewrittenInterpolations = parsed.interpolationParts.map((part) => rewriteFastAPIExpr(part.trim(), pathParams, bodyFields, authUser, imports));
512
+ let fmt = '';
513
+ for (let i = 0; i < parsed.textParts.length; i++) {
514
+ fmt += escapePythonTemplateText(parsed.textParts[i], true);
515
+ if (i < parsed.interpolationParts.length)
516
+ fmt += '{}';
517
+ }
518
+ return `"${fmt}".format(${rewrittenInterpolations.join(', ')})`;
519
+ }
520
+ function extractTemplateLiterals(expr, pathParams, bodyFields, authUser, imports) {
521
+ let maskedExpr = '';
522
+ const replacements = [];
523
+ let quote = null;
524
+ for (let i = 0; i < expr.length;) {
525
+ const c = expr[i];
526
+ if (quote) {
527
+ maskedExpr += c;
528
+ if (c === '\\') {
529
+ maskedExpr += expr[i + 1] ?? '';
530
+ i += 2;
531
+ continue;
532
+ }
533
+ if (c === quote)
534
+ quote = null;
535
+ i += 1;
536
+ continue;
537
+ }
538
+ if (c === '"' || c === "'") {
539
+ quote = c;
540
+ maskedExpr += c;
541
+ i += 1;
542
+ continue;
543
+ }
544
+ if (c === '`') {
545
+ const parsed = parseTemplateLiteral(expr, i);
546
+ if (!parsed) {
547
+ maskedExpr += c;
548
+ i += 1;
549
+ continue;
550
+ }
551
+ const placeholder = `__KERN_TEMPLATE_${replacements.length}__`;
552
+ const lowered = lowerTemplateLiteralToPython(parsed, pathParams, bodyFields, authUser, imports);
553
+ replacements.push({ placeholder, lowered });
554
+ maskedExpr += placeholder;
555
+ i = parsed.endIndex + 1;
556
+ continue;
557
+ }
558
+ maskedExpr += c;
559
+ i += 1;
560
+ }
561
+ return { maskedExpr, replacements };
562
+ }
563
+ // Lower JS spread elements to Python unpacking, choosing the operator from the
564
+ // enclosing bracket: `{...x}` → `{**x}`, `[...x]` / `f(...x)` → `[*x]` / `f(*x)`.
565
+ // Bracket-aware (a stack) and string-aware (skips quoted contents) so a literal
566
+ // "..." inside a string is left intact. Runs BEFORE the request-ref rewrites so
567
+ // that, e.g., `...user.roles` becomes `*user.roles` and the auth rewrite's
568
+ // `(?<!\.)` lookbehind no longer sees the spread's trailing dot.
569
+ function lowerSpreadElements(expr) {
570
+ let out = '';
571
+ const stack = [];
572
+ let i = 0;
573
+ while (i < expr.length) {
574
+ const ch = expr[i];
575
+ if (ch === '"' || ch === "'") {
576
+ const q = ch;
577
+ out += ch;
578
+ i++;
579
+ while (i < expr.length) {
580
+ out += expr[i];
581
+ if (expr[i] === '\\') {
582
+ i++;
583
+ if (i < expr.length)
584
+ out += expr[i];
585
+ i++;
586
+ continue;
587
+ }
588
+ if (expr[i] === q) {
589
+ i++;
590
+ break;
591
+ }
592
+ i++;
593
+ }
594
+ continue;
595
+ }
596
+ if (ch === '{' || ch === '[' || ch === '(') {
597
+ stack.push(ch);
598
+ out += ch;
599
+ i++;
600
+ continue;
601
+ }
602
+ if (ch === '}' || ch === ']' || ch === ')') {
603
+ stack.pop();
604
+ out += ch;
605
+ i++;
606
+ continue;
607
+ }
608
+ if (ch === '.' && expr[i + 1] === '.' && expr[i + 2] === '.') {
609
+ out += stack[stack.length - 1] === '{' ? '**' : '*';
610
+ i += 3;
611
+ // Collapse whitespace after the operator so `{ ... body }` yields tight
612
+ // `{**body}` — the model_dump pass matches `**body`, not `** body` (Codex).
613
+ while (i < expr.length && /\s/.test(expr[i]))
614
+ i++;
615
+ continue;
616
+ }
617
+ out += ch;
618
+ i++;
619
+ }
620
+ return out;
621
+ }
622
+ export function rewriteFastAPIExpr(expr, pathParams, bodyFields = new Set(), authUser = false, imports) {
623
+ const { maskedExpr, replacements } = extractTemplateLiterals(expr, pathParams, bodyFields, authUser, imports);
624
+ let result = maskedExpr;
625
+ // Spread → unpacking first, so the request-ref rewrites below see clean
626
+ // operands (e.g. `*user.roles`, not `...user.roles`).
627
+ result = lowerSpreadElements(result);
628
+ // Expand object shorthand BEFORE Array.from lowering, so a shorthand length
629
+ // object `Array.from({ length }, …)` becomes `{ length: length }` and is
630
+ // recognised (codex review of d75a9d05). No later pass creates new object
631
+ // literals, so this single early pass covers length objects, arrow bodies,
632
+ // and every other object.
633
+ result = expandObjectShorthand(result);
634
+ // Array.from(length, arrow) → list comprehension. Runs before the ref/key
635
+ // passes so they lower the count and body of the produced comprehension.
636
+ result = lowerArrayFromCalls(result);
92
637
  // params.X → X (function param) for path params
93
638
  for (const param of pathParams) {
94
639
  result = result.replace(new RegExp(`\\bparams\\.${param}\\b`, 'g'), param);
95
640
  }
96
641
  // Fallback: any remaining params.X → X (for query params not in pathParams)
97
642
  result = result.replace(/\bparams\.([A-Za-z_]\w*)/g, '$1');
98
- // body.X → body.X (Pydantic model already correct)
643
+ // user.X → user["X"]: with auth, `user` is the decoded JWT payload (a dict
644
+ // returned by auth_required/auth_optional), so attribute access would raise
645
+ // AttributeError. Only applied when the route declares auth (Codex review).
646
+ // Skip text inside string literals so `{{"user.id"}}` isn't corrupted to
647
+ // `"user["id"]"` (Codex review on 02ecb2fa), and require `user` NOT be a
648
+ // property of something else (negative lookbehind `(?<!\.)`) so a nested
649
+ // body access like `body.user.id` is left intact (Kimi review on 02ecb2fa).
650
+ if (authUser) {
651
+ const USER_FIELD_RE = new RegExp(`${STRING_LITERAL_ALT}|(?<!\\.)\\buser\\.([A-Za-z_]\\w*)`, 'g');
652
+ result = result.replace(USER_FIELD_RE, (match, field) => (field ? `user["${field}"]` : match));
653
+ }
654
+ // body.X → body.<snake_case(X)>: the generated Pydantic model snake-cases
655
+ // every field, so a camelCase access would raise AttributeError at runtime.
656
+ // Only remap fields the model actually declares; leave unknown `body.X`
657
+ // (e.g. external validate schemas) untouched.
658
+ result = result.replace(/\bbody\.([A-Za-z_]\w*)/g, (match, field) => bodyFields.has(field) ? `body.${toSnakeCase(field)}` : match);
659
+ // Spreading the whole request body: `{**body}` raises TypeError because a
660
+ // Pydantic model is not a mapping, so unpack its dict form instead. This is
661
+ // unconditional: whenever the `body` symbol exists it is a Pydantic model
662
+ // (inline `RequestBody`, or an external `validate` schema typed `body: X` for
663
+ // POST/PUT/PATCH) — there is no `body: dict` codegen path, so model_dump() is
664
+ // always correct. Keying on bodyFields would wrongly skip external schemas
665
+ // (their field names are unknown but the param is still a model). A
666
+ // `**body.field` member spread is left alone via the `(?!\s*\.)` guard.
667
+ result = result.replace(/\*\*body\b(?!\s*\.)/g, '**body.model_dump()');
99
668
  // query.X → X (function param)
100
669
  result = result.replace(/\bquery\.([A-Za-z_]\w*)/g, '$1');
101
670
  // headers.X → request.headers.get("X")
@@ -127,6 +696,37 @@ export function rewriteFastAPIExpr(expr, pathParams) {
127
696
  return 'False';
128
697
  return match; // quoted string
129
698
  });
699
+ // ── Host-builtin lowering (JS globals → Python stdlib) ────────────────
700
+ // crypto / Date are fixed forms matched by regex with a `(?<![\w.])` guard so
701
+ // a custom receiver (`some.crypto.randomUUID()`) is left untouched. The JSON
702
+ // calls need balanced argument parsing (regex can't), so they go through the
703
+ // string-aware scanner `lowerJsonBuiltinCalls`.
704
+ // crypto.randomUUID() → str(uuid.uuid4())
705
+ result = result.replace(new RegExp(`${STRING_LITERAL_ALT}|(?<![\\w.])crypto\\.randomUUID\\(\\)`, 'g'), (match) => {
706
+ if (match === 'crypto.randomUUID()') {
707
+ imports?.add('import uuid');
708
+ return 'str(uuid.uuid4())';
709
+ }
710
+ return match; // string literal — leave untouched
711
+ });
712
+ // new Date().toISOString() → datetime.now(timezone.utc).isoformat()
713
+ result = result.replace(new RegExp(`${STRING_LITERAL_ALT}|(?<![\\w.])new Date\\(\\)\\.toISOString\\(\\)`, 'g'), (match) => {
714
+ if (match === 'new Date().toISOString()') {
715
+ imports?.add('from datetime import datetime, timezone');
716
+ return 'datetime.now(timezone.utc).isoformat()';
717
+ }
718
+ return match;
719
+ });
720
+ // JSON.stringify(...) → json.dumps(...) / JSON.parse(...) → json.loads(...)
721
+ result = lowerJsonBuiltinCalls(result, imports);
722
+ // Object-literal keys → quoted Python dict keys (`{userId: x}` →
723
+ // `{"userId": x}`). Applied last, mirroring the raw `res.json(...)` path's
724
+ // outer quote-after-lower order; runs after array-method lowering so dicts
725
+ // produced inside list comprehensions are quoted too.
726
+ result = quoteObjectKeysOutsideStrings(result);
727
+ for (const replacement of replacements) {
728
+ result = result.split(replacement.placeholder).join(replacement.lowered);
729
+ }
130
730
  return result;
131
731
  }
132
732
  export function extractExprCode(prop) {