tywrap 0.2.1 → 0.4.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 (144) hide show
  1. package/README.md +100 -32
  2. package/dist/cli.js +70 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts +11 -7
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +62 -20
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/analyzer.d.ts +1 -0
  9. package/dist/core/analyzer.d.ts.map +1 -1
  10. package/dist/core/analyzer.js +46 -12
  11. package/dist/core/analyzer.js.map +1 -1
  12. package/dist/core/annotation-parser.d.ts +8 -0
  13. package/dist/core/annotation-parser.d.ts.map +1 -0
  14. package/dist/core/annotation-parser.js +409 -0
  15. package/dist/core/annotation-parser.js.map +1 -0
  16. package/dist/core/discovery.d.ts +1 -0
  17. package/dist/core/discovery.d.ts.map +1 -1
  18. package/dist/core/discovery.js +9 -9
  19. package/dist/core/discovery.js.map +1 -1
  20. package/dist/core/generator.d.ts +9 -1
  21. package/dist/core/generator.d.ts.map +1 -1
  22. package/dist/core/generator.js +738 -104
  23. package/dist/core/generator.js.map +1 -1
  24. package/dist/core/mapper.d.ts +9 -7
  25. package/dist/core/mapper.d.ts.map +1 -1
  26. package/dist/core/mapper.js +129 -44
  27. package/dist/core/mapper.js.map +1 -1
  28. package/dist/dev.d.ts +57 -0
  29. package/dist/dev.d.ts.map +1 -0
  30. package/dist/dev.js +743 -0
  31. package/dist/dev.js.map +1 -0
  32. package/dist/index.d.ts +8 -7
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -4
  35. package/dist/index.js.map +1 -1
  36. package/dist/runtime/bridge-core.d.ts +1 -0
  37. package/dist/runtime/bridge-core.d.ts.map +1 -1
  38. package/dist/runtime/bridge-core.js +52 -17
  39. package/dist/runtime/bridge-core.js.map +1 -1
  40. package/dist/runtime/bridge-protocol.d.ts +17 -0
  41. package/dist/runtime/bridge-protocol.d.ts.map +1 -1
  42. package/dist/runtime/bridge-protocol.js +97 -0
  43. package/dist/runtime/bridge-protocol.js.map +1 -1
  44. package/dist/runtime/errors.d.ts +10 -0
  45. package/dist/runtime/errors.d.ts.map +1 -1
  46. package/dist/runtime/errors.js +9 -0
  47. package/dist/runtime/errors.js.map +1 -1
  48. package/dist/runtime/http-io.d.ts.map +1 -1
  49. package/dist/runtime/http-io.js.map +1 -1
  50. package/dist/runtime/http.d.ts +1 -0
  51. package/dist/runtime/http.d.ts.map +1 -1
  52. package/dist/runtime/http.js +6 -0
  53. package/dist/runtime/http.js.map +1 -1
  54. package/dist/runtime/node.d.ts.map +1 -1
  55. package/dist/runtime/node.js +206 -32
  56. package/dist/runtime/node.js.map +1 -1
  57. package/dist/runtime/optimized-node.d.ts +6 -5
  58. package/dist/runtime/optimized-node.d.ts.map +1 -1
  59. package/dist/runtime/optimized-node.js +6 -5
  60. package/dist/runtime/optimized-node.js.map +1 -1
  61. package/dist/runtime/pooled-transport.d.ts +4 -0
  62. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  63. package/dist/runtime/pooled-transport.js +2 -0
  64. package/dist/runtime/pooled-transport.js.map +1 -1
  65. package/dist/runtime/process-io.d.ts +5 -0
  66. package/dist/runtime/process-io.d.ts.map +1 -1
  67. package/dist/runtime/process-io.js +64 -30
  68. package/dist/runtime/process-io.js.map +1 -1
  69. package/dist/runtime/pyodide-io.d.ts +4 -5
  70. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  71. package/dist/runtime/pyodide-io.js +99 -37
  72. package/dist/runtime/pyodide-io.js.map +1 -1
  73. package/dist/runtime/safe-codec.d.ts +25 -3
  74. package/dist/runtime/safe-codec.d.ts.map +1 -1
  75. package/dist/runtime/safe-codec.js +231 -57
  76. package/dist/runtime/safe-codec.js.map +1 -1
  77. package/dist/runtime/transport.d.ts +6 -1
  78. package/dist/runtime/transport.d.ts.map +1 -1
  79. package/dist/runtime/transport.js.map +1 -1
  80. package/dist/runtime/validators.d.ts +0 -11
  81. package/dist/runtime/validators.d.ts.map +1 -1
  82. package/dist/runtime/validators.js +13 -3
  83. package/dist/runtime/validators.js.map +1 -1
  84. package/dist/runtime/worker-pool.d.ts +17 -0
  85. package/dist/runtime/worker-pool.d.ts.map +1 -1
  86. package/dist/runtime/worker-pool.js +97 -22
  87. package/dist/runtime/worker-pool.js.map +1 -1
  88. package/dist/types/index.d.ts +51 -8
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/tywrap.d.ts +6 -0
  91. package/dist/tywrap.d.ts.map +1 -1
  92. package/dist/tywrap.js +237 -331
  93. package/dist/tywrap.js.map +1 -1
  94. package/dist/utils/cache.d.ts.map +1 -1
  95. package/dist/utils/cache.js +53 -5
  96. package/dist/utils/cache.js.map +1 -1
  97. package/dist/utils/codec.d.ts.map +1 -1
  98. package/dist/utils/codec.js +10 -16
  99. package/dist/utils/codec.js.map +1 -1
  100. package/dist/utils/ir-cache.d.ts +28 -0
  101. package/dist/utils/ir-cache.d.ts.map +1 -0
  102. package/dist/utils/ir-cache.js +29 -0
  103. package/dist/utils/ir-cache.js.map +1 -0
  104. package/dist/utils/parallel-processor.d.ts +1 -1
  105. package/dist/utils/parallel-processor.d.ts.map +1 -1
  106. package/dist/utils/parallel-processor.js +111 -40
  107. package/dist/utils/parallel-processor.js.map +1 -1
  108. package/dist/utils/runtime.d.ts +5 -1
  109. package/dist/utils/runtime.d.ts.map +1 -1
  110. package/dist/utils/runtime.js +112 -18
  111. package/dist/utils/runtime.js.map +1 -1
  112. package/package.json +47 -21
  113. package/runtime/python_bridge.py +78 -12
  114. package/src/cli.ts +85 -5
  115. package/src/config/index.ts +91 -32
  116. package/src/core/analyzer.ts +51 -15
  117. package/src/core/annotation-parser.ts +500 -0
  118. package/src/core/discovery.ts +26 -12
  119. package/src/core/generator.ts +956 -116
  120. package/src/core/mapper.ts +158 -51
  121. package/src/dev.ts +983 -0
  122. package/src/index.ts +35 -6
  123. package/src/runtime/bridge-core.ts +58 -18
  124. package/src/runtime/bridge-protocol.ts +164 -0
  125. package/src/runtime/errors.ts +14 -0
  126. package/src/runtime/http-io.ts +2 -1
  127. package/src/runtime/http.ts +7 -0
  128. package/src/runtime/node.ts +312 -46
  129. package/src/runtime/optimized-node.ts +9 -5
  130. package/src/runtime/pooled-transport.ts +12 -1
  131. package/src/runtime/process-io.ts +83 -55
  132. package/src/runtime/pyodide-io.ts +110 -44
  133. package/src/runtime/safe-codec.ts +290 -82
  134. package/src/runtime/transport.ts +6 -1
  135. package/src/runtime/validators.ts +14 -3
  136. package/src/runtime/worker-pool.ts +120 -24
  137. package/src/types/global.d.ts +11 -1
  138. package/src/types/index.ts +63 -8
  139. package/src/tywrap.ts +296 -361
  140. package/src/utils/cache.ts +59 -5
  141. package/src/utils/codec.ts +11 -15
  142. package/src/utils/ir-cache.ts +51 -0
  143. package/src/utils/parallel-processor.ts +119 -43
  144. package/src/utils/runtime.ts +129 -18
