tywrap 0.6.1 → 0.8.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 (110) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +40 -9
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/dev.d.ts.map +1 -1
  11. package/dist/dev.js +1 -3
  12. package/dist/dev.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/runtime/base-bridge.d.ts +57 -0
  17. package/dist/runtime/base-bridge.d.ts.map +1 -0
  18. package/dist/runtime/base-bridge.js +72 -0
  19. package/dist/runtime/base-bridge.js.map +1 -0
  20. package/dist/runtime/frame-codec.d.ts +111 -0
  21. package/dist/runtime/frame-codec.d.ts.map +1 -0
  22. package/dist/runtime/frame-codec.js +352 -0
  23. package/dist/runtime/frame-codec.js.map +1 -0
  24. package/dist/runtime/http-transport.d.ts +11 -1
  25. package/dist/runtime/http-transport.d.ts.map +1 -1
  26. package/dist/runtime/http-transport.js +19 -0
  27. package/dist/runtime/http-transport.js.map +1 -1
  28. package/dist/runtime/http.d.ts +5 -12
  29. package/dist/runtime/http.d.ts.map +1 -1
  30. package/dist/runtime/http.js +6 -29
  31. package/dist/runtime/http.js.map +1 -1
  32. package/dist/runtime/index.d.ts +2 -2
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/node.d.ts +25 -19
  37. package/dist/runtime/node.d.ts.map +1 -1
  38. package/dist/runtime/node.js +19 -34
  39. package/dist/runtime/node.js.map +1 -1
  40. package/dist/runtime/pooled-transport.d.ts +21 -2
  41. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  42. package/dist/runtime/pooled-transport.js +16 -0
  43. package/dist/runtime/pooled-transport.js.map +1 -1
  44. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  45. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  47. package/dist/runtime/pyodide-transport.d.ts +12 -1
  48. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  49. package/dist/runtime/pyodide-transport.js +20 -0
  50. package/dist/runtime/pyodide-transport.js.map +1 -1
  51. package/dist/runtime/pyodide.d.ts +5 -12
  52. package/dist/runtime/pyodide.d.ts.map +1 -1
  53. package/dist/runtime/pyodide.js +6 -29
  54. package/dist/runtime/pyodide.js.map +1 -1
  55. package/dist/runtime/rpc-client.d.ts +14 -1
  56. package/dist/runtime/rpc-client.d.ts.map +1 -1
  57. package/dist/runtime/rpc-client.js +68 -6
  58. package/dist/runtime/rpc-client.js.map +1 -1
  59. package/dist/runtime/subprocess-transport.d.ts +177 -3
  60. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  61. package/dist/runtime/subprocess-transport.js +526 -26
  62. package/dist/runtime/subprocess-transport.js.map +1 -1
  63. package/dist/runtime/transport.d.ts +141 -0
  64. package/dist/runtime/transport.d.ts.map +1 -1
  65. package/dist/runtime/transport.js +21 -0
  66. package/dist/runtime/transport.js.map +1 -1
  67. package/dist/types/index.d.ts +59 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/dist/tywrap.d.ts.map +1 -1
  70. package/dist/tywrap.js +204 -149
  71. package/dist/tywrap.js.map +1 -1
  72. package/dist/utils/codec.d.ts +2 -0
  73. package/dist/utils/codec.d.ts.map +1 -1
  74. package/dist/utils/codec.js +205 -6
  75. package/dist/utils/codec.js.map +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +7 -1
  78. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  79. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  80. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  81. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  82. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  83. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  84. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  85. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  86. package/runtime/frame_codec.py +424 -0
  87. package/runtime/python_bridge.py +241 -42
  88. package/runtime/tywrap_bridge_core.py +152 -13
  89. package/src/core/annotation-parser.ts +2 -1
  90. package/src/core/emit-call.ts +1 -7
  91. package/src/core/generator.ts +50 -11
  92. package/src/dev.ts +1 -3
  93. package/src/index.ts +1 -0
  94. package/src/runtime/base-bridge.ts +106 -0
  95. package/src/runtime/frame-codec.ts +469 -0
  96. package/src/runtime/http-transport.ts +21 -1
  97. package/src/runtime/http.ts +7 -51
  98. package/src/runtime/index.ts +2 -6
  99. package/src/runtime/node.ts +42 -53
  100. package/src/runtime/pooled-transport.ts +25 -2
  101. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  102. package/src/runtime/pyodide-transport.ts +22 -0
  103. package/src/runtime/pyodide.ts +7 -52
  104. package/src/runtime/rpc-client.ts +91 -7
  105. package/src/runtime/subprocess-transport.ts +629 -30
  106. package/src/runtime/transport.ts +169 -0
  107. package/src/types/index.ts +62 -0
  108. package/src/tywrap.ts +265 -162
  109. package/src/utils/codec.ts +245 -7
  110. package/src/version.ts +1 -1
package/src/tywrap.ts CHANGED
@@ -15,13 +15,14 @@ import type {
15
15
  PythonTypeAlias,
16
16
  Parameter,
17
17
  PythonType,
18
+ PythonModuleConfig,
18
19
  } from './types/index.js';
19
20
  import { fsUtils, pathUtils, processUtils, isWindows } from './utils/runtime.js';
20
21
  import { globalCache } from './utils/cache.js';
21
22
  import { resolvePythonExecutable } from './utils/python.js';
22
23
  import { computeIrCacheFilename } from './utils/ir-cache.js';
23
24
 
24
- const TYWRAP_IR_VERSION = '0.2.0';
25
+ const TYWRAP_IR_VERSION = '0.3.0';
25
26
 
26
27
  // Collect unknown typing constructs encountered during annotation parsing (per-generate run)
27
28
  let unknownTypeNamesCollector: Map<string, number> = new Map();
@@ -90,6 +91,195 @@ async function safeReadFile(path: string): Promise<string | null> {
90
91
  }
91
92
  }
92
93
 
94
+ const CACHE_DIR = '.tywrap/cache';
95
+
96
+ /**
97
+ * Resolve module IR via the Python extractor, consulting and (best-effort)
98
+ * populating the on-disk cache. Mirrors the original inline cache-then-fetch
99
+ * logic exactly: the cache is only read/written when caching is enabled, the
100
+ * filesystem is available, and we are not in check mode.
101
+ */
102
+ async function fetchAndCacheIr(
103
+ moduleKey: string,
104
+ resolvedOptions: ReturnType<typeof createConfig>,
105
+ pythonPath: string,
106
+ cacheKey: string,
107
+ caching: boolean,
108
+ checkMode: boolean
109
+ ): Promise<{ ir: unknown | null; error?: string }> {
110
+ let ir: unknown | null = null;
111
+ let irError: string | undefined;
112
+ if (caching && fsUtils.isAvailable() && !checkMode) {
113
+ try {
114
+ const cached = await fsUtils.readFile(pathUtils.join(CACHE_DIR, cacheKey));
115
+ ir = JSON.parse(cached);
116
+ } catch {
117
+ ir = null;
118
+ }
119
+ }
120
+ if (!ir) {
121
+ const fetchResult = await fetchPythonIr(moduleKey, pythonPath, {
122
+ timeoutMs: resolvedOptions.runtime?.node?.timeout,
123
+ pythonImportPath: resolvedOptions.pythonImportPath,
124
+ });
125
+ ir = fetchResult.ir;
126
+ irError = fetchResult.error;
127
+ if (ir && caching && fsUtils.isAvailable() && !checkMode) {
128
+ try {
129
+ await fsUtils.writeFile(pathUtils.join(CACHE_DIR, cacheKey), JSON.stringify(ir));
130
+ } catch {}
131
+ }
132
+ }
133
+ return { ir, error: irError };
134
+ }
135
+
136
+ /**
137
+ * Apply module-level export filtering (functions/classes + excludes) in place.
138
+ * Pushes any filtering warnings onto the provided `warnings` array. Behavior is
139
+ * identical to the original inline block.
140
+ */
141
+ function filterModuleExports(
142
+ moduleModel: TSPythonModule,
143
+ moduleConfig: PythonModuleConfig,
144
+ moduleKey: string,
145
+ warnings: string[]
146
+ ): void {
147
+ const builtInDefaultExcludes = new Set([
148
+ 'dataclass',
149
+ 'property',
150
+ 'staticmethod',
151
+ 'classmethod',
152
+ 'abstractmethod',
153
+ 'cached_property',
154
+ ]);
155
+
156
+ const excludeExact = new Set((moduleConfig.exclude ?? []).map(String));
157
+ const excludeRegexes: RegExp[] = [];
158
+ for (const pattern of moduleConfig.excludePatterns ?? []) {
159
+ try {
160
+ excludeRegexes.push(new RegExp(String(pattern)));
161
+ } catch (err: unknown) {
162
+ const message = err instanceof Error ? err.message : String(err);
163
+ warnings.push(
164
+ `Module ${moduleKey}: invalid excludePatterns regex "${String(pattern)}": ${message}`
165
+ );
166
+ }
167
+ }
168
+
169
+ const shouldExclude = (name: string, applyBuiltInDefaults: boolean): boolean => {
170
+ if (excludeExact.has(name)) {
171
+ return true;
172
+ }
173
+ if (excludeRegexes.some(r => r.test(name))) {
174
+ return true;
175
+ }
176
+ if (applyBuiltInDefaults && builtInDefaultExcludes.has(name)) {
177
+ return true;
178
+ }
179
+ return false;
180
+ };
181
+
182
+ if (Array.isArray(moduleConfig.functions)) {
183
+ const allow = new Set(moduleConfig.functions.map(String));
184
+ for (const requested of allow) {
185
+ if (!moduleModel.functions.some(f => f.name === requested)) {
186
+ warnings.push(`Module ${moduleKey}: configured function "${requested}" not found in IR`);
187
+ }
188
+ }
189
+ moduleModel.functions = moduleModel.functions.filter(
190
+ f => allow.has(f.name) && !shouldExclude(f.name, false)
191
+ );
192
+ } else {
193
+ moduleModel.functions = moduleModel.functions.filter(f => !shouldExclude(f.name, true));
194
+ }
195
+
196
+ if (Array.isArray(moduleConfig.classes)) {
197
+ const allow = new Set(moduleConfig.classes.map(String));
198
+ for (const requested of allow) {
199
+ if (!moduleModel.classes.some(c => c.name === requested)) {
200
+ warnings.push(`Module ${moduleKey}: configured class "${requested}" not found in IR`);
201
+ }
202
+ }
203
+ moduleModel.classes = moduleModel.classes.filter(
204
+ c => allow.has(c.name) && !shouldExclude(c.name, false)
205
+ );
206
+ } else {
207
+ moduleModel.classes = moduleModel.classes.filter(c => !shouldExclude(c.name, true));
208
+ }
209
+
210
+ moduleModel.typeAliases = (moduleModel.typeAliases ?? []).filter(
211
+ alias => !shouldExclude(alias.name, false)
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Either compare the generated files against disk (check mode) or write them.
217
+ * Mutates `written`/`outOfDate` exactly as the original inline branches did.
218
+ */
219
+ async function emitOrCompareFiles(
220
+ filesToEmit: Array<{ path: string; content: string }>,
221
+ checkMode: boolean,
222
+ written: string[],
223
+ outOfDate: string[]
224
+ ): Promise<void> {
225
+ if (checkMode) {
226
+ for (const file of filesToEmit) {
227
+ const existing = await safeReadFile(file.path);
228
+ if (existing === null) {
229
+ outOfDate.push(file.path);
230
+ continue;
231
+ }
232
+ if (normalizeForComparison(existing) !== normalizeForComparison(file.content)) {
233
+ outOfDate.push(file.path);
234
+ }
235
+ }
236
+ } else {
237
+ for (const file of filesToEmit) {
238
+ await fsUtils.writeFile(file.path, file.content);
239
+ written.push(file.path);
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Emit the best-effort warning summary of unknown typing constructs collected
246
+ * for the current module, and (outside check mode) write the JSON report.
247
+ */
248
+ async function reportUnknownTypes(
249
+ baseName: string,
250
+ unknownTypeNames: Map<string, number>,
251
+ checkMode: boolean,
252
+ warnings: string[]
253
+ ): Promise<void> {
254
+ if (unknownTypeNames.size === 0) {
255
+ return;
256
+ }
257
+ const entries = Array.from(unknownTypeNames.entries()).sort((a, b) => b[1] - a[1]);
258
+ const unkList = entries
259
+ .slice(0, 25)
260
+ .map(([n, c]) => `${n}:${c}`)
261
+ .join(', ');
262
+ warnings.push(
263
+ `Module ${baseName}: unknown typing constructs encountered: ${unkList}${entries.length > 25 ? '…' : ''}`
264
+ );
265
+ // Write JSON report
266
+ if (!checkMode) {
267
+ try {
268
+ const reportsDir = pathUtils.join('.tywrap', 'reports');
269
+ await fsUtils.writeFile(
270
+ pathUtils.join(reportsDir, `${baseName}.json`),
271
+ JSON.stringify({
272
+ module: baseName,
273
+ unknowns: Object.fromEntries(entries),
274
+ generatedAt: new Date().toISOString(),
275
+ })
276
+ );
277
+ } catch {
278
+ // ignore
279
+ }
280
+ }
281
+ }
282
+
93
283
  /**
94
284
  * Generate TypeScript wrappers for configured Python modules.
95
285
  * Minimal MVP implementation: resolves module file, analyzes, generates TS, writes to output.dir
@@ -107,7 +297,6 @@ export async function generate(
107
297
  const failures: GenerateFailure[] = [];
108
298
  const outputDir = resolvedOptions.output.dir;
109
299
  const caching = resolvedOptions.performance.caching;
110
- const cacheDir = '.tywrap/cache';
111
300
  const pythonPath = await resolvePythonExecutable({
112
301
  pythonPath: resolvedOptions.runtime?.node?.pythonPath,
113
302
  virtualEnv: resolvedOptions.runtime?.node?.virtualEnv,
@@ -131,29 +320,14 @@ export async function generate(
131
320
  unknownTypeNamesCollector = new Map();
132
321
  // Resolve module IR via the Python extractor (with optional cache)
133
322
  const cacheKey = await computeCacheKey(moduleKey, resolvedOptions);
134
- let ir: unknown | null = null;
135
- let irError: string | undefined;
136
- if (caching && fsUtils.isAvailable() && !checkMode) {
137
- try {
138
- const cached = await fsUtils.readFile(pathUtils.join(cacheDir, cacheKey));
139
- ir = JSON.parse(cached);
140
- } catch {
141
- ir = null;
142
- }
143
- }
144
- if (!ir) {
145
- const fetchResult = await fetchPythonIr(moduleKey, pythonPath, {
146
- timeoutMs: resolvedOptions.runtime?.node?.timeout,
147
- pythonImportPath: resolvedOptions.pythonImportPath,
148
- });
149
- ir = fetchResult.ir;
150
- irError = fetchResult.error;
151
- if (ir && caching && fsUtils.isAvailable() && !checkMode) {
152
- try {
153
- await fsUtils.writeFile(pathUtils.join(cacheDir, cacheKey), JSON.stringify(ir));
154
- } catch {}
155
- }
156
- }
323
+ const { ir, error: irError } = await fetchAndCacheIr(
324
+ moduleKey,
325
+ resolvedOptions,
326
+ pythonPath,
327
+ cacheKey,
328
+ caching,
329
+ checkMode
330
+ );
157
331
  if (!ir) {
158
332
  const message = `No IR produced for module ${moduleKey}${irError ? `: ${irError}` : ''}`;
159
333
  warnings.push(message);
@@ -168,76 +342,7 @@ export async function generate(
168
342
  const moduleModel = transformIrToTsModel(ir);
169
343
 
170
344
  // Apply module-level export filtering (functions/classes + excludes).
171
- {
172
- const builtInDefaultExcludes = new Set([
173
- 'dataclass',
174
- 'property',
175
- 'staticmethod',
176
- 'classmethod',
177
- 'abstractmethod',
178
- 'cached_property',
179
- ]);
180
-
181
- const excludeExact = new Set((moduleConfig.exclude ?? []).map(String));
182
- const excludeRegexes: RegExp[] = [];
183
- for (const pattern of moduleConfig.excludePatterns ?? []) {
184
- try {
185
- excludeRegexes.push(new RegExp(String(pattern)));
186
- } catch (err: unknown) {
187
- const message = err instanceof Error ? err.message : String(err);
188
- warnings.push(
189
- `Module ${moduleKey}: invalid excludePatterns regex "${String(pattern)}": ${message}`
190
- );
191
- }
192
- }
193
-
194
- const shouldExclude = (name: string, applyBuiltInDefaults: boolean): boolean => {
195
- if (excludeExact.has(name)) {
196
- return true;
197
- }
198
- if (excludeRegexes.some(r => r.test(name))) {
199
- return true;
200
- }
201
- if (applyBuiltInDefaults && builtInDefaultExcludes.has(name)) {
202
- return true;
203
- }
204
- return false;
205
- };
206
-
207
- if (Array.isArray(moduleConfig.functions)) {
208
- const allow = new Set(moduleConfig.functions.map(String));
209
- for (const requested of allow) {
210
- if (!moduleModel.functions.some(f => f.name === requested)) {
211
- warnings.push(
212
- `Module ${moduleKey}: configured function "${requested}" not found in IR`
213
- );
214
- }
215
- }
216
- moduleModel.functions = moduleModel.functions.filter(
217
- f => allow.has(f.name) && !shouldExclude(f.name, false)
218
- );
219
- } else {
220
- moduleModel.functions = moduleModel.functions.filter(f => !shouldExclude(f.name, true));
221
- }
222
-
223
- if (Array.isArray(moduleConfig.classes)) {
224
- const allow = new Set(moduleConfig.classes.map(String));
225
- for (const requested of allow) {
226
- if (!moduleModel.classes.some(c => c.name === requested)) {
227
- warnings.push(`Module ${moduleKey}: configured class "${requested}" not found in IR`);
228
- }
229
- }
230
- moduleModel.classes = moduleModel.classes.filter(
231
- c => allow.has(c.name) && !shouldExclude(c.name, false)
232
- );
233
- } else {
234
- moduleModel.classes = moduleModel.classes.filter(c => !shouldExclude(c.name, true));
235
- }
236
-
237
- moduleModel.typeAliases = (moduleModel.typeAliases ?? []).filter(
238
- alias => !shouldExclude(alias.name, false)
239
- );
240
- }
345
+ filterModuleExports(moduleModel, moduleConfig, moduleKey, warnings);
241
346
 
242
347
  // Generate module code
243
348
  const annotatedJSDoc = Boolean(resolvedOptions.output?.annotatedJSDoc);
@@ -264,51 +369,10 @@ export async function generate(
264
369
  });
265
370
  }
266
371
 
267
- if (checkMode) {
268
- for (const file of filesToEmit) {
269
- const existing = await safeReadFile(file.path);
270
- if (existing === null) {
271
- outOfDate.push(file.path);
272
- continue;
273
- }
274
- if (normalizeForComparison(existing) !== normalizeForComparison(file.content)) {
275
- outOfDate.push(file.path);
276
- }
277
- }
278
- } else {
279
- for (const file of filesToEmit) {
280
- await fsUtils.writeFile(file.path, file.content);
281
- written.push(file.path);
282
- }
283
- }
372
+ await emitOrCompareFiles(filesToEmit, checkMode, written, outOfDate);
284
373
 
285
374
  // Emit warning summary of unknown typing constructs (best-effort)
286
- if (unknownTypeNamesCollector.size > 0) {
287
- const entries = Array.from(unknownTypeNamesCollector.entries()).sort((a, b) => b[1] - a[1]);
288
- const unkList = entries
289
- .slice(0, 25)
290
- .map(([n, c]) => `${n}:${c}`)
291
- .join(', ');
292
- warnings.push(
293
- `Module ${baseName}: unknown typing constructs encountered: ${unkList}${entries.length > 25 ? '…' : ''}`
294
- );
295
- // Write JSON report
296
- if (!checkMode) {
297
- try {
298
- const reportsDir = pathUtils.join('.tywrap', 'reports');
299
- await fsUtils.writeFile(
300
- pathUtils.join(reportsDir, `${baseName}.json`),
301
- JSON.stringify({
302
- module: baseName,
303
- unknowns: Object.fromEntries(entries),
304
- generatedAt: new Date().toISOString(),
305
- })
306
- );
307
- } catch {
308
- // ignore
309
- }
310
- }
311
- }
375
+ await reportUnknownTypes(baseName, unknownTypeNamesCollector, checkMode, warnings);
312
376
  }
313
377
 
314
378
  if (checkMode) {
@@ -318,6 +382,40 @@ export async function generate(
318
382
  return { written, warnings, failures };
319
383
  }
320
384
 
385
+ /**
386
+ * Run the IR extractor (`python <command...>`) and parse its JSON stdout.
387
+ *
388
+ * Returns the exec result alongside a parsed-IR outcome. On a zero exit code the
389
+ * stdout is JSON-parsed; on parse failure the supplied `parseErrorLabel` is used
390
+ * to build the error message. The caller is responsible for handling non-zero
391
+ * exit codes (e.g. the module-missing fallback), so a non-zero exit yields
392
+ * `ir: null` with no `error` set here.
393
+ */
394
+ async function execAndParseIr(
395
+ pythonPath: string,
396
+ command: string[],
397
+ execOptions: { timeoutMs?: number; env?: Record<string, string> },
398
+ parseErrorLabel: 'tywrap_ir output' | 'tywrap_ir fallback output'
399
+ ): Promise<{
400
+ result: { code: number; stdout: string; stderr: string };
401
+ ir: unknown | null;
402
+ error?: string;
403
+ }> {
404
+ const result = await processUtils.exec(pythonPath, command, execOptions);
405
+ if (result.code !== 0) {
406
+ return { result, ir: null };
407
+ }
408
+ try {
409
+ return { result, ir: JSON.parse(result.stdout) };
410
+ } catch {
411
+ return {
412
+ result,
413
+ ir: null,
414
+ error: `Failed to parse ${parseErrorLabel}. stderr: ${result.stderr.trim() || 'empty'}`,
415
+ };
416
+ }
417
+ }
418
+
321
419
  /**
322
420
  * Invoke the Python IR CLI to get JSON IR for a module.
323
421
  */
@@ -339,24 +437,19 @@ async function fetchPythonIr(
339
437
  .filter(Boolean)
340
438
  .join(delimiter);
341
439
  const env = mergedPyPath ? { PYTHONPATH: mergedPyPath } : undefined;
440
+ const execOptions = { timeoutMs: options.timeoutMs, env };
342
441
  try {
343
- const result = await processUtils.exec(
442
+ const primary = await execAndParseIr(
344
443
  pythonPath,
345
444
  ['-m', 'tywrap_ir', '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
346
- { timeoutMs: options.timeoutMs, env }
445
+ execOptions,
446
+ 'tywrap_ir output'
347
447
  );
348
- if (result.code === 0) {
349
- try {
350
- return { ir: JSON.parse(result.stdout) };
351
- } catch {
352
- return {
353
- ir: null,
354
- error: `Failed to parse tywrap_ir output. stderr: ${result.stderr.trim() || 'empty'}`,
355
- };
356
- }
448
+ if (primary.result.code === 0) {
449
+ return { ir: primary.ir, error: primary.error };
357
450
  }
358
451
 
359
- const stderrText = result.stderr.trim();
452
+ const stderrText = primary.result.stderr.trim();
360
453
  const isTywrapIrMissing =
361
454
  stderrText.includes('No module named') && stderrText.includes('tywrap_ir');
362
455
  if (!isTywrapIrMissing) {
@@ -381,25 +474,19 @@ async function fetchPythonIr(
381
474
  };
382
475
  }
383
476
 
384
- const fallback = await processUtils.exec(
477
+ const fallback = await execAndParseIr(
385
478
  pythonPath,
386
479
  [localMain, '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
387
- { timeoutMs: options.timeoutMs, env }
480
+ execOptions,
481
+ 'tywrap_ir fallback output'
388
482
  );
389
- if (fallback.code !== 0) {
390
- return {
391
- ir: null,
392
- error: `tywrap_ir failed. stderr: ${fallback.stderr.trim() || stderrText || 'empty'}`,
393
- };
394
- }
395
- try {
396
- return { ir: JSON.parse(fallback.stdout) };
397
- } catch {
483
+ if (fallback.result.code !== 0) {
398
484
  return {
399
485
  ir: null,
400
- error: `Failed to parse tywrap_ir fallback output. stderr: ${fallback.stderr.trim() || 'empty'}`,
486
+ error: `tywrap_ir failed. stderr: ${fallback.result.stderr.trim() || stderrText || 'empty'}`,
401
487
  };
402
488
  }
489
+ return { ir: fallback.ir, error: fallback.error };
403
490
  } catch (err) {
404
491
  return { ir: null, error: err instanceof Error ? err.message : String(err) };
405
492
  }
@@ -483,6 +570,9 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
483
570
  keywordOnly: p.kind === 'KEYWORD_ONLY',
484
571
  });
485
572
 
573
+ const mapMethodKind = (value: unknown): PythonFunction['methodKind'] =>
574
+ value === 'class' || value === 'static' ? value : 'instance';
575
+
486
576
  const mapFunc = (
487
577
  f: Record<string, unknown>,
488
578
  inheritedTypeParameters: readonly PythonGenericParameter[] = []
@@ -512,6 +602,7 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
512
602
  mapParam((v ?? {}) as Record<string, unknown>, annotationTypeParameters)
513
603
  )
514
604
  : [],
605
+ methodKind: mapMethodKind(f.method_kind),
515
606
  };
516
607
  };
517
608
 
@@ -538,6 +629,18 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
538
629
  };
539
630
  })
540
631
  : [],
632
+ accessors: Array.isArray(c.accessors)
633
+ ? (c.accessors as unknown[]).map(v => {
634
+ const a = (v ?? {}) as Record<string, unknown>;
635
+ return {
636
+ name: String(a.name ?? ''),
637
+ type: parseType(a.returns, classTypeParameters),
638
+ docstring: (a.docstring as string | undefined) ?? undefined,
639
+ readOnly: typeof a.read_only === 'boolean' ? a.read_only : undefined,
640
+ isCached: Boolean(a.is_cached),
641
+ };
642
+ })
643
+ : [],
541
644
  docstring: (c.docstring as string | undefined) ?? undefined,
542
645
  decorators: (c.typed_dict as boolean) ? ['__typed_dict__'] : [],
543
646
  kind: (c.typed_dict as boolean)