tywrap 0.6.0 → 0.7.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 (118) hide show
  1. package/dist/core/annotation-parser.d.ts.map +1 -1
  2. package/dist/core/annotation-parser.js +19 -14
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/discovery.d.ts +17 -0
  5. package/dist/core/discovery.d.ts.map +1 -1
  6. package/dist/core/discovery.js +59 -49
  7. package/dist/core/discovery.js.map +1 -1
  8. package/dist/core/generator.d.ts.map +1 -1
  9. package/dist/core/generator.js +40 -9
  10. package/dist/core/generator.js.map +1 -1
  11. package/dist/core/validation.d.ts +23 -0
  12. package/dist/core/validation.d.ts.map +1 -1
  13. package/dist/core/validation.js +52 -48
  14. package/dist/core/validation.js.map +1 -1
  15. package/dist/dev.d.ts.map +1 -1
  16. package/dist/dev.js +175 -133
  17. package/dist/dev.js.map +1 -1
  18. package/dist/runtime/base-bridge.d.ts +57 -0
  19. package/dist/runtime/base-bridge.d.ts.map +1 -0
  20. package/dist/runtime/base-bridge.js +72 -0
  21. package/dist/runtime/base-bridge.js.map +1 -0
  22. package/dist/runtime/bridge-codec.d.ts +15 -0
  23. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  24. package/dist/runtime/bridge-codec.js +45 -48
  25. package/dist/runtime/bridge-codec.js.map +1 -1
  26. package/dist/runtime/http-transport.d.ts +11 -1
  27. package/dist/runtime/http-transport.d.ts.map +1 -1
  28. package/dist/runtime/http-transport.js +19 -0
  29. package/dist/runtime/http-transport.js.map +1 -1
  30. package/dist/runtime/http.d.ts +5 -12
  31. package/dist/runtime/http.d.ts.map +1 -1
  32. package/dist/runtime/http.js +6 -29
  33. package/dist/runtime/http.js.map +1 -1
  34. package/dist/runtime/index.d.ts +1 -1
  35. package/dist/runtime/index.d.ts.map +1 -1
  36. package/dist/runtime/index.js.map +1 -1
  37. package/dist/runtime/node.d.ts +12 -19
  38. package/dist/runtime/node.d.ts.map +1 -1
  39. package/dist/runtime/node.js +14 -34
  40. package/dist/runtime/node.js.map +1 -1
  41. package/dist/runtime/pooled-transport.d.ts +21 -2
  42. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  43. package/dist/runtime/pooled-transport.js +16 -0
  44. package/dist/runtime/pooled-transport.js.map +1 -1
  45. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  47. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  48. package/dist/runtime/pyodide-transport.d.ts +12 -1
  49. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  50. package/dist/runtime/pyodide-transport.js +20 -0
  51. package/dist/runtime/pyodide-transport.js.map +1 -1
  52. package/dist/runtime/pyodide.d.ts +5 -12
  53. package/dist/runtime/pyodide.d.ts.map +1 -1
  54. package/dist/runtime/pyodide.js +6 -29
  55. package/dist/runtime/pyodide.js.map +1 -1
  56. package/dist/runtime/rpc-client.d.ts +32 -1
  57. package/dist/runtime/rpc-client.d.ts.map +1 -1
  58. package/dist/runtime/rpc-client.js +45 -24
  59. package/dist/runtime/rpc-client.js.map +1 -1
  60. package/dist/runtime/subprocess-transport.d.ts +26 -1
  61. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  62. package/dist/runtime/subprocess-transport.js +72 -35
  63. package/dist/runtime/subprocess-transport.js.map +1 -1
  64. package/dist/runtime/transport.d.ts +59 -0
  65. package/dist/runtime/transport.d.ts.map +1 -1
  66. package/dist/runtime/transport.js +1 -0
  67. package/dist/runtime/transport.js.map +1 -1
  68. package/dist/types/index.d.ts +35 -0
  69. package/dist/types/index.d.ts.map +1 -1
  70. package/dist/tywrap.d.ts +0 -5
  71. package/dist/tywrap.d.ts.map +1 -1
  72. package/dist/tywrap.js +212 -169
  73. package/dist/tywrap.js.map +1 -1
  74. package/dist/utils/cache.d.ts +11 -0
  75. package/dist/utils/cache.d.ts.map +1 -1
  76. package/dist/utils/cache.js +50 -58
  77. package/dist/utils/cache.js.map +1 -1
  78. package/dist/utils/codec.d.ts +2 -0
  79. package/dist/utils/codec.d.ts.map +1 -1
  80. package/dist/utils/codec.js +53 -2
  81. package/dist/utils/codec.js.map +1 -1
  82. package/dist/utils/python.d.ts.map +1 -1
  83. package/dist/utils/python.js +28 -17
  84. package/dist/utils/python.js.map +1 -1
  85. package/dist/utils/runtime.d.ts.map +1 -1
  86. package/dist/utils/runtime.js +22 -16
  87. package/dist/utils/runtime.js.map +1 -1
  88. package/dist/version.js +1 -1
  89. package/package.json +7 -1
  90. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  91. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  92. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  93. package/runtime/tywrap_bridge_core.py +55 -3
  94. package/src/core/annotation-parser.ts +23 -18
  95. package/src/core/discovery.ts +70 -54
  96. package/src/core/generator.ts +50 -11
  97. package/src/core/validation.ts +84 -48
  98. package/src/dev.ts +237 -153
  99. package/src/runtime/base-bridge.ts +106 -0
  100. package/src/runtime/bridge-codec.ts +58 -70
  101. package/src/runtime/http-transport.ts +21 -1
  102. package/src/runtime/http.ts +7 -51
  103. package/src/runtime/index.ts +1 -0
  104. package/src/runtime/node.ts +17 -52
  105. package/src/runtime/pooled-transport.ts +25 -2
  106. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  107. package/src/runtime/pyodide-transport.ts +22 -0
  108. package/src/runtime/pyodide.ts +7 -52
  109. package/src/runtime/rpc-client.ts +56 -32
  110. package/src/runtime/subprocess-transport.ts +89 -40
  111. package/src/runtime/transport.ts +71 -0
  112. package/src/types/index.ts +37 -0
  113. package/src/tywrap.ts +274 -187
  114. package/src/utils/cache.ts +59 -61
  115. package/src/utils/codec.ts +61 -4
  116. package/src/utils/python.ts +33 -20
  117. package/src/utils/runtime.ts +24 -13
  118. 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();
@@ -52,30 +53,6 @@ export async function tywrap(options: Partial<TywrapOptions> = {}): Promise<Tywr
52
53
  };
53
54
  }
54
55
 
55
- export default tywrap;
56
-
57
- /**
58
- * Append minimal tsd tests for a generated module (optional dev aid)
59
- */
60
- export async function emitTypeTestsForModule(
61
- moduleName: string,
62
- outDir = 'test-d/generated'
63
- ): Promise<void> {
64
- const { writeFile, mkdir } = await import('fs/promises');
65
- const { join } = await import('path');
66
- try {
67
- await mkdir(outDir, { recursive: true });
68
- } catch {}
69
- const filePath = join(outDir, `${moduleName}.test-d.ts`);
70
- const content = `import { expectType } from 'tsd';
71
- import * as mod from '../../generated/${moduleName}.generated.ts';
72
-
73
- // Opportunistic: ensure module namespace is an object
74
- expectType<Record<string, unknown>>(mod);
75
- `;
76
- await writeFile(filePath, content, 'utf-8');
77
- }
78
-
79
56
  export interface GenerateRunOptions {
80
57
  /**
81
58
  * If true, do not write files; instead compare generated output to what's on disk.
@@ -114,6 +91,195 @@ async function safeReadFile(path: string): Promise<string | null> {
114
91
  }
115
92
  }
116
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
+
117
283
  /**
118
284
  * Generate TypeScript wrappers for configured Python modules.
119
285
  * Minimal MVP implementation: resolves module file, analyzes, generates TS, writes to output.dir
@@ -131,7 +297,6 @@ export async function generate(
131
297
  const failures: GenerateFailure[] = [];
132
298
  const outputDir = resolvedOptions.output.dir;
133
299
  const caching = resolvedOptions.performance.caching;
134
- const cacheDir = '.tywrap/cache';
135
300
  const pythonPath = await resolvePythonExecutable({
136
301
  pythonPath: resolvedOptions.runtime?.node?.pythonPath,
137
302
  virtualEnv: resolvedOptions.runtime?.node?.virtualEnv,
@@ -155,29 +320,14 @@ export async function generate(
155
320
  unknownTypeNamesCollector = new Map();
156
321
  // Resolve module IR via the Python extractor (with optional cache)
157
322
  const cacheKey = await computeCacheKey(moduleKey, resolvedOptions);
158
- let ir: unknown | null = null;
159
- let irError: string | undefined;
160
- if (caching && fsUtils.isAvailable() && !checkMode) {
161
- try {
162
- const cached = await fsUtils.readFile(pathUtils.join(cacheDir, cacheKey));
163
- ir = JSON.parse(cached);
164
- } catch {
165
- ir = null;
166
- }
167
- }
168
- if (!ir) {
169
- const fetchResult = await fetchPythonIr(moduleKey, pythonPath, {
170
- timeoutMs: resolvedOptions.runtime?.node?.timeout,
171
- pythonImportPath: resolvedOptions.pythonImportPath,
172
- });
173
- ir = fetchResult.ir;
174
- irError = fetchResult.error;
175
- if (ir && caching && fsUtils.isAvailable() && !checkMode) {
176
- try {
177
- await fsUtils.writeFile(pathUtils.join(cacheDir, cacheKey), JSON.stringify(ir));
178
- } catch {}
179
- }
180
- }
323
+ const { ir, error: irError } = await fetchAndCacheIr(
324
+ moduleKey,
325
+ resolvedOptions,
326
+ pythonPath,
327
+ cacheKey,
328
+ caching,
329
+ checkMode
330
+ );
181
331
  if (!ir) {
182
332
  const message = `No IR produced for module ${moduleKey}${irError ? `: ${irError}` : ''}`;
183
333
  warnings.push(message);
@@ -192,76 +342,7 @@ export async function generate(
192
342
  const moduleModel = transformIrToTsModel(ir);
193
343
 
194
344
  // Apply module-level export filtering (functions/classes + excludes).
195
- {
196
- const builtInDefaultExcludes = new Set([
197
- 'dataclass',
198
- 'property',
199
- 'staticmethod',
200
- 'classmethod',
201
- 'abstractmethod',
202
- 'cached_property',
203
- ]);
204
-
205
- const excludeExact = new Set((moduleConfig.exclude ?? []).map(String));
206
- const excludeRegexes: RegExp[] = [];
207
- for (const pattern of moduleConfig.excludePatterns ?? []) {
208
- try {
209
- excludeRegexes.push(new RegExp(String(pattern)));
210
- } catch (err: unknown) {
211
- const message = err instanceof Error ? err.message : String(err);
212
- warnings.push(
213
- `Module ${moduleKey}: invalid excludePatterns regex "${String(pattern)}": ${message}`
214
- );
215
- }
216
- }
217
-
218
- const shouldExclude = (name: string, applyBuiltInDefaults: boolean): boolean => {
219
- if (excludeExact.has(name)) {
220
- return true;
221
- }
222
- if (excludeRegexes.some(r => r.test(name))) {
223
- return true;
224
- }
225
- if (applyBuiltInDefaults && builtInDefaultExcludes.has(name)) {
226
- return true;
227
- }
228
- return false;
229
- };
230
-
231
- if (Array.isArray(moduleConfig.functions)) {
232
- const allow = new Set(moduleConfig.functions.map(String));
233
- for (const requested of allow) {
234
- if (!moduleModel.functions.some(f => f.name === requested)) {
235
- warnings.push(
236
- `Module ${moduleKey}: configured function "${requested}" not found in IR`
237
- );
238
- }
239
- }
240
- moduleModel.functions = moduleModel.functions.filter(
241
- f => allow.has(f.name) && !shouldExclude(f.name, false)
242
- );
243
- } else {
244
- moduleModel.functions = moduleModel.functions.filter(f => !shouldExclude(f.name, true));
245
- }
246
-
247
- if (Array.isArray(moduleConfig.classes)) {
248
- const allow = new Set(moduleConfig.classes.map(String));
249
- for (const requested of allow) {
250
- if (!moduleModel.classes.some(c => c.name === requested)) {
251
- warnings.push(`Module ${moduleKey}: configured class "${requested}" not found in IR`);
252
- }
253
- }
254
- moduleModel.classes = moduleModel.classes.filter(
255
- c => allow.has(c.name) && !shouldExclude(c.name, false)
256
- );
257
- } else {
258
- moduleModel.classes = moduleModel.classes.filter(c => !shouldExclude(c.name, true));
259
- }
260
-
261
- moduleModel.typeAliases = (moduleModel.typeAliases ?? []).filter(
262
- alias => !shouldExclude(alias.name, false)
263
- );
264
- }
345
+ filterModuleExports(moduleModel, moduleConfig, moduleKey, warnings);
265
346
 
266
347
  // Generate module code
267
348
  const annotatedJSDoc = Boolean(resolvedOptions.output?.annotatedJSDoc);
@@ -288,51 +369,10 @@ export async function generate(
288
369
  });
289
370
  }
290
371
 
291
- if (checkMode) {
292
- for (const file of filesToEmit) {
293
- const existing = await safeReadFile(file.path);
294
- if (existing === null) {
295
- outOfDate.push(file.path);
296
- continue;
297
- }
298
- if (normalizeForComparison(existing) !== normalizeForComparison(file.content)) {
299
- outOfDate.push(file.path);
300
- }
301
- }
302
- } else {
303
- for (const file of filesToEmit) {
304
- await fsUtils.writeFile(file.path, file.content);
305
- written.push(file.path);
306
- }
307
- }
372
+ await emitOrCompareFiles(filesToEmit, checkMode, written, outOfDate);
308
373
 
309
374
  // Emit warning summary of unknown typing constructs (best-effort)
310
- if (unknownTypeNamesCollector.size > 0) {
311
- const entries = Array.from(unknownTypeNamesCollector.entries()).sort((a, b) => b[1] - a[1]);
312
- const unkList = entries
313
- .slice(0, 25)
314
- .map(([n, c]) => `${n}:${c}`)
315
- .join(', ');
316
- warnings.push(
317
- `Module ${baseName}: unknown typing constructs encountered: ${unkList}${entries.length > 25 ? '…' : ''}`
318
- );
319
- // Write JSON report
320
- if (!checkMode) {
321
- try {
322
- const reportsDir = pathUtils.join('.tywrap', 'reports');
323
- await fsUtils.writeFile(
324
- pathUtils.join(reportsDir, `${baseName}.json`),
325
- JSON.stringify({
326
- module: baseName,
327
- unknowns: Object.fromEntries(entries),
328
- generatedAt: new Date().toISOString(),
329
- })
330
- );
331
- } catch {
332
- // ignore
333
- }
334
- }
335
- }
375
+ await reportUnknownTypes(baseName, unknownTypeNamesCollector, checkMode, warnings);
336
376
  }
337
377
 
338
378
  if (checkMode) {
@@ -342,6 +382,40 @@ export async function generate(
342
382
  return { written, warnings, failures };
343
383
  }
344
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
+
345
419
  /**
346
420
  * Invoke the Python IR CLI to get JSON IR for a module.
347
421
  */
@@ -363,24 +437,27 @@ async function fetchPythonIr(
363
437
  .filter(Boolean)
364
438
  .join(delimiter);
365
439
  const env = mergedPyPath ? { PYTHONPATH: mergedPyPath } : undefined;
440
+ const execOptions = { timeoutMs: options.timeoutMs, env };
366
441
  try {
367
- const result = await processUtils.exec(
442
+ const primary = await execAndParseIr(
368
443
  pythonPath,
369
- ['-m', 'tywrap_ir', '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
370
- { timeoutMs: options.timeoutMs, env }
444
+ [
445
+ '-m',
446
+ 'tywrap_ir',
447
+ '--module',
448
+ moduleName,
449
+ '--ir-version',
450
+ TYWRAP_IR_VERSION,
451
+ '--no-pretty',
452
+ ],
453
+ execOptions,
454
+ 'tywrap_ir output'
371
455
  );
372
- if (result.code === 0) {
373
- try {
374
- return { ir: JSON.parse(result.stdout) };
375
- } catch {
376
- return {
377
- ir: null,
378
- error: `Failed to parse tywrap_ir output. stderr: ${result.stderr.trim() || 'empty'}`,
379
- };
380
- }
456
+ if (primary.result.code === 0) {
457
+ return { ir: primary.ir, error: primary.error };
381
458
  }
382
459
 
383
- const stderrText = result.stderr.trim();
460
+ const stderrText = primary.result.stderr.trim();
384
461
  const isTywrapIrMissing =
385
462
  stderrText.includes('No module named') && stderrText.includes('tywrap_ir');
386
463
  if (!isTywrapIrMissing) {
@@ -405,25 +482,19 @@ async function fetchPythonIr(
405
482
  };
406
483
  }
407
484
 
408
- const fallback = await processUtils.exec(
485
+ const fallback = await execAndParseIr(
409
486
  pythonPath,
410
487
  [localMain, '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
411
- { timeoutMs: options.timeoutMs, env }
488
+ execOptions,
489
+ 'tywrap_ir fallback output'
412
490
  );
413
- if (fallback.code !== 0) {
491
+ if (fallback.result.code !== 0) {
414
492
  return {
415
493
  ir: null,
416
- error: `tywrap_ir failed. stderr: ${fallback.stderr.trim() || stderrText || 'empty'}`,
417
- };
418
- }
419
- try {
420
- return { ir: JSON.parse(fallback.stdout) };
421
- } catch {
422
- return {
423
- ir: null,
424
- error: `Failed to parse tywrap_ir fallback output. stderr: ${fallback.stderr.trim() || 'empty'}`,
494
+ error: `tywrap_ir failed. stderr: ${fallback.result.stderr.trim() || stderrText || 'empty'}`,
425
495
  };
426
496
  }
497
+ return { ir: fallback.ir, error: fallback.error };
427
498
  } catch (err) {
428
499
  return { ir: null, error: err instanceof Error ? err.message : String(err) };
429
500
  }
@@ -507,6 +578,9 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
507
578
  keywordOnly: p.kind === 'KEYWORD_ONLY',
508
579
  });
509
580
 
581
+ const mapMethodKind = (value: unknown): PythonFunction['methodKind'] =>
582
+ value === 'class' || value === 'static' ? value : 'instance';
583
+
510
584
  const mapFunc = (
511
585
  f: Record<string, unknown>,
512
586
  inheritedTypeParameters: readonly PythonGenericParameter[] = []
@@ -536,6 +610,7 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
536
610
  mapParam((v ?? {}) as Record<string, unknown>, annotationTypeParameters)
537
611
  )
538
612
  : [],
613
+ methodKind: mapMethodKind(f.method_kind),
539
614
  };
540
615
  };
541
616
 
@@ -562,6 +637,18 @@ function transformIrToTsModel(ir: unknown): TSPythonModule {
562
637
  };
563
638
  })
564
639
  : [],
640
+ accessors: Array.isArray(c.accessors)
641
+ ? (c.accessors as unknown[]).map(v => {
642
+ const a = (v ?? {}) as Record<string, unknown>;
643
+ return {
644
+ name: String(a.name ?? ''),
645
+ type: parseType(a.returns, classTypeParameters),
646
+ docstring: (a.docstring as string | undefined) ?? undefined,
647
+ readOnly: typeof a.read_only === 'boolean' ? a.read_only : undefined,
648
+ isCached: Boolean(a.is_cached),
649
+ };
650
+ })
651
+ : [],
565
652
  docstring: (c.docstring as string | undefined) ?? undefined,
566
653
  decorators: (c.typed_dict as boolean) ? ['__typed_dict__'] : [],
567
654
  kind: (c.typed_dict as boolean)
@@ -149,75 +149,73 @@ export class ArtifactCache {
149
149
  hash.update(prefix);
150
150
  hash.update('\0');
151
151
 
152
- // Add inputs
152
+ // Add inputs. Each input contributes a type tag, an optional payload, and a
153
+ // boundary separator so that "1" vs 1 and ["a","bc"] vs ["ab","c"] never collide.
153
154
  for (const input of inputs) {
154
- // Why: disambiguate types so "1" and 1 don't collide, and keep input boundaries so
155
- // ["a", "bc"] doesn't collide with ["ab", "c"].
156
- if (input === undefined) {
157
- hash.update('undef:');
158
- hash.update('\0');
159
- continue;
155
+ const { tag, payload } = ArtifactCache.encodeKeyInput(input);
156
+ hash.update(tag);
157
+ if (payload !== undefined) {
158
+ hash.update(payload);
160
159
  }
161
- if (input === null) {
162
- hash.update('null:');
163
- hash.update('\0');
164
- continue;
165
- }
166
- if (typeof input === 'string') {
167
- hash.update('str:');
168
- hash.update(input);
169
- hash.update('\0');
170
- continue;
171
- }
172
- if (typeof input === 'number') {
173
- hash.update('num:');
174
- hash.update(String(input));
175
- hash.update('\0');
176
- continue;
177
- }
178
- if (typeof input === 'boolean') {
179
- hash.update('bool:');
180
- hash.update(input ? '1' : '0');
181
- hash.update('\0');
182
- continue;
183
- }
184
- if (typeof input === 'bigint') {
185
- hash.update('bigint:');
186
- hash.update(input.toString());
187
- hash.update('\0');
188
- continue;
189
- }
190
- if (Buffer.isBuffer(input)) {
191
- hash.update('buf:');
192
- hash.update(input);
193
- hash.update('\0');
194
- continue;
195
- }
196
-
197
- if (typeof input === 'symbol') {
198
- hash.update('sym:');
199
- hash.update(input.toString());
200
- hash.update('\0');
201
- continue;
202
- }
203
-
204
- hash.update('json:');
205
- let json: string;
206
- try {
207
- json =
208
- JSON.stringify(input, (_key, value) =>
209
- typeof value === 'symbol' ? value.toString() : value
210
- ) ?? 'undefined';
211
- } catch {
212
- json = String(input);
213
- }
214
- hash.update(json);
215
160
  hash.update('\0');
216
161
  }
217
162
 
218
163
  return hash.digest('hex').substring(0, 16); // Use first 16 chars for readability
219
164
  }
220
165
 
166
+ /**
167
+ * Encode a single key input as a type tag plus optional payload.
168
+ * Why: disambiguate types so "1" and 1 don't collide, and keep input boundaries so
169
+ * ["a", "bc"] doesn't collide with ["ab", "c"].
170
+ */
171
+ private static encodeKeyInput(input: unknown): {
172
+ tag: string;
173
+ payload?: string | Buffer;
174
+ } {
175
+ if (input === undefined) {
176
+ return { tag: 'undef:' };
177
+ }
178
+ if (input === null) {
179
+ return { tag: 'null:' };
180
+ }
181
+ if (typeof input === 'string') {
182
+ return { tag: 'str:', payload: input };
183
+ }
184
+ if (typeof input === 'number') {
185
+ return { tag: 'num:', payload: String(input) };
186
+ }
187
+ if (typeof input === 'boolean') {
188
+ return { tag: 'bool:', payload: input ? '1' : '0' };
189
+ }
190
+ if (typeof input === 'bigint') {
191
+ return { tag: 'bigint:', payload: input.toString() };
192
+ }
193
+ if (Buffer.isBuffer(input)) {
194
+ return { tag: 'buf:', payload: input };
195
+ }
196
+ if (typeof input === 'symbol') {
197
+ return { tag: 'sym:', payload: input.toString() };
198
+ }
199
+
200
+ return { tag: 'json:', payload: ArtifactCache.stringifyKeyInput(input) };
201
+ }
202
+
203
+ /**
204
+ * Stringify an arbitrary key input for hashing, normalizing symbols and
205
+ * falling back to String() when JSON serialization fails.
206
+ */
207
+ private static stringifyKeyInput(input: unknown): string {
208
+ try {
209
+ return (
210
+ JSON.stringify(input, (_key, value) =>
211
+ typeof value === 'symbol' ? value.toString() : value
212
+ ) ?? 'undefined'
213
+ );
214
+ } catch {
215
+ return String(input);
216
+ }
217
+ }
218
+
221
219
  /**
222
220
  * Get entry from cache with performance tracking
223
221
  */