package/src/tywrap.ts CHANGED
@@ -4,19 +4,25 @@
4
4
 
5
5
  import { CodeGenerator } from './core/generator.js';
6
6
  import { TypeMapper } from './core/mapper.js';
7
+ import { parseAnnotationToPythonType } from './core/annotation-parser.js';
7
8
  import { createConfig } from './config/index.js';
8
9
  import type {
9
10
  TywrapOptions,
11
+ PythonGenericParameter,
10
12
  PythonFunction,
11
13
  PythonModule as TSPythonModule,
12
14
  PythonClass,
15
+ PythonTypeAlias,
13
16
  Parameter,
14
17
  PythonType,
15
18
  } from './types/index.js';
16
- import { fsUtils, pathUtils, processUtils, hashUtils } from './utils/runtime.js';
19
+ import { fsUtils, pathUtils, processUtils, isWindows } from './utils/runtime.js';
17
20
  import { globalCache } from './utils/cache.js';
18
21
  import { globalParallelProcessor } from './utils/parallel-processor.js';
19
22
  import { resolvePythonExecutable } from './utils/python.js';
23
+ import { computeIrCacheFilename } from './utils/ir-cache.js';
24
+
25
+ const TYWRAP_IR_VERSION = '0.2.0';
20
26
 
21
27
  // Collect unknown typing constructs encountered during annotation parsing (per-generate run)
22
28
  let unknownTypeNamesCollector: Map<string, number> = new Map();
@@ -80,9 +86,16 @@ export interface GenerateRunOptions {
80
86
  check?: boolean;
81
87
  }
82
88
 
89
+ export interface GenerateFailure {
90
+ module: string;
91
+ code: 'ir-unavailable';
92
+ message: string;
93
+ }
94
+
83
95
  export interface GenerateResult {
84
96
  written: string[];
85
97
  warnings: string[];
98
+ failures: GenerateFailure[];
86
99
  /**
87
100
  * Only set when `GenerateRunOptions.check === true`.
88
101
  * Lists files that are missing or differ from what would be generated.
@@ -117,6 +130,7 @@ export async function generate(
117
130
  const written: string[] = [];
118
131
  const outOfDate: string[] = [];
119
132
  const warnings: string[] = [];
133
+ const failures: GenerateFailure[] = [];
120
134
  const outputDir = resolvedOptions.output.dir;
121
135
  const caching = resolvedOptions.performance.caching;
122
136
  const cacheDir = '.tywrap/cache';
@@ -138,13 +152,14 @@ export async function generate(
138
152
  const modules = resolvedOptions.pythonModules ?? {};
139
153
  for (const entry of Object.entries(modules)) {
140
154
  const moduleKey = entry[0];
155
+ const moduleConfig = entry[1];
141
156
  // reset collector for each module
142
157
  unknownTypeNamesCollector = new Map();
143
158
  // Prefer Python IR extractor over TS analyzer with optional cache
144
- const cacheKey = await computeCacheKey(moduleKey, options);
159
+ const cacheKey = await computeCacheKey(moduleKey, resolvedOptions);
145
160
  let ir: unknown | null = null;
146
161
  let irError: string | undefined;
147
- if (caching && fsUtils.isAvailable()) {
162
+ if (caching && fsUtils.isAvailable() && !checkMode) {
148
163
  try {
149
164
  const cached = await fsUtils.readFile(pathUtils.join(cacheDir, cacheKey));
150
165
  ir = JSON.parse(cached);
@@ -153,7 +168,10 @@ export async function generate(
153
168
  }
154
169
  }
155
170
  if (!ir) {
156
- const fetchResult = await fetchPythonIr(moduleKey, pythonPath);
171
+ const fetchResult = await fetchPythonIr(moduleKey, pythonPath, {
172
+ timeoutMs: resolvedOptions.runtime?.node?.timeout,
173
+ pythonImportPath: resolvedOptions.pythonImportPath,
174
+ });
157
175
  ir = fetchResult.ir;
158
176
  irError = fetchResult.error;
159
177
  if (ir && caching && fsUtils.isAvailable() && !checkMode) {
@@ -163,12 +181,90 @@ export async function generate(
163
181
  }
164
182
  }
165
183
  if (!ir) {
166
- warnings.push(`No IR produced for module ${moduleKey}${irError ? `: ${irError}` : ''}`);
184
+ const message = `No IR produced for module ${moduleKey}${irError ? `: ${irError}` : ''}`;
185
+ warnings.push(message);
186
+ failures.push({
187
+ module: moduleKey,
188
+ code: 'ir-unavailable',
189
+ message,
190
+ });
167
191
  continue;
168
192
  }
169
193
 
170
194
  const moduleModel = transformIrToTsModel(ir);
171
195
 
196
+ // Apply module-level export filtering (functions/classes + excludes).
197
+ {
198
+ const builtInDefaultExcludes = new Set([
199
+ 'dataclass',
200
+ 'property',
201
+ 'staticmethod',
202
+ 'classmethod',
203
+ 'abstractmethod',
204
+ 'cached_property',
205
+ ]);
206
+
207
+ const excludeExact = new Set((moduleConfig.exclude ?? []).map(String));
208
+ const excludeRegexes: RegExp[] = [];
209
+ for (const pattern of moduleConfig.excludePatterns ?? []) {
210
+ try {
211
+ excludeRegexes.push(new RegExp(String(pattern)));
212
+ } catch (err: unknown) {
213
+ const message = err instanceof Error ? err.message : String(err);
214
+ warnings.push(
215
+ `Module ${moduleKey}: invalid excludePatterns regex "${String(pattern)}": ${message}`
216
+ );
217
+ }
218
+ }
219
+
220
+ const shouldExclude = (name: string, applyBuiltInDefaults: boolean): boolean => {
221
+ if (excludeExact.has(name)) {
222
+ return true;
223
+ }
224
+ if (excludeRegexes.some(r => r.test(name))) {
225
+ return true;
226
+ }
227
+ if (applyBuiltInDefaults && builtInDefaultExcludes.has(name)) {
228
+ return true;
229
+ }
230
+ return false;
231
+ };
232
+
233
+ if (Array.isArray(moduleConfig.functions)) {
234
+ const allow = new Set(moduleConfig.functions.map(String));
235
+ for (const requested of allow) {
236
+ if (!moduleModel.functions.some(f => f.name === requested)) {
237
+ warnings.push(
238
+ `Module ${moduleKey}: configured function "${requested}" not found in IR`
239
+ );
240
+ }
241
+ }
242
+ moduleModel.functions = moduleModel.functions.filter(
243
+ f => allow.has(f.name) && !shouldExclude(f.name, false)
244
+ );
245
+ } else {
246
+ moduleModel.functions = moduleModel.functions.filter(f => !shouldExclude(f.name, true));
247
+ }
248
+
249
+ if (Array.isArray(moduleConfig.classes)) {
250
+ const allow = new Set(moduleConfig.classes.map(String));
251
+ for (const requested of allow) {
252
+ if (!moduleModel.classes.some(c => c.name === requested)) {
253
+ warnings.push(`Module ${moduleKey}: configured class "${requested}" not found in IR`);
254
+ }
255
+ }
256
+ moduleModel.classes = moduleModel.classes.filter(
257
+ c => allow.has(c.name) && !shouldExclude(c.name, false)
258
+ );
259
+ } else {
260
+ moduleModel.classes = moduleModel.classes.filter(c => !shouldExclude(c.name, true));
261
+ }
262
+
263
+ moduleModel.typeAliases = (moduleModel.typeAliases ?? []).filter(
264
+ alias => !shouldExclude(alias.name, false)
265
+ );
266
+ }
267
+
172
268
  // Generate module code
173
269
  const annotatedJSDoc = Boolean(resolvedOptions.output?.annotatedJSDoc);
174
270
  const gen = instance.generator.generateModuleDefinition(moduleModel, annotatedJSDoc);
@@ -182,7 +278,7 @@ export async function generate(
182
278
  if (resolvedOptions.output?.declaration) {
183
279
  filesToEmit.push({
184
280
  path: pathUtils.join(outputDir, `${baseName}.generated.d.ts`),
185
- content: renderDts(gen.typescript),
281
+ content: gen.declaration,
186
282
  });
187
283
  }
188
284
 
@@ -242,10 +338,10 @@ export async function generate(
242
338
  }
243
339
 
244
340
  if (checkMode) {
245
- return { written: [], warnings, outOfDate };
341
+ return { written: [], warnings, failures, outOfDate };
246
342
  }
247
343
 
248
- return { written, warnings };
344
+ return { written, warnings, failures };
249
345
  }
250
346
 
251
347
  /**
@@ -253,19 +349,28 @@ export async function generate(
253
349
  */
254
350
  async function fetchPythonIr(
255
351
  moduleName: string,
256
- pythonPath: string
352
+ pythonPath: string,
353
+ options: { timeoutMs?: number; pythonImportPath?: string[] } = {}
257
354
  ): Promise<{ ir: unknown | null; error?: string }> {
258
355
  if (!processUtils.isAvailable()) {
259
356
  return { ir: null, error: 'Subprocess operations not available in this runtime' };
260
357
  }
358
+ const delimiter = isWindows() ? ';' : ':';
359
+ const extraPaths = (options.pythonImportPath ?? []).filter(Boolean);
360
+ const existingPyPath =
361
+ typeof process !== 'undefined' && typeof process.env === 'object' && process.env
362
+ ? process.env.PYTHONPATH
363
+ : undefined;
364
+ const mergedPyPath = [...extraPaths, ...(existingPyPath ? [existingPyPath] : [])]
365
+ .filter(Boolean)
366
+ .join(delimiter);
367
+ const env = mergedPyPath ? { PYTHONPATH: mergedPyPath } : undefined;
261
368
  try {
262
- const result = await processUtils.exec(pythonPath, [
263
- '-m',
264
- 'tywrap_ir',
265
- '--module',
266
- moduleName,
267
- '--no-pretty',
268
- ]);
369
+ const result = await processUtils.exec(
370
+ pythonPath,
371
+ ['-m', 'tywrap_ir', '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
372
+ { timeoutMs: options.timeoutMs, env }
373
+ );
269
374
  if (result.code === 0) {
270
375
  try {
271
376
  return { ir: JSON.parse(result.stdout) };
@@ -277,18 +382,40 @@ async function fetchPythonIr(
277
382
  }
278
383
  }
279
384
 
280
- // Fallback to invoking local __main__.py
385
+ const stderrText = result.stderr.trim();
386
+ const isTywrapIrMissing =
387
+ stderrText.includes('No module named') && stderrText.includes('tywrap_ir');
388
+ if (!isTywrapIrMissing) {
389
+ return { ir: null, error: `tywrap_ir failed. stderr: ${stderrText || 'empty'}` };
390
+ }
391
+
392
+ // Fallback to invoking local __main__.py (useful when running from the repo).
281
393
  const localMain = pathUtils.join(process.cwd(), 'tywrap_ir', 'tywrap_ir', '__main__.py');
282
- const fallback = await processUtils.exec(pythonPath, [
283
- localMain,
284
- '--module',
285
- moduleName,
286
- '--no-pretty',
287
- ]);
394
+ let localMainExists = false;
395
+ if (fsUtils.isAvailable()) {
396
+ try {
397
+ await fsUtils.readFile(localMain);
398
+ localMainExists = true;
399
+ } catch {
400
+ localMainExists = false;
401
+ }
402
+ }
403
+ if (!localMainExists) {
404
+ return {
405
+ ir: null,
406
+ error: `tywrap_ir not found on PYTHONPATH. stderr: ${stderrText || 'empty'}`,
407
+ };
408
+ }
409
+
410
+ const fallback = await processUtils.exec(
411
+ pythonPath,
412
+ [localMain, '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
413
+ { timeoutMs: options.timeoutMs, env }
414
+ );
288
415
  if (fallback.code !== 0) {
289
416
  return {
290
417
  ir: null,
291
- error: `tywrap_ir failed. stderr: ${fallback.stderr.trim() || result.stderr.trim() || 'empty'}`,
418
+ error: `tywrap_ir failed. stderr: ${fallback.stderr.trim() || stderrText || 'empty'}`,
292
419
  };
293
420
  }
294
421
  try {
@@ -307,73 +434,161 @@ async function fetchPythonIr(
307
434
  /**
308
435
  * Convert JSON IR from Python into the internal TypeScript model used by the generator.
309
436
  */
437
+ function collectModuleTypeVarNames(obj: Record<string, unknown>): Set<string> {
438
+ const names = new Set<string>();
439
+ const constants = Array.isArray(obj.constants) ? (obj.constants as unknown[]) : [];
440
+
441
+ for (const constant of constants) {
442
+ const entry = constant as Record<string, unknown>;
443
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
444
+ if (!name) {
445
+ continue;
446
+ }
447
+
448
+ const valueRepr = typeof entry.value_repr === 'string' ? entry.value_repr.trim() : '';
449
+ if (/^~?[A-Za-z_][A-Za-z0-9_]*$/.test(valueRepr) && valueRepr.replace(/^~/, '') === name) {
450
+ names.add(name);
451
+ continue;
452
+ }
453
+
454
+ const annotation = typeof entry.annotation === 'string' ? entry.annotation.trim() : '';
455
+ if (/(^|\.)(TypeVar|ParamSpec|TypeVarTuple)(\[|\(|$)/.test(annotation)) {
456
+ names.add(name);
457
+ }
458
+ }
459
+
460
+ return names;
461
+ }
462
+
310
463
  function transformIrToTsModel(ir: unknown): TSPythonModule {
311
464
  const obj: Record<string, unknown> =
312
465
  typeof ir === 'object' && ir !== null ? (ir as Record<string, unknown>) : {};
313
466
  const functions = (obj.functions as unknown[]) ?? [];
314
467
  const classes = (obj.classes as unknown[]) ?? [];
315
- const mapParam = (p: Record<string, unknown>): Parameter => ({
468
+ const aliases = (obj.type_aliases as unknown[]) ?? [];
469
+ const moduleTypeVarNames = collectModuleTypeVarNames(obj);
470
+ const parseType = (
471
+ annotation: unknown,
472
+ typeParameters: readonly PythonGenericParameter[] = []
473
+ ): PythonType =>
474
+ parseAnnotationToPythonType(annotation, {
475
+ onUnknownTypeName: recordUnknown,
476
+ knownTypeVarNames: moduleTypeVarNames,
477
+ typeParameters,
478
+ });
479
+ const mapTypeParameters = (value: Record<string, unknown>): PythonGenericParameter[] =>
480
+ Array.isArray(value.type_params)
481
+ ? (value.type_params as unknown[]).map(v => {
482
+ const param = (v ?? {}) as Record<string, unknown>;
483
+ return {
484
+ name: String(param.name ?? ''),
485
+ kind: String(param.kind ?? 'typevar') as PythonGenericParameter['kind'],
486
+ bound: param.bound ? parseType(param.bound) : undefined,
487
+ constraints: Array.isArray(param.constraints)
488
+ ? (param.constraints as unknown[]).map(item => parseType(item))
489
+ : undefined,
490
+ variance:
491
+ param.variance === 'covariant' ||
492
+ param.variance === 'contravariant' ||
493
+ param.variance === 'invariant'
494
+ ? param.variance
495
+ : undefined,
496
+ } satisfies PythonGenericParameter;
497
+ })
498
+ : [];
499
+ const mapParam = (
500
+ p: Record<string, unknown>,
501
+ typeParameters: readonly PythonGenericParameter[] = []
502
+ ): Parameter => ({
316
503
  name: String(p.name ?? ''),
317
- type: parseAnnotationToPythonType(p.annotation),
504
+ type: parseType(p.annotation, typeParameters),
318
505
  optional: Boolean(p.default),
319
506
  varArgs: p.kind === 'VAR_POSITIONAL',
320
507
  kwArgs: p.kind === 'VAR_KEYWORD',
508
+ positionalOnly: p.kind === 'POSITIONAL_ONLY',
509
+ keywordOnly: p.kind === 'KEYWORD_ONLY',
321
510
  });
322
511
 
323
- const mapFunc = (f: Record<string, unknown>): PythonFunction => ({
324
- name: String(f.name ?? ''),
325
- signature: {
326
- parameters: Array.isArray(f.parameters)
327
- ? (f.parameters as unknown[]).map(v => mapParam((v ?? {}) as Record<string, unknown>))
328
- : [],
329
- returnType: parseAnnotationToPythonType(f.returns),
512
+ const mapFunc = (
513
+ f: Record<string, unknown>,
514
+ inheritedTypeParameters: readonly PythonGenericParameter[] = []
515
+ ): PythonFunction => {
516
+ const localTypeParameters = mapTypeParameters(f);
517
+ const annotationTypeParameters = [...inheritedTypeParameters, ...localTypeParameters];
518
+ return {
519
+ name: String(f.name ?? ''),
520
+ signature: {
521
+ parameters: Array.isArray(f.parameters)
522
+ ? (f.parameters as unknown[]).map(v =>
523
+ mapParam((v ?? {}) as Record<string, unknown>, annotationTypeParameters)
524
+ )
525
+ : [],
526
+ returnType: parseType(f.returns, annotationTypeParameters),
527
+ isAsync: Boolean(f.is_async),
528
+ isGenerator: Boolean(f.is_generator),
529
+ },
530
+ docstring: (f.docstring as string | undefined) ?? undefined,
531
+ decorators: [],
330
532
  isAsync: Boolean(f.is_async),
331
533
  isGenerator: Boolean(f.is_generator),
332
- },
333
- docstring: (f.docstring as string | undefined) ?? undefined,
334
- decorators: [],
335
- isAsync: Boolean(f.is_async),
336
- isGenerator: Boolean(f.is_generator),
337
- returnType: parseAnnotationToPythonType(f.returns),
338
- parameters: Array.isArray(f.parameters)
339
- ? (f.parameters as unknown[]).map(v => mapParam((v ?? {}) as Record<string, unknown>))
340
- : [],
341
- });
534
+ typeParameters: [...localTypeParameters],
535
+ returnType: parseType(f.returns, annotationTypeParameters),
536
+ parameters: Array.isArray(f.parameters)
537
+ ? (f.parameters as unknown[]).map(v =>
538
+ mapParam((v ?? {}) as Record<string, unknown>, annotationTypeParameters)
539
+ )
540
+ : [],
541
+ };
542
+ };
342
543
 
343
- const mapClass = (c: Record<string, unknown>): PythonClass => ({
344
- name: String(c.name ?? ''),
345
- bases: Array.isArray(c.bases) ? (c.bases as string[]) : [],
346
- methods: Array.isArray(c.methods)
347
- ? (c.methods as unknown[]).map(v => mapFunc((v ?? {}) as Record<string, unknown>))
348
- : [],
349
- properties: Array.isArray(c.fields)
350
- ? ((c.fields as unknown[]).map(v => {
351
- const p = (v ?? {}) as Record<string, unknown>;
352
- const optional = Boolean(p.default);
353
- return {
354
- name: String(p.name ?? ''),
355
- type: parseAnnotationToPythonType(p.annotation),
356
- readonly: false,
357
- setter: false,
358
- getter: true,
359
- optional,
360
- } as unknown as never;
361
- }) as unknown as PythonClass['properties'])
362
- : [],
363
- docstring: (c.docstring as string | undefined) ?? undefined,
364
- decorators: (c.typed_dict as boolean) ? ['__typed_dict__'] : [],
365
- kind: (c.typed_dict as boolean)
366
- ? 'typed_dict'
367
- : (c.is_protocol as boolean)
368
- ? 'protocol'
369
- : (c.is_namedtuple as boolean)
370
- ? 'namedtuple'
371
- : (c.is_dataclass as boolean)
372
- ? 'dataclass'
373
- : (c.is_pydantic as boolean)
374
- ? 'pydantic'
375
- : 'class',
376
- });
544
+ const mapClass = (c: Record<string, unknown>): PythonClass => {
545
+ const classTypeParameters = mapTypeParameters(c);
546
+ return {
547
+ name: String(c.name ?? ''),
548
+ bases: Array.isArray(c.bases) ? (c.bases as string[]) : [],
549
+ methods: Array.isArray(c.methods)
550
+ ? (c.methods as unknown[]).map(v =>
551
+ mapFunc((v ?? {}) as Record<string, unknown>, classTypeParameters)
552
+ )
553
+ : [],
554
+ properties: Array.isArray(c.fields)
555
+ ? (c.fields as unknown[]).map(v => {
556
+ const p = (v ?? {}) as Record<string, unknown>;
557
+ return {
558
+ name: String(p.name ?? ''),
559
+ type: parseType(p.annotation, classTypeParameters),
560
+ readonly: false,
561
+ setter: false,
562
+ getter: true,
563
+ optional: Boolean(p.default),
564
+ };
565
+ })
566
+ : [],
567
+ docstring: (c.docstring as string | undefined) ?? undefined,
568
+ decorators: (c.typed_dict as boolean) ? ['__typed_dict__'] : [],
569
+ kind: (c.typed_dict as boolean)
570
+ ? 'typed_dict'
571
+ : (c.is_protocol as boolean)
572
+ ? 'protocol'
573
+ : (c.is_namedtuple as boolean)
574
+ ? 'namedtuple'
575
+ : (c.is_dataclass as boolean)
576
+ ? 'dataclass'
577
+ : (c.is_pydantic as boolean)
578
+ ? 'pydantic'
579
+ : 'class',
580
+ typeParameters: classTypeParameters,
581
+ };
582
+ };
583
+
584
+ const mapTypeAlias = (alias: Record<string, unknown>): PythonTypeAlias => {
585
+ const typeParameters = mapTypeParameters(alias);
586
+ return {
587
+ name: String(alias.name ?? ''),
588
+ type: parseType(alias.definition, typeParameters),
589
+ typeParameters,
590
+ };
591
+ };
377
592
 
378
593
  const moduleModel: TSPythonModule = {
379
594
  name: (obj.module as string) ?? 'module',
@@ -384,255 +599,13 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
384
599
  : undefined,
385
600
  functions: functions.map(v => mapFunc((v ?? {}) as Record<string, unknown>)),
386
601
  classes: classes.map(v => mapClass((v ?? {}) as Record<string, unknown>)),
602
+ typeAliases: aliases.map(v => mapTypeAlias((v ?? {}) as Record<string, unknown>)),
387
603
  imports: [],
388
604
  exports: [],
389
605
  };
390
606
  return moduleModel;
391
607
  }
392
608
 
393
- function unknownType(): PythonType {
394
- return { kind: 'custom', name: 'Any', module: 'typing' };
395
- }
396
-
397
- // Parse Python IR annotation string -> PythonType
398
- function parseAnnotationToPythonType(annotation: unknown, depth = 0): PythonType {
399
- if (annotation === null || annotation === undefined) {
400
- return unknownType();
401
- }
402
- if (depth > 100) {
403
- return unknownType();
404
- }
405
- const raw = String(annotation).trim();
406
-
407
- // Handle built-in class repr: <class 'int'>
408
- const classMatch = raw.match(/^<class ['"][^'"]+['"]>$/);
409
- if (classMatch) {
410
- const inner = (raw.match(/^<class ['"]([^'"]+)['"]>$/) ?? [])[1] ?? '';
411
- const name = (inner.split('.').pop() ?? '').toString();
412
- return mapSimpleName(name);
413
- }
414
-
415
- // PEP 604 unions: int | str | None
416
- if (raw.includes(' | ')) {
417
- const parts = splitTopLevel(raw, '|');
418
- const types = parts.map(p => parseAnnotationToPythonType(p.trim(), depth + 1));
419
- return { kind: 'union', types };
420
- }
421
-
422
- // typing.Union[...]
423
- if (raw.startsWith('typing.Union[') || raw.startsWith('Union[')) {
424
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
425
- const parts = splitTopLevel(inner, ',');
426
- const types = parts.map(p => parseAnnotationToPythonType(p.trim(), depth + 1));
427
- return { kind: 'union', types };
428
- }
429
-
430
- // Optional[T]
431
- if (raw.startsWith('typing.Optional[') || raw.startsWith('Optional[')) {
432
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
433
- const base = parseAnnotationToPythonType(inner, depth + 1);
434
- return { kind: 'optional', type: base };
435
- }
436
-
437
- // Literal[...] -> literal values union
438
- if (raw.startsWith('typing.Literal[') || raw.startsWith('Literal[')) {
439
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
440
- const parts = splitTopLevel(inner, ',');
441
- if (parts.length === 1) {
442
- return mapLiteral(String(parts[0] ?? '').trim());
443
- }
444
- return { kind: 'union', types: parts.map(p => mapLiteral(String(p).trim())) } as PythonType;
445
- }
446
-
447
- // typing_extensions wrappers: ClassVar[T], Final[T], Required[T], NotRequired[T]
448
- const extMatch = raw.match(
449
- /^(typing\.|typing_extensions\.)?(ClassVar|Final|Required|NotRequired)\[(.*)\]$/
450
- );
451
- if (extMatch) {
452
- const inner = extMatch[3] ?? '';
453
- const base = parseAnnotationToPythonType(inner, depth + 1);
454
- return base;
455
- }
456
-
457
- // LiteralString
458
- if (raw === 'typing.LiteralString' || raw === 'LiteralString') {
459
- return { kind: 'primitive', name: 'str' } as PythonType;
460
- }
461
-
462
- // Callable[[...], R]
463
- if (raw.startsWith('typing.Callable[') || raw.startsWith('Callable[')) {
464
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
465
- const parts = splitTopLevel(inner, ',');
466
- if (parts.length >= 2) {
467
- const paramsPart = (parts[0] ?? '').trim();
468
- const returnPart = parts.slice(1).join(',').trim();
469
- const paramInner =
470
- paramsPart.startsWith('[') && paramsPart.endsWith(']') ? paramsPart.slice(1, -1) : '';
471
- const paramTypes = ((): PythonType[] => {
472
- const trimmed = paramInner.trim();
473
- if (trimmed === '...' || trimmed === 'Ellipsis') {
474
- return [{ kind: 'custom', name: '...' } as PythonType];
475
- }
476
- return trimmed
477
- ? splitTopLevel(trimmed, ',').map(p => parseAnnotationToPythonType(p.trim(), depth + 1))
478
- : [];
479
- })();
480
- const returnType = parseAnnotationToPythonType(returnPart, depth + 1);
481
- return { kind: 'callable', parameters: paramTypes, returnType } as PythonType;
482
- }
483
- }
484
-
485
- // Mapping[K, V] / Dict[K, V] normalization
486
- if (raw.startsWith('typing.Mapping[') || raw.startsWith('Mapping[')) {
487
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
488
- const parts = splitTopLevel(inner, ',');
489
- const itemTypes = parts.map(p => parseAnnotationToPythonType(p.trim(), depth + 1));
490
- return { kind: 'collection', name: 'dict', itemTypes } as PythonType;
491
- }
492
-
493
- // Annotated[T, ...] -> annotated node with base and metadata
494
- if (raw.startsWith('typing.Annotated[') || raw.startsWith('Annotated[')) {
495
- const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
496
- const parts = splitTopLevel(inner, ',');
497
- if (parts.length > 0) {
498
- const base = parseAnnotationToPythonType((parts[0] ?? '').trim(), depth + 1);
499
- const metaParts = parts.slice(1).map(p => String(p).trim());
500
- return { kind: 'annotated', base, metadata: metaParts } as PythonType;
501
- }
502
- }
503
-
504
- // Collections: list[T], dict[K,V], tuple[...], set[T]
505
- const coll = normalizeCollectionName(raw);
506
- if (coll) {
507
- const { name, inner } = coll;
508
- const itemParts =
509
- name === 'dict' ? splitTopLevel(inner ?? '', ',') : splitTopLevel(inner ?? '', ',');
510
- const itemTypes = (inner ? itemParts : []).map(p =>
511
- parseAnnotationToPythonType(p.trim(), depth + 1)
512
- );
513
- return { kind: 'collection', name, itemTypes };
514
- }
515
-
516
- // typing.Callable[...] unlikely for now - treat as custom
517
-
518
- // Bare names like int, str, float, bool, bytes, None
519
- return mapSimpleName(raw);
520
- }
521
-
522
- function mapSimpleName(name: string): PythonType {
523
- const n = name.replace(/^typing\./, '').trim();
524
- if (n === 'int' || n === 'float' || n === 'str' || n === 'bool' || n === 'bytes') {
525
- // cast to the specific union type without using any
526
- return { kind: 'primitive', name: n };
527
- }
528
- // Track unknown typing-ish names for diagnostics
529
- if (
530
- n === 'Any' ||
531
- n === 'Never' ||
532
- n === 'LiteralString' ||
533
- n === 'ClassVar' ||
534
- n === 'Final' ||
535
- n === 'TypeAlias' ||
536
- n === 'Required' ||
537
- n === 'NotRequired'
538
- ) {
539
- try {
540
- recordUnknown(n);
541
- } catch {}
542
- }
543
- if (n === 'None' || n.toLowerCase() === 'nonetype') {
544
- return { kind: 'primitive', name: 'None' };
545
- }
546
- if (n === 'list' || n === 'List') {
547
- return { kind: 'collection', name: 'list', itemTypes: [] };
548
- }
549
- if (n === 'dict' || n === 'Dict') {
550
- return { kind: 'collection', name: 'dict', itemTypes: [] };
551
- }
552
- if (n === 'tuple' || n === 'Tuple') {
553
- return { kind: 'collection', name: 'tuple', itemTypes: [] };
554
- }
555
- if (n === 'set' || n === 'Set') {
556
- return { kind: 'collection', name: 'set', itemTypes: [] };
557
- }
558
- return { kind: 'custom', name: n };
559
- }
560
-
561
- function normalizeCollectionName(
562
- raw: string
563
- ): { name: 'list' | 'dict' | 'tuple' | 'set' | 'frozenset'; inner?: string } | null {
564
- const m = raw.match(/^(typing\.)?(List|Dict|Tuple|Set|list|dict|tuple|set)\[(.*)\]$/);
565
- if (!m) {
566
- return null;
567
- }
568
- const nameRaw = String(m[2] ?? '');
569
- const name =
570
- nameRaw.toLowerCase() === 'list'
571
- ? 'list'
572
- : nameRaw.toLowerCase() === 'dict'
573
- ? 'dict'
574
- : nameRaw.toLowerCase() === 'tuple'
575
- ? 'tuple'
576
- : 'set';
577
- return { name, inner: String(m[3] ?? '') };
578
- }
579
-
580
- function splitTopLevel(input: string, sep: '|' | ','): string[] {
581
- const results: string[] = [];
582
- let level = 0;
583
- let cur = '';
584
- let guard = 0;
585
- for (let i = 0; i < input.length; i++) {
586
- // Depth/length guard to avoid pathological recursion/loops
587
- guard++;
588
- if (guard > 20000) {
589
- // Bail out safely; push remaining and break
590
- if (cur.trim()) {
591
- results.push(cur.trim());
592
- }
593
- break;
594
- }
595
- const ch = input.charAt(i);
596
- if (ch === '[' || ch === '(') {
597
- level++;
598
- }
599
- if (ch === ']' || ch === ')') {
600
- level = Math.max(0, level - 1);
601
- }
602
- if (level === 0 && input.slice(i, i + sep.length) === sep) {
603
- results.push(cur.trim());
604
- cur = '';
605
- i += sep.length - 1;
606
- continue;
607
- }
608
- cur += ch;
609
- }
610
- if (cur.trim()) {
611
- results.push(cur.trim());
612
- }
613
- return results;
614
- }
615
-
616
- function mapLiteral(text: string): PythonType {
617
- // strip quotes if present
618
- const t = text.trim();
619
- if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
620
- return { kind: 'literal', value: t.slice(1, -1) } as PythonType;
621
- }
622
- if (t === 'True' || t === 'False') {
623
- return { kind: 'literal', value: t === 'True' } as PythonType;
624
- }
625
- if (t === 'None') {
626
- return { kind: 'literal', value: null } as PythonType;
627
- }
628
- const num = Number(t);
629
- if (!Number.isNaN(num)) {
630
- return { kind: 'literal', value: num } as PythonType;
631
- }
632
- // fallback
633
- return { kind: 'custom', name: t } as PythonType;
634
- }
635
-
636
609
  /**
637
610
  * Compute a stable cache key filename for a module and options
638
611
  */
@@ -650,6 +623,8 @@ async function computeCacheKey(
650
623
  const keyObject = {
651
624
  module: moduleName,
652
625
  moduleVersion: moduleConfig?.version ?? null,
626
+ irVersion: TYWRAP_IR_VERSION,
627
+ pythonImportPath: options.pythonImportPath ?? [],
653
628
  runtime: {
654
629
  pythonPath: runtimePython,
655
630
  virtualEnv: options.runtime?.node?.virtualEnv ?? null,
@@ -665,47 +640,7 @@ async function computeCacheKey(
665
640
  },
666
641
  typeHints: moduleConfig?.typeHints ?? 'strict',
667
642
  } as const;
668
- const normalized = JSON.stringify(keyObject);
669
- const digest = await hashUtils.sha256Hex(normalized);
670
- return `ir_${moduleName}_${digest.slice(0, 16)}.json`;
671
- }
672
-
673
- /**
674
- * Very lightweight .d.ts emitter derived from generated TS wrappers
675
- * This is intentionally minimal and stable for our wrappers shape.
676
- */
677
- function renderDts(generatedTs: string): string {
678
- const header = `// Generated by tywrap\n// Type Declarations\n// DO NOT EDIT MANUALLY\n\n`;
679
- const lines: string[] = [header];
680
- // Extract function exports
681
- const funcRegex = /export\s+async\s+function\s+(\w+)\s*\(([^)]*)\)\s*:\s*Promise<([^>]+)>/g;
682
- let m: RegExpExecArray | null;
683
- while ((m = funcRegex.exec(generatedTs)) !== null) {
684
- const name = m[1];
685
- const params = m[2];
686
- const ret = m[3];
687
- lines.push(`export function ${name}(${params}): Promise<${ret}>;`);
688
- }
689
- // Extract class exports and methods
690
- const classRegex = /export\s+class\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
691
- while ((m = classRegex.exec(generatedTs)) !== null) {
692
- const className = String(m[1] ?? '');
693
- const body = String(m[2] ?? '');
694
- // Constructor is always variadic unknown[] in current generator
695
- const methods: string[] = [];
696
- const methodRegex = /\n\s+async\s+(\w+)\s*\(([^)]*)\)\s*:\s*Promise<([^>]+)>/g;
697
- let mm: RegExpExecArray | null;
698
- while ((mm = methodRegex.exec(body)) !== null) {
699
- methods.push(` ${mm[1]}(${mm[2]}): Promise<${mm[3]}>;`);
700
- }
701
- lines.push(`export class ${className} {`);
702
- lines.push(` constructor(...args: unknown[]);`);
703
- if (methods.length > 0) {
704
- lines.push(methods.join('\n'));
705
- }
706
- lines.push('}');
707
- }
708
- return `${lines.join('\n')}\n`;
643
+ return await computeIrCacheFilename(keyObject);
709
644
  }
710
645
 
711
646
  /**