tywrap 0.2.1 → 0.3.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 (138) hide show
  1. package/README.md +65 -31
  2. package/dist/cli.js +49 -2
  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 +43 -7
  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 +47 -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/index.d.ts +7 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +4 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/bridge-core.d.ts +1 -0
  33. package/dist/runtime/bridge-core.d.ts.map +1 -1
  34. package/dist/runtime/bridge-core.js +52 -17
  35. package/dist/runtime/bridge-core.js.map +1 -1
  36. package/dist/runtime/bridge-protocol.d.ts +17 -0
  37. package/dist/runtime/bridge-protocol.d.ts.map +1 -1
  38. package/dist/runtime/bridge-protocol.js +97 -0
  39. package/dist/runtime/bridge-protocol.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +10 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +9 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/http-io.d.ts.map +1 -1
  45. package/dist/runtime/http-io.js.map +1 -1
  46. package/dist/runtime/http.d.ts +1 -0
  47. package/dist/runtime/http.d.ts.map +1 -1
  48. package/dist/runtime/http.js +6 -0
  49. package/dist/runtime/http.js.map +1 -1
  50. package/dist/runtime/node.d.ts.map +1 -1
  51. package/dist/runtime/node.js +170 -32
  52. package/dist/runtime/node.js.map +1 -1
  53. package/dist/runtime/optimized-node.d.ts +1 -1
  54. package/dist/runtime/optimized-node.d.ts.map +1 -1
  55. package/dist/runtime/optimized-node.js +1 -1
  56. package/dist/runtime/optimized-node.js.map +1 -1
  57. package/dist/runtime/pooled-transport.d.ts +4 -0
  58. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  59. package/dist/runtime/pooled-transport.js +2 -0
  60. package/dist/runtime/pooled-transport.js.map +1 -1
  61. package/dist/runtime/process-io.d.ts +5 -0
  62. package/dist/runtime/process-io.d.ts.map +1 -1
  63. package/dist/runtime/process-io.js +64 -30
  64. package/dist/runtime/process-io.js.map +1 -1
  65. package/dist/runtime/pyodide-io.d.ts +4 -5
  66. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  67. package/dist/runtime/pyodide-io.js +99 -37
  68. package/dist/runtime/pyodide-io.js.map +1 -1
  69. package/dist/runtime/safe-codec.d.ts +25 -3
  70. package/dist/runtime/safe-codec.d.ts.map +1 -1
  71. package/dist/runtime/safe-codec.js +231 -57
  72. package/dist/runtime/safe-codec.js.map +1 -1
  73. package/dist/runtime/transport.d.ts +6 -1
  74. package/dist/runtime/transport.d.ts.map +1 -1
  75. package/dist/runtime/transport.js.map +1 -1
  76. package/dist/runtime/validators.d.ts +0 -11
  77. package/dist/runtime/validators.d.ts.map +1 -1
  78. package/dist/runtime/validators.js +13 -3
  79. package/dist/runtime/validators.js.map +1 -1
  80. package/dist/runtime/worker-pool.d.ts +12 -0
  81. package/dist/runtime/worker-pool.d.ts.map +1 -1
  82. package/dist/runtime/worker-pool.js +53 -5
  83. package/dist/runtime/worker-pool.js.map +1 -1
  84. package/dist/types/index.d.ts +51 -1
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/dist/tywrap.d.ts.map +1 -1
  87. package/dist/tywrap.js +227 -328
  88. package/dist/tywrap.js.map +1 -1
  89. package/dist/utils/cache.d.ts.map +1 -1
  90. package/dist/utils/cache.js +53 -5
  91. package/dist/utils/cache.js.map +1 -1
  92. package/dist/utils/codec.d.ts.map +1 -1
  93. package/dist/utils/codec.js +10 -16
  94. package/dist/utils/codec.js.map +1 -1
  95. package/dist/utils/ir-cache.d.ts +28 -0
  96. package/dist/utils/ir-cache.d.ts.map +1 -0
  97. package/dist/utils/ir-cache.js +29 -0
  98. package/dist/utils/ir-cache.js.map +1 -0
  99. package/dist/utils/parallel-processor.d.ts +1 -1
  100. package/dist/utils/parallel-processor.d.ts.map +1 -1
  101. package/dist/utils/parallel-processor.js +111 -40
  102. package/dist/utils/parallel-processor.js.map +1 -1
  103. package/dist/utils/runtime.d.ts +5 -1
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +112 -18
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/package.json +40 -18
  108. package/runtime/python_bridge.py +78 -12
  109. package/src/cli.ts +61 -2
  110. package/src/config/index.ts +63 -17
  111. package/src/core/analyzer.ts +52 -15
  112. package/src/core/annotation-parser.ts +500 -0
  113. package/src/core/discovery.ts +26 -12
  114. package/src/core/generator.ts +956 -116
  115. package/src/core/mapper.ts +158 -51
  116. package/src/index.ts +34 -5
  117. package/src/runtime/bridge-core.ts +58 -18
  118. package/src/runtime/bridge-protocol.ts +164 -0
  119. package/src/runtime/errors.ts +14 -0
  120. package/src/runtime/http-io.ts +2 -1
  121. package/src/runtime/http.ts +7 -0
  122. package/src/runtime/node.ts +264 -44
  123. package/src/runtime/optimized-node.ts +4 -1
  124. package/src/runtime/pooled-transport.ts +12 -1
  125. package/src/runtime/process-io.ts +83 -55
  126. package/src/runtime/pyodide-io.ts +110 -44
  127. package/src/runtime/safe-codec.ts +290 -82
  128. package/src/runtime/transport.ts +6 -1
  129. package/src/runtime/validators.ts +14 -3
  130. package/src/runtime/worker-pool.ts +67 -6
  131. package/src/types/global.d.ts +11 -1
  132. package/src/types/index.ts +63 -0
  133. package/src/tywrap.ts +279 -358
  134. package/src/utils/cache.ts +59 -5
  135. package/src/utils/codec.ts +11 -15
  136. package/src/utils/ir-cache.ts +51 -0
  137. package/src/utils/parallel-processor.ts +119 -43
  138. package/src/utils/runtime.ts +129 -18
@@ -26,6 +26,8 @@ import { getComponentLogger } from '../utils/logger.js';
26
26
  const log = getComponentLogger('Analyzer');
27
27
 
28
28
  const UNKNOWN_TYPE: PythonType = { kind: 'custom', name: 'Any', module: 'typing' };
29
+ // tree-sitter-python >=0.25 updates its typings, but Parser#setLanguage still expects the package export at runtime.
30
+ const PYTHON_LANGUAGE = Python as unknown as Parser.Language;
29
31
 
30
32
  export class PyAnalyzer {
31
33
  private parser: Parser;
@@ -44,7 +46,7 @@ export class PyAnalyzer {
44
46
  }
45
47
 
46
48
  try {
47
- await this.parser.setLanguage(Python);
49
+ await this.parser.setLanguage(PYTHON_LANGUAGE);
48
50
  this.initialized = true;
49
51
  } catch (error) {
50
52
  throw new Error(`Failed to initialize Python parser: ${error}`);
@@ -141,7 +143,7 @@ export class PyAnalyzer {
141
143
  async extractFunctions(node: SyntaxNode): Promise<PythonFunction[]> {
142
144
  const functions: PythonFunction[] = [];
143
145
 
144
- const functionNodes = this.findNodesByType(node, 'function_definition');
146
+ const functionNodes = this.findDirectDefinitions(node, 'function_definition');
145
147
 
146
148
  for (const funcNode of functionNodes) {
147
149
  try {
@@ -161,7 +163,7 @@ export class PyAnalyzer {
161
163
  async extractClasses(node: SyntaxNode): Promise<PythonClass[]> {
162
164
  const classes: PythonClass[] = [];
163
165
 
164
- const classNodes = this.findNodesByType(node, 'class_definition');
166
+ const classNodes = this.findDirectDefinitions(node, 'class_definition');
165
167
 
166
168
  for (const classNode of classNodes) {
167
169
  try {
@@ -238,7 +240,7 @@ export class PyAnalyzer {
238
240
  const properties: Property[] = [];
239
241
 
240
242
  if (bodyNode) {
241
- const methodNodes = this.findNodesByType(bodyNode, 'function_definition');
243
+ const methodNodes = this.findDirectDefinitions(bodyNode, 'function_definition');
242
244
  for (const methodNode of methodNodes) {
243
245
  try {
244
246
  const method = await this.extractFunction(methodNode);
@@ -249,9 +251,18 @@ export class PyAnalyzer {
249
251
  }
250
252
 
251
253
  // Extract properties (assignments with type annotations or @property decorators)
252
- const assignmentNodes = this.findNodesByType(bodyNode, 'assignment');
253
- for (const assignNode of assignmentNodes) {
254
- const prop = this.extractProperty(assignNode);
254
+ for (const stmt of bodyNode.namedChildren) {
255
+ // Only consider class-body statements, never descend into method bodies.
256
+ const assignmentNode =
257
+ stmt.type === 'assignment'
258
+ ? stmt
259
+ : stmt.type === 'expression_statement'
260
+ ? (stmt.namedChildren.find(c => c.type === 'assignment') ?? null)
261
+ : null;
262
+ if (!assignmentNode) {
263
+ continue;
264
+ }
265
+ const prop = this.extractProperty(assignmentNode);
255
266
  if (prop) {
256
267
  properties.push(prop);
257
268
  }
@@ -641,21 +652,45 @@ export class PyAnalyzer {
641
652
  */
642
653
  private findNodesByType(node: SyntaxNode, type: string): SyntaxNode[] {
643
654
  const results: SyntaxNode[] = [];
644
-
645
- const traverse = (n: SyntaxNode): void => {
655
+ const stack: SyntaxNode[] = [node];
656
+ while (stack.length > 0) {
657
+ const n = stack.pop();
658
+ if (!n) {
659
+ break;
660
+ }
646
661
  if (n.type === type) {
647
662
  results.push(n);
648
663
  }
649
-
650
- for (const child of n.children) {
651
- traverse(child);
664
+ for (let i = n.children.length - 1; i >= 0; i--) {
665
+ const child = n.children[i];
666
+ if (child) {
667
+ stack.push(child);
668
+ }
652
669
  }
653
- };
654
-
655
- traverse(node);
670
+ }
656
671
  return results;
657
672
  }
658
673
 
674
+ private findDirectDefinitions(
675
+ node: SyntaxNode,
676
+ type: 'function_definition' | 'class_definition'
677
+ ): SyntaxNode[] {
678
+ const out: SyntaxNode[] = [];
679
+ for (const child of node.namedChildren) {
680
+ if (child.type === type) {
681
+ out.push(child);
682
+ continue;
683
+ }
684
+ if (child.type === 'decorated_definition') {
685
+ const def = child.namedChildren.find(c => c.type === type) ?? null;
686
+ if (def) {
687
+ out.push(def);
688
+ }
689
+ }
690
+ }
691
+ return out;
692
+ }
693
+
659
694
  private extractModuleName(path?: string): string {
660
695
  if (!path) {
661
696
  return 'unknown';
@@ -683,6 +718,8 @@ export class PyAnalyzer {
683
718
  const expr = firstChild.child(0);
684
719
  if (expr?.type === 'string') {
685
720
  let docstring = expr.text;
721
+ // Strip docstring prefixes like r/u/f/b and combos (rf/fr/br/...).
722
+ docstring = docstring.replace(/^(?:[rRuUbBfF]{1,2})(?=(\"\"\"|'''|\"|'))/, '');
686
723
  // Remove outer quotes and any remaining inner quotes
687
724
  if (docstring.startsWith('"""') && docstring.endsWith('"""')) {
688
725
  docstring = docstring.slice(3, -3);
@@ -0,0 +1,500 @@
1
+ import type { PythonGenericParameter, PythonType } from '../types/index.js';
2
+
3
+ export interface AnnotationParserOptions {
4
+ onUnknownTypeName?: (name: string) => void;
5
+ knownTypeVarNames?: Iterable<string>;
6
+ typeParameters?: readonly PythonGenericParameter[];
7
+ }
8
+
9
+ export function parseAnnotationToPythonType(
10
+ annotation: unknown,
11
+ options: AnnotationParserOptions = {}
12
+ ): PythonType {
13
+ const onUnknownTypeName = options.onUnknownTypeName;
14
+ const knownTypeVarNames = new Set(options.knownTypeVarNames ?? []);
15
+ const knownTypeParameters = new Map(
16
+ (options.typeParameters ?? []).map(param => [param.name, param] as const)
17
+ );
18
+ const modulePrefixes = ['', 'typing.', 'typing_extensions.', 'collections.abc.'] as const;
19
+
20
+ const unknownType = (): PythonType => ({ kind: 'custom', name: 'Any', module: 'typing' });
21
+
22
+ const recordUnknown = (name: string): void => {
23
+ try {
24
+ onUnknownTypeName?.(name);
25
+ } catch {
26
+ // ignore diagnostics hooks
27
+ }
28
+ };
29
+
30
+ const mapLiteral = (text: string): PythonType => {
31
+ const t = text.trim();
32
+ if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
33
+ return { kind: 'literal', value: t.slice(1, -1) } as PythonType;
34
+ }
35
+ if (t === 'True' || t === 'False') {
36
+ return { kind: 'literal', value: t === 'True' } as PythonType;
37
+ }
38
+ if (t === 'None') {
39
+ return { kind: 'literal', value: null } as PythonType;
40
+ }
41
+ const num = Number(t);
42
+ if (t !== '' && !Number.isNaN(num)) {
43
+ return { kind: 'literal', value: num } as PythonType;
44
+ }
45
+ return { kind: 'custom', name: t } as PythonType;
46
+ };
47
+
48
+ const mapKnownTypeParameter = (name: string): PythonType | null => {
49
+ const normalized = name.replace(/^~/, '').trim();
50
+ const param = knownTypeParameters.get(normalized);
51
+ if (!param) {
52
+ return null;
53
+ }
54
+ switch (param.kind) {
55
+ case 'typevar':
56
+ return {
57
+ kind: 'typevar',
58
+ name: param.name,
59
+ bound: param.bound,
60
+ constraints: param.constraints,
61
+ variance: param.variance,
62
+ } satisfies PythonType;
63
+ case 'paramspec':
64
+ return { kind: 'paramspec', name: param.name } satisfies PythonType;
65
+ case 'typevartuple':
66
+ return { kind: 'typevartuple', name: param.name } satisfies PythonType;
67
+ }
68
+ };
69
+
70
+ const mapSimpleName = (name: string): PythonType => {
71
+ const n = name.replace(/^(typing\.|typing_extensions\.|collections\.abc\.)/, '').trim();
72
+ const known = mapKnownTypeParameter(n);
73
+ if (known) {
74
+ return known;
75
+ }
76
+ if (knownTypeVarNames.has(n)) {
77
+ return { kind: 'typevar', name: n };
78
+ }
79
+
80
+ if (n === 'int' || n === 'float' || n === 'str' || n === 'bool' || n === 'bytes') {
81
+ return { kind: 'primitive', name: n };
82
+ }
83
+
84
+ if (
85
+ n === 'Any' ||
86
+ n === 'Never' ||
87
+ n === 'LiteralString' ||
88
+ n === 'ClassVar' ||
89
+ n === 'Final' ||
90
+ n === 'TypeAlias' ||
91
+ n === 'Required' ||
92
+ n === 'NotRequired'
93
+ ) {
94
+ recordUnknown(n);
95
+ }
96
+
97
+ if (n === 'None' || n.toLowerCase() === 'nonetype') {
98
+ return { kind: 'primitive', name: 'None' };
99
+ }
100
+ if (n === 'list' || n === 'List') {
101
+ return { kind: 'collection', name: 'list', itemTypes: [] };
102
+ }
103
+ if (n === 'dict' || n === 'Dict') {
104
+ return { kind: 'collection', name: 'dict', itemTypes: [] };
105
+ }
106
+ if (n === 'tuple' || n === 'Tuple') {
107
+ return { kind: 'collection', name: 'tuple', itemTypes: [] };
108
+ }
109
+ if (n === 'set' || n === 'Set') {
110
+ return { kind: 'collection', name: 'set', itemTypes: [] };
111
+ }
112
+ if (n === 'frozenset' || n === 'FrozenSet') {
113
+ return { kind: 'collection', name: 'frozenset', itemTypes: [] };
114
+ }
115
+ if (n.startsWith('~')) {
116
+ return { kind: 'typevar', name: n.slice(1) };
117
+ }
118
+ return { kind: 'custom', name: n };
119
+ };
120
+
121
+ const parseTypingFactoryName = (text: string, name: string): string | null => {
122
+ for (const prefix of modulePrefixes) {
123
+ const start = `${prefix}${name}(`;
124
+ if (!text.startsWith(start) || !text.endsWith(')')) {
125
+ continue;
126
+ }
127
+
128
+ const inner = text.slice(start.length, -1).trim();
129
+ if (inner.length < 2) {
130
+ return null;
131
+ }
132
+
133
+ const quote = inner[0];
134
+ if ((quote !== "'" && quote !== '"') || inner[inner.length - 1] !== quote) {
135
+ return null;
136
+ }
137
+
138
+ const commaIndex = inner.indexOf(',');
139
+ const quoted = commaIndex === -1 ? inner : inner.slice(0, commaIndex).trimEnd();
140
+ if (quoted.length < 2 || quoted[quoted.length - 1] !== quote) {
141
+ return null;
142
+ }
143
+
144
+ return quoted.slice(1, -1);
145
+ }
146
+
147
+ return null;
148
+ };
149
+
150
+ const splitQualifiedName = (raw: string): { name: string; module?: string } => {
151
+ const trimmed = raw.trim();
152
+ const parts = trimmed.split('.').filter(Boolean);
153
+ if (parts.length <= 1) {
154
+ return { name: trimmed };
155
+ }
156
+ const name = parts[parts.length - 1] ?? trimmed;
157
+ return { name, module: parts.slice(0, -1).join('.') || undefined };
158
+ };
159
+
160
+ const normalizeCollectionName = (
161
+ raw: string
162
+ ): { name: 'list' | 'dict' | 'tuple' | 'set' | 'frozenset'; inner?: string } | null => {
163
+ const m = raw.match(
164
+ /^(typing\.|typing_extensions\.)?(List|Dict|Tuple|Set|FrozenSet|list|dict|tuple|set|frozenset)\[(.*)\]$/
165
+ );
166
+ if (!m) {
167
+ return null;
168
+ }
169
+ const nameRaw = String(m[2] ?? '');
170
+ const nameLower = nameRaw.toLowerCase();
171
+ const name =
172
+ nameLower === 'list'
173
+ ? 'list'
174
+ : nameLower === 'dict'
175
+ ? 'dict'
176
+ : nameLower === 'tuple'
177
+ ? 'tuple'
178
+ : nameLower === 'frozenset'
179
+ ? 'frozenset'
180
+ : 'set';
181
+ return { name, inner: String(m[3] ?? '') };
182
+ };
183
+
184
+ const splitTopLevel = (input: string, sep: '|' | ','): string[] => {
185
+ const results: string[] = [];
186
+ let level = 0;
187
+ let cur = '';
188
+ let inQuote: "'" | '"' | null = null;
189
+ let escaped = false;
190
+ let guard = 0;
191
+
192
+ for (let i = 0; i < input.length; i++) {
193
+ guard++;
194
+ if (guard > 20000) {
195
+ if (cur.trim()) {
196
+ results.push(cur.trim());
197
+ }
198
+ break;
199
+ }
200
+
201
+ const ch = input.charAt(i);
202
+
203
+ if (escaped) {
204
+ cur += ch;
205
+ escaped = false;
206
+ continue;
207
+ }
208
+
209
+ if (inQuote) {
210
+ cur += ch;
211
+ if (ch === '\\') {
212
+ escaped = true;
213
+ continue;
214
+ }
215
+ if (ch === inQuote) {
216
+ inQuote = null;
217
+ }
218
+ continue;
219
+ }
220
+
221
+ if (ch === "'" || ch === '"') {
222
+ inQuote = ch;
223
+ cur += ch;
224
+ continue;
225
+ }
226
+
227
+ if (ch === '[' || ch === '(') {
228
+ level++;
229
+ } else if (ch === ']' || ch === ')') {
230
+ level = Math.max(0, level - 1);
231
+ }
232
+
233
+ if (level === 0 && ch === sep) {
234
+ results.push(cur.trim());
235
+ cur = '';
236
+ continue;
237
+ }
238
+
239
+ cur += ch;
240
+ }
241
+
242
+ if (cur.trim()) {
243
+ results.push(cur.trim());
244
+ }
245
+ return results;
246
+ };
247
+
248
+ const splitGenericInvocation = (raw: string): { name: string; inner: string } | null => {
249
+ if (!raw.endsWith(']')) {
250
+ return null;
251
+ }
252
+ const bracketStart = raw.indexOf('[');
253
+ if (bracketStart <= 0) {
254
+ return null;
255
+ }
256
+
257
+ let depth = 0;
258
+ for (let i = bracketStart; i < raw.length; i++) {
259
+ const ch = raw.charAt(i);
260
+ if (ch === '[') {
261
+ depth++;
262
+ } else if (ch === ']') {
263
+ depth--;
264
+ if (depth === 0 && i !== raw.length - 1) {
265
+ return null;
266
+ }
267
+ }
268
+ }
269
+
270
+ if (depth !== 0) {
271
+ return null;
272
+ }
273
+
274
+ return {
275
+ name: raw.slice(0, bracketStart).trim(),
276
+ inner: raw.slice(bracketStart + 1, -1),
277
+ };
278
+ };
279
+
280
+ const parse = (ann: unknown, depth = 0): PythonType => {
281
+ if (ann === null || ann === undefined) {
282
+ return unknownType();
283
+ }
284
+ if (depth > 100) {
285
+ return unknownType();
286
+ }
287
+ const rawText = String(ann).trim();
288
+ const raw = rawText.startsWith('~') ? rawText.slice(1).trim() : rawText;
289
+ if (raw === '') {
290
+ return unknownType();
291
+ }
292
+
293
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw) && rawText.startsWith('~')) {
294
+ return mapKnownTypeParameter(raw) ?? { kind: 'typevar', name: raw };
295
+ }
296
+
297
+ const paramspecArgsMatch = rawText.match(/^~?([A-Za-z_][A-Za-z0-9_]*)\.(args|kwargs)$/);
298
+ if (paramspecArgsMatch?.[1] && paramspecArgsMatch[2]) {
299
+ const baseName = paramspecArgsMatch[1];
300
+ const known = mapKnownTypeParameter(baseName);
301
+ if (known?.kind === 'paramspec' || rawText.startsWith('~')) {
302
+ return paramspecArgsMatch[2] === 'args'
303
+ ? ({ kind: 'paramspec_args', name: baseName } satisfies PythonType)
304
+ : ({ kind: 'paramspec_kwargs', name: baseName } satisfies PythonType);
305
+ }
306
+ }
307
+
308
+ const builtInClassMatch = raw.match(/^<class ['"][^'"]+['"]>$/);
309
+ if (builtInClassMatch) {
310
+ const inner = (raw.match(/^<class ['"]([^'"]+)['"]>$/) ?? [])[1] ?? '';
311
+ const name = (inner.split('.').pop() ?? '').toString();
312
+ return mapSimpleName(name);
313
+ }
314
+
315
+ if (raw.includes('|')) {
316
+ const parts = splitTopLevel(raw, '|');
317
+ if (parts.length > 1) {
318
+ const types = parts.map(p => parse(p.trim(), depth + 1));
319
+ return { kind: 'union', types };
320
+ }
321
+ }
322
+
323
+ if (
324
+ raw.startsWith('typing.Union[') ||
325
+ raw.startsWith('typing_extensions.Union[') ||
326
+ raw.startsWith('Union[')
327
+ ) {
328
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
329
+ const parts = splitTopLevel(inner, ',');
330
+ const types = parts.map(p => parse(p.trim(), depth + 1));
331
+ return { kind: 'union', types };
332
+ }
333
+
334
+ if (
335
+ raw.startsWith('typing.Optional[') ||
336
+ raw.startsWith('typing_extensions.Optional[') ||
337
+ raw.startsWith('Optional[')
338
+ ) {
339
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
340
+ return { kind: 'optional', type: parse(inner, depth + 1) };
341
+ }
342
+
343
+ if (
344
+ raw.startsWith('typing.Literal[') ||
345
+ raw.startsWith('typing_extensions.Literal[') ||
346
+ raw.startsWith('Literal[')
347
+ ) {
348
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
349
+ const parts = splitTopLevel(inner, ',');
350
+ if (parts.length === 1) {
351
+ return mapLiteral(String(parts[0] ?? '').trim());
352
+ }
353
+ return { kind: 'union', types: parts.map(p => mapLiteral(String(p).trim())) };
354
+ }
355
+
356
+ const extMatch = raw.match(
357
+ /^(typing\.|typing_extensions\.)?(ClassVar|Final|Required|NotRequired)\[(.*)\]$/
358
+ );
359
+ if (extMatch) {
360
+ return parse(extMatch[3] ?? '', depth + 1);
361
+ }
362
+
363
+ const typeVarName = parseTypingFactoryName(rawText, 'TypeVar');
364
+ if (typeVarName) {
365
+ return mapKnownTypeParameter(typeVarName) ?? { kind: 'typevar', name: typeVarName };
366
+ }
367
+
368
+ const paramSpecName = parseTypingFactoryName(rawText, 'ParamSpec');
369
+ if (paramSpecName) {
370
+ return mapKnownTypeParameter(paramSpecName) ?? { kind: 'paramspec', name: paramSpecName };
371
+ }
372
+
373
+ const typeVarTupleName = parseTypingFactoryName(rawText, 'TypeVarTuple');
374
+ if (typeVarTupleName) {
375
+ return (
376
+ mapKnownTypeParameter(typeVarTupleName) ?? {
377
+ kind: 'typevartuple',
378
+ name: typeVarTupleName,
379
+ }
380
+ );
381
+ }
382
+ if (
383
+ raw === 'typing.LiteralString' ||
384
+ raw === 'typing_extensions.LiteralString' ||
385
+ raw === 'LiteralString'
386
+ ) {
387
+ return { kind: 'primitive', name: 'str' };
388
+ }
389
+
390
+ if (
391
+ raw.startsWith('typing.Callable[') ||
392
+ raw.startsWith('typing_extensions.Callable[') ||
393
+ raw.startsWith('Callable[')
394
+ ) {
395
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
396
+ const parts = splitTopLevel(inner, ',');
397
+ if (parts.length >= 2) {
398
+ const paramsPart = (parts[0] ?? '').trim();
399
+ const returnPart = parts.slice(1).join(',').trim();
400
+ const returnType = parse(returnPart, depth + 1);
401
+
402
+ if (paramsPart === '...' || paramsPart === 'Ellipsis') {
403
+ return {
404
+ kind: 'callable',
405
+ parameters: [{ kind: 'custom', name: '...' }],
406
+ returnType,
407
+ };
408
+ }
409
+
410
+ const directParamSpec = parse(paramsPart, depth + 1);
411
+ if (directParamSpec.kind === 'paramspec') {
412
+ return {
413
+ kind: 'callable',
414
+ parameters: [],
415
+ parameterSpec: directParamSpec,
416
+ returnType,
417
+ };
418
+ }
419
+
420
+ const paramInner =
421
+ paramsPart.startsWith('[') && paramsPart.endsWith(']') ? paramsPart.slice(1, -1) : '';
422
+ const trimmed = paramInner.trim();
423
+ if (trimmed === '...' || trimmed === 'Ellipsis') {
424
+ return {
425
+ kind: 'callable',
426
+ parameters: [{ kind: 'custom', name: '...' }],
427
+ returnType,
428
+ };
429
+ }
430
+
431
+ const parameters = trimmed
432
+ ? splitTopLevel(trimmed, ',').map(p => parse(p.trim(), depth + 1))
433
+ : [];
434
+ return { kind: 'callable', parameters, returnType };
435
+ }
436
+ }
437
+
438
+ if (
439
+ raw.startsWith('typing.Mapping[') ||
440
+ raw.startsWith('typing_extensions.Mapping[') ||
441
+ raw.startsWith('Mapping[')
442
+ ) {
443
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
444
+ const parts = splitTopLevel(inner, ',');
445
+ return {
446
+ kind: 'collection',
447
+ name: 'dict',
448
+ itemTypes: parts.map(p => parse(p.trim(), depth + 1)),
449
+ };
450
+ }
451
+
452
+ if (
453
+ raw.startsWith('typing.Annotated[') ||
454
+ raw.startsWith('typing_extensions.Annotated[') ||
455
+ raw.startsWith('Annotated[')
456
+ ) {
457
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
458
+ const parts = splitTopLevel(inner, ',');
459
+ if (parts.length > 0) {
460
+ return {
461
+ kind: 'annotated',
462
+ base: parse(parts[0] ?? '', depth + 1),
463
+ metadata: parts.slice(1).map(p => String(p).trim()),
464
+ };
465
+ }
466
+ }
467
+
468
+ if (
469
+ raw.startsWith('typing.Unpack[') ||
470
+ raw.startsWith('typing_extensions.Unpack[') ||
471
+ raw.startsWith('Unpack[')
472
+ ) {
473
+ const inner = raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
474
+ return { kind: 'unpack', type: parse(inner, depth + 1) };
475
+ }
476
+
477
+ const coll = normalizeCollectionName(raw);
478
+ if (coll) {
479
+ const itemParts = splitTopLevel(coll.inner ?? '', ',');
480
+ const itemTypes = (coll.inner ? itemParts : []).map(p => parse(p.trim(), depth + 1));
481
+ return { kind: 'collection', name: coll.name, itemTypes };
482
+ }
483
+
484
+ const generic = splitGenericInvocation(raw);
485
+ if (generic) {
486
+ const typeArgs = splitTopLevel(generic.inner, ',').map(part => parse(part.trim(), depth + 1));
487
+ const qualified = splitQualifiedName(generic.name);
488
+ return {
489
+ kind: 'generic',
490
+ name: qualified.name,
491
+ module: qualified.module,
492
+ typeArgs,
493
+ };
494
+ }
495
+
496
+ return mapSimpleName(raw);
497
+ };
498
+
499
+ return parse(annotation, 0);
500
+ }
@@ -26,6 +26,7 @@ export interface DiscoveryOptions {
26
26
  searchPaths?: string[];
27
27
  excludePatterns?: string[];
28
28
  includeStdLib?: boolean;
29
+ timeoutMs?: number;
29
30
  }
30
31
 
31
32
  export class ModuleDiscovery {
@@ -39,6 +40,13 @@ export class ModuleDiscovery {
39
40
  includeStdLib: false,
40
41
  excludePatterns: ['__pycache__', '*.pyc', '.git', '.svn'],
41
42
  ...options,
43
+ // Avoid "no timeout by default" for subprocess discovery paths.
44
+ timeoutMs:
45
+ typeof options.timeoutMs === 'number' &&
46
+ Number.isFinite(options.timeoutMs) &&
47
+ options.timeoutMs > 0
48
+ ? options.timeoutMs
49
+ : 30000,
42
50
  };
43
51
  }
44
52
 
@@ -89,10 +97,14 @@ export class ModuleDiscovery {
89
97
  if (processUtils.isAvailable()) {
90
98
  try {
91
99
  const pythonPath = await this.getPythonExecutable();
92
- const result = await processUtils.exec(pythonPath, [
93
- '-c',
94
- `import ${moduleName}; print(${moduleName}.__file__ if hasattr(${moduleName}, '__file__') else '${moduleName}.__path__[0]' if hasattr(${moduleName}, '__path__') else 'builtin')`,
95
- ]);
100
+ const result = await processUtils.exec(
101
+ pythonPath,
102
+ [
103
+ '-c',
104
+ `import ${moduleName}; print(${moduleName}.__file__ if hasattr(${moduleName}, '__file__') else '${moduleName}.__path__[0]' if hasattr(${moduleName}, '__path__') else 'builtin')`,
105
+ ],
106
+ { timeoutMs: this.options.timeoutMs }
107
+ );
96
108
 
97
109
  if (result.code === 0 && result.stdout.trim() !== 'builtin') {
98
110
  const modulePath = result.stdout.trim();
@@ -297,10 +309,11 @@ export class ModuleDiscovery {
297
309
  if (processUtils.isAvailable()) {
298
310
  try {
299
311
  const pythonPath = await this.getPythonExecutable();
300
- const result = await processUtils.exec(pythonPath, [
301
- '-c',
302
- 'import sys; print("\\n".join(sys.path))',
303
- ]);
312
+ const result = await processUtils.exec(
313
+ pythonPath,
314
+ ['-c', 'import sys; print("\\n".join(sys.path))'],
315
+ { timeoutMs: this.options.timeoutMs }
316
+ );
304
317
 
305
318
  if (result.code === 0) {
306
319
  const sysPaths = result.stdout
@@ -376,10 +389,11 @@ export class ModuleDiscovery {
376
389
 
377
390
  try {
378
391
  const pythonPath = await this.getPythonExecutable();
379
- const result = await processUtils.exec(pythonPath, [
380
- '-c',
381
- `import ${moduleName}; print(getattr(${moduleName}, '__version__', 'unknown'))`,
382
- ]);
392
+ const result = await processUtils.exec(
393
+ pythonPath,
394
+ ['-c', `import ${moduleName}; print(getattr(${moduleName}, '__version__', 'unknown'))`],
395
+ { timeoutMs: this.options.timeoutMs }
396
+ );
383
397
 
384
398
  if (result.code === 0 && result.stdout.trim() !== 'unknown') {
385
399
  return result.stdout.trim();