tywrap 0.5.1 → 0.6.1

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 (178) hide show
  1. package/dist/cli.js +8 -6
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +236 -147
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts.map +1 -1
  7. package/dist/core/annotation-parser.js +112 -97
  8. package/dist/core/annotation-parser.js.map +1 -1
  9. package/dist/core/discovery.d.ts +17 -0
  10. package/dist/core/discovery.d.ts.map +1 -1
  11. package/dist/core/discovery.js +85 -49
  12. package/dist/core/discovery.js.map +1 -1
  13. package/dist/core/emit-call.d.ts +58 -0
  14. package/dist/core/emit-call.d.ts.map +1 -0
  15. package/dist/core/emit-call.js +103 -0
  16. package/dist/core/emit-call.js.map +1 -0
  17. package/dist/core/generator.d.ts +2 -0
  18. package/dist/core/generator.d.ts.map +1 -1
  19. package/dist/core/generator.js +54 -211
  20. package/dist/core/generator.js.map +1 -1
  21. package/dist/core/mapper.d.ts.map +1 -1
  22. package/dist/core/mapper.js +118 -104
  23. package/dist/core/mapper.js.map +1 -1
  24. package/dist/core/validation.d.ts +23 -0
  25. package/dist/core/validation.d.ts.map +1 -1
  26. package/dist/core/validation.js +52 -48
  27. package/dist/core/validation.js.map +1 -1
  28. package/dist/dev.d.ts.map +1 -1
  29. package/dist/dev.js +175 -133
  30. package/dist/dev.js.map +1 -1
  31. package/dist/index.d.ts +6 -25
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +9 -31
  34. package/dist/index.js.map +1 -1
  35. package/dist/runtime/bounded-context.d.ts +2 -2
  36. package/dist/runtime/bounded-context.js +2 -2
  37. package/dist/runtime/{safe-codec.d.ts → bridge-codec.d.ts} +26 -11
  38. package/dist/runtime/bridge-codec.d.ts.map +1 -0
  39. package/dist/runtime/{safe-codec.js → bridge-codec.js} +69 -72
  40. package/dist/runtime/bridge-codec.js.map +1 -0
  41. package/dist/runtime/{http-io.d.ts → http-transport.d.ts} +6 -6
  42. package/dist/runtime/http-transport.d.ts.map +1 -0
  43. package/dist/runtime/{http-io.js → http-transport.js} +3 -3
  44. package/dist/runtime/http-transport.js.map +1 -0
  45. package/dist/runtime/http.d.ts +3 -3
  46. package/dist/runtime/http.d.ts.map +1 -1
  47. package/dist/runtime/http.js +4 -4
  48. package/dist/runtime/http.js.map +1 -1
  49. package/dist/runtime/index.d.ts +9 -1
  50. package/dist/runtime/index.d.ts.map +1 -1
  51. package/dist/runtime/index.js +9 -1
  52. package/dist/runtime/index.js.map +1 -1
  53. package/dist/runtime/node.d.ts +6 -6
  54. package/dist/runtime/node.d.ts.map +1 -1
  55. package/dist/runtime/node.js +7 -7
  56. package/dist/runtime/node.js.map +1 -1
  57. package/dist/runtime/pooled-transport.d.ts +11 -11
  58. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  59. package/dist/runtime/pooled-transport.js +10 -10
  60. package/dist/runtime/pooled-transport.js.map +1 -1
  61. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  62. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  63. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  64. package/dist/runtime/{pyodide-io.d.ts → pyodide-transport.d.ts} +8 -8
  65. package/dist/runtime/pyodide-transport.d.ts.map +1 -0
  66. package/dist/runtime/{pyodide-io.js → pyodide-transport.js} +5 -5
  67. package/dist/runtime/pyodide-transport.js.map +1 -0
  68. package/dist/runtime/pyodide.d.ts +3 -3
  69. package/dist/runtime/pyodide.d.ts.map +1 -1
  70. package/dist/runtime/pyodide.js +5 -5
  71. package/dist/runtime/pyodide.js.map +1 -1
  72. package/dist/runtime/rpc-client.d.ts +23 -5
  73. package/dist/runtime/rpc-client.d.ts.map +1 -1
  74. package/dist/runtime/rpc-client.js +36 -31
  75. package/dist/runtime/rpc-client.js.map +1 -1
  76. package/dist/runtime/{process-io.d.ts → subprocess-transport.d.ts} +25 -9
  77. package/dist/runtime/subprocess-transport.d.ts.map +1 -0
  78. package/dist/runtime/{process-io.js → subprocess-transport.js} +60 -41
  79. package/dist/runtime/subprocess-transport.js.map +1 -0
  80. package/dist/runtime/{worker-pool.d.ts → transport-pool.d.ts} +15 -15
  81. package/dist/runtime/transport-pool.d.ts.map +1 -0
  82. package/dist/runtime/{worker-pool.js → transport-pool.js} +6 -6
  83. package/dist/runtime/transport-pool.js.map +1 -0
  84. package/dist/runtime/transport.d.ts +14 -8
  85. package/dist/runtime/transport.d.ts.map +1 -1
  86. package/dist/runtime/transport.js +10 -4
  87. package/dist/runtime/transport.js.map +1 -1
  88. package/dist/types/index.d.ts +10 -1
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/tywrap.d.ts +0 -5
  91. package/dist/tywrap.d.ts.map +1 -1
  92. package/dist/tywrap.js +0 -20
  93. package/dist/tywrap.js.map +1 -1
  94. package/dist/utils/cache.d.ts +13 -2
  95. package/dist/utils/cache.d.ts.map +1 -1
  96. package/dist/utils/cache.js +52 -60
  97. package/dist/utils/cache.js.map +1 -1
  98. package/dist/utils/codec.d.ts +1 -1
  99. package/dist/utils/codec.d.ts.map +1 -1
  100. package/dist/utils/codec.js +181 -182
  101. package/dist/utils/codec.js.map +1 -1
  102. package/dist/utils/python.d.ts.map +1 -1
  103. package/dist/utils/python.js +28 -17
  104. package/dist/utils/python.js.map +1 -1
  105. package/dist/utils/runtime.d.ts.map +1 -1
  106. package/dist/utils/runtime.js +22 -16
  107. package/dist/utils/runtime.js.map +1 -1
  108. package/dist/version.d.ts +12 -0
  109. package/dist/version.d.ts.map +1 -0
  110. package/dist/version.js +12 -0
  111. package/dist/version.js.map +1 -0
  112. package/package.json +3 -2
  113. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  114. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  115. package/runtime/python_bridge.py +54 -6
  116. package/runtime/safe_codec.py +8 -8
  117. package/runtime/tywrap_bridge_core.py +159 -23
  118. package/src/cli.ts +8 -12
  119. package/src/config/index.ts +271 -165
  120. package/src/core/annotation-parser.ts +135 -123
  121. package/src/core/discovery.ts +102 -54
  122. package/src/core/emit-call.ts +171 -0
  123. package/src/core/generator.ts +67 -263
  124. package/src/core/mapper.ts +125 -110
  125. package/src/core/validation.ts +84 -48
  126. package/src/dev.ts +237 -153
  127. package/src/index.ts +11 -68
  128. package/src/runtime/bounded-context.ts +2 -2
  129. package/src/runtime/{safe-codec.ts → bridge-codec.ts} +83 -95
  130. package/src/runtime/{http-io.ts → http-transport.ts} +5 -5
  131. package/src/runtime/http.ts +5 -5
  132. package/src/runtime/index.ts +23 -1
  133. package/src/runtime/node.ts +15 -15
  134. package/src/runtime/pooled-transport.ts +15 -15
  135. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  136. package/src/runtime/{pyodide-io.ts → pyodide-transport.ts} +7 -7
  137. package/src/runtime/pyodide.ts +6 -6
  138. package/src/runtime/rpc-client.ts +51 -40
  139. package/src/runtime/{process-io.ts → subprocess-transport.ts} +77 -47
  140. package/src/runtime/{worker-pool.ts → transport-pool.ts} +26 -26
  141. package/src/runtime/transport.ts +15 -8
  142. package/src/types/index.ts +10 -1
  143. package/src/tywrap.ts +0 -24
  144. package/src/utils/cache.ts +61 -63
  145. package/src/utils/codec.ts +217 -196
  146. package/src/utils/python.ts +33 -20
  147. package/src/utils/runtime.ts +24 -13
  148. package/src/version.ts +12 -0
  149. package/dist/runtime/http-io.d.ts.map +0 -1
  150. package/dist/runtime/http-io.js.map +0 -1
  151. package/dist/runtime/optimized-node.d.ts +0 -23
  152. package/dist/runtime/optimized-node.d.ts.map +0 -1
  153. package/dist/runtime/optimized-node.js +0 -23
  154. package/dist/runtime/optimized-node.js.map +0 -1
  155. package/dist/runtime/process-io.d.ts.map +0 -1
  156. package/dist/runtime/process-io.js.map +0 -1
  157. package/dist/runtime/protocol.d.ts +0 -3
  158. package/dist/runtime/protocol.d.ts.map +0 -1
  159. package/dist/runtime/protocol.js +0 -3
  160. package/dist/runtime/protocol.js.map +0 -1
  161. package/dist/runtime/pyodide-io.d.ts.map +0 -1
  162. package/dist/runtime/pyodide-io.js.map +0 -1
  163. package/dist/runtime/safe-codec.d.ts.map +0 -1
  164. package/dist/runtime/safe-codec.js.map +0 -1
  165. package/dist/runtime/worker-pool.d.ts.map +0 -1
  166. package/dist/runtime/worker-pool.js.map +0 -1
  167. package/dist/utils/bundle-optimizer.d.ts +0 -182
  168. package/dist/utils/bundle-optimizer.d.ts.map +0 -1
  169. package/dist/utils/bundle-optimizer.js +0 -680
  170. package/dist/utils/bundle-optimizer.js.map +0 -1
  171. package/dist/utils/memory-profiler.d.ts +0 -123
  172. package/dist/utils/memory-profiler.d.ts.map +0 -1
  173. package/dist/utils/memory-profiler.js +0 -426
  174. package/dist/utils/memory-profiler.js.map +0 -1
  175. package/src/runtime/optimized-node.ts +0 -25
  176. package/src/runtime/protocol.ts +0 -2
  177. package/src/utils/bundle-optimizer.ts +0 -876
  178. package/src/utils/memory-profiler.ts +0 -582
@@ -118,30 +118,35 @@ export function parseAnnotationToPythonType(
118
118
  return { kind: 'custom', name: n };
119
119
  };
120
120
 
121
+ // Extracts the leading quoted string from the body of a `Name(...)` factory call
122
+ // (the `inner` already stripped of the outer `Name(` and trailing `)`). Returns
123
+ // the unquoted name, or null when the body is not a well-formed quoted argument.
124
+ const extractQuotedFactoryArg = (inner: string): string | null => {
125
+ if (inner.length < 2) {
126
+ return null;
127
+ }
128
+
129
+ const quote = inner[0];
130
+ if ((quote !== "'" && quote !== '"') || inner[inner.length - 1] !== quote) {
131
+ return null;
132
+ }
133
+
134
+ const commaIndex = inner.indexOf(',');
135
+ const quoted = commaIndex === -1 ? inner : inner.slice(0, commaIndex).trimEnd();
136
+ if (quoted.length < 2 || quoted[quoted.length - 1] !== quote) {
137
+ return null;
138
+ }
139
+
140
+ return quoted.slice(1, -1);
141
+ };
142
+
121
143
  const parseTypingFactoryName = (text: string, name: string): string | null => {
122
144
  for (const prefix of modulePrefixes) {
123
145
  const start = `${prefix}${name}(`;
124
146
  if (!text.startsWith(start) || !text.endsWith(')')) {
125
147
  continue;
126
148
  }
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);
149
+ return extractQuotedFactoryArg(text.slice(start.length, -1).trim());
145
150
  }
146
151
 
147
152
  return null;
@@ -245,6 +250,19 @@ export function parseAnnotationToPythonType(
245
250
  return results;
246
251
  };
247
252
 
253
+ // Returns the content between the first `[` and the last `]` of a special-form
254
+ // annotation (e.g. the `int, str` of `Union[int, str]`). Callers gate this on a
255
+ // prefix check, so the brackets are known to be present.
256
+ const bracketInner = (raw: string): string => raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
257
+
258
+ // True when `raw` opens with one of the supported module prefixes followed by
259
+ // `name[`. Mirrors the inlined `raw.startsWith('typing.Name[') || ...` checks so
260
+ // the branch ordering in `parse` is unchanged.
261
+ const startsWithSpecialForm = (raw: string, name: string): boolean =>
262
+ raw.startsWith(`typing.${name}[`) ||
263
+ raw.startsWith(`typing_extensions.${name}[`) ||
264
+ raw.startsWith(`${name}[`);
265
+
248
266
  const splitGenericInvocation = (raw: string): { name: string; inner: string } | null => {
249
267
  if (!raw.endsWith(']')) {
250
268
  return null;
@@ -277,6 +295,86 @@ export function parseAnnotationToPythonType(
277
295
  };
278
296
  };
279
297
 
298
+ // Parses the body of a `Callable[...]` form (the `inner` already stripped of the
299
+ // outer brackets). `recurse` is the top-level `parse`, threaded in so this stays a
300
+ // standalone helper. Returns null only when the form has fewer than the required
301
+ // two comma-separated sections, matching the original guarded branch exactly.
302
+ const parseCallableInner = (
303
+ inner: string,
304
+ depth: number,
305
+ recurse: (ann: unknown, depth?: number) => PythonType
306
+ ): PythonType | null => {
307
+ const parts = splitTopLevel(inner, ',');
308
+ if (parts.length < 2) {
309
+ return null;
310
+ }
311
+
312
+ const paramsPart = (parts[0] ?? '').trim();
313
+ const returnPart = parts.slice(1).join(',').trim();
314
+ const returnType = recurse(returnPart, depth + 1);
315
+
316
+ if (paramsPart === '...' || paramsPart === 'Ellipsis') {
317
+ return {
318
+ kind: 'callable',
319
+ parameters: [{ kind: 'custom', name: '...' }],
320
+ returnType,
321
+ };
322
+ }
323
+
324
+ const directParamSpec = recurse(paramsPart, depth + 1);
325
+ if (directParamSpec.kind === 'paramspec') {
326
+ return {
327
+ kind: 'callable',
328
+ parameters: [],
329
+ parameterSpec: directParamSpec,
330
+ returnType,
331
+ };
332
+ }
333
+
334
+ const paramInner =
335
+ paramsPart.startsWith('[') && paramsPart.endsWith(']') ? paramsPart.slice(1, -1) : '';
336
+ const trimmed = paramInner.trim();
337
+ if (trimmed === '...' || trimmed === 'Ellipsis') {
338
+ return {
339
+ kind: 'callable',
340
+ parameters: [{ kind: 'custom', name: '...' }],
341
+ returnType,
342
+ };
343
+ }
344
+
345
+ const parameters = trimmed
346
+ ? splitTopLevel(trimmed, ',').map(p => recurse(p.trim(), depth + 1))
347
+ : [];
348
+ return { kind: 'callable', parameters, returnType };
349
+ };
350
+
351
+ // Handles the `typing.TypeVar('T')` / `ParamSpec('P')` / `TypeVarTuple('Ts')`
352
+ // factory-call forms. Returns null when `rawText` is none of them, preserving
353
+ // the original sequential fall-through (TypeVar, then ParamSpec, then TypeVarTuple).
354
+ const parseTypingFactory = (rawText: string): PythonType | null => {
355
+ const typeVarName = parseTypingFactoryName(rawText, 'TypeVar');
356
+ if (typeVarName) {
357
+ return mapKnownTypeParameter(typeVarName) ?? { kind: 'typevar', name: typeVarName };
358
+ }
359
+
360
+ const paramSpecName = parseTypingFactoryName(rawText, 'ParamSpec');
361
+ if (paramSpecName) {
362
+ return mapKnownTypeParameter(paramSpecName) ?? { kind: 'paramspec', name: paramSpecName };
363
+ }
364
+
365
+ const typeVarTupleName = parseTypingFactoryName(rawText, 'TypeVarTuple');
366
+ if (typeVarTupleName) {
367
+ return (
368
+ mapKnownTypeParameter(typeVarTupleName) ?? {
369
+ kind: 'typevartuple',
370
+ name: typeVarTupleName,
371
+ }
372
+ );
373
+ }
374
+
375
+ return null;
376
+ };
377
+
280
378
  const parse = (ann: unknown, depth = 0): PythonType => {
281
379
  if (ann === null || ann === undefined) {
282
380
  return unknownType();
@@ -320,33 +418,18 @@ export function parseAnnotationToPythonType(
320
418
  }
321
419
  }
322
420
 
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, ',');
421
+ if (startsWithSpecialForm(raw, 'Union')) {
422
+ const parts = splitTopLevel(bracketInner(raw), ',');
330
423
  const types = parts.map(p => parse(p.trim(), depth + 1));
331
424
  return { kind: 'union', types };
332
425
  }
333
426
 
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) };
427
+ if (startsWithSpecialForm(raw, 'Optional')) {
428
+ return { kind: 'optional', type: parse(bracketInner(raw), depth + 1) };
341
429
  }
342
430
 
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, ',');
431
+ if (startsWithSpecialForm(raw, 'Literal')) {
432
+ const parts = splitTopLevel(bracketInner(raw), ',');
350
433
  if (parts.length === 1) {
351
434
  return mapLiteral(String(parts[0] ?? '').trim());
352
435
  }
@@ -360,24 +443,9 @@ export function parseAnnotationToPythonType(
360
443
  return parse(extMatch[3] ?? '', depth + 1);
361
444
  }
362
445
 
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
- );
446
+ const typingFactory = parseTypingFactory(rawText);
447
+ if (typingFactory) {
448
+ return typingFactory;
381
449
  }
382
450
  if (
383
451
  raw === 'typing.LiteralString' ||
@@ -387,61 +455,15 @@ export function parseAnnotationToPythonType(
387
455
  return { kind: 'primitive', name: 'str' };
388
456
  }
389
457
 
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 };
458
+ if (startsWithSpecialForm(raw, 'Callable')) {
459
+ const callable = parseCallableInner(bracketInner(raw), depth, parse);
460
+ if (callable) {
461
+ return callable;
435
462
  }
436
463
  }
437
464
 
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, ',');
465
+ if (startsWithSpecialForm(raw, 'Mapping')) {
466
+ const parts = splitTopLevel(bracketInner(raw), ',');
445
467
  return {
446
468
  kind: 'collection',
447
469
  name: 'dict',
@@ -449,13 +471,8 @@ export function parseAnnotationToPythonType(
449
471
  };
450
472
  }
451
473
 
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, ',');
474
+ if (startsWithSpecialForm(raw, 'Annotated')) {
475
+ const parts = splitTopLevel(bracketInner(raw), ',');
459
476
  if (parts.length > 0) {
460
477
  return {
461
478
  kind: 'annotated',
@@ -465,13 +482,8 @@ export function parseAnnotationToPythonType(
465
482
  }
466
483
  }
467
484
 
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) };
485
+ if (startsWithSpecialForm(raw, 'Unpack')) {
486
+ return { kind: 'unpack', type: parse(bracketInner(raw), depth + 1) };
475
487
  }
476
488
 
477
489
  const coll = normalizeCollectionName(raw);
@@ -12,6 +12,32 @@ import { getComponentLogger } from '../utils/logger.js';
12
12
 
13
13
  const log = getComponentLogger('Discovery');
14
14
 
15
+ /**
16
+ * Strict allow-list for Python module names.
17
+ *
18
+ * Module names are interpolated into a `python -c` source string, so anything
19
+ * outside a conservative identifier-plus-dots shape (e.g. quotes, semicolons,
20
+ * newlines, parentheses, whitespace) could break out of the intended
21
+ * expression and inject arbitrary code into the interpreter. Reject those
22
+ * before they ever reach the subprocess argv.
23
+ */
24
+ const SAFE_MODULE_NAME = /^[A-Za-z_][A-Za-z0-9_.]*$/;
25
+
26
+ /**
27
+ * Validate a Python module name against {@link SAFE_MODULE_NAME}.
28
+ *
29
+ * @throws {Error} if the name is empty or contains characters that could
30
+ * escape the `python -c` expression it is interpolated into.
31
+ */
32
+ function assertSafeModuleName(moduleName: string): void {
33
+ if (typeof moduleName !== 'string' || !SAFE_MODULE_NAME.test(moduleName)) {
34
+ throw new Error(
35
+ `Unsafe Python module name rejected: ${JSON.stringify(moduleName)}. ` +
36
+ 'Module names must match /^[A-Za-z_][A-Za-z0-9_.]*$/.'
37
+ );
38
+ }
39
+ }
40
+
15
41
  export interface ModuleInfo {
16
42
  name: string;
17
43
  path: string;
@@ -87,44 +113,70 @@ export class ModuleDiscovery {
87
113
  * Resolve Python module path from module name
88
114
  */
89
115
  async resolvePythonPath(moduleName: string): Promise<string | null> {
116
+ // Reject names that could break out of the `python -c` expression below.
117
+ assertSafeModuleName(moduleName);
118
+
90
119
  // Check cache first
91
120
  const cached = this.moduleCache.get(moduleName);
92
121
  if (cached) {
93
122
  return cached.path;
94
123
  }
95
124
 
96
- // Try to resolve using Python's module finder
97
- if (processUtils.isAvailable()) {
98
- try {
99
- const pythonPath = await this.getPythonExecutable();
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
- );
125
+ // Try to resolve using Python's module finder, then fall back to a
126
+ // filesystem search across the known Python paths.
127
+ return (
128
+ (await this.resolveViaInterpreter(moduleName)) ??
129
+ (await this.resolveViaSearchPaths(moduleName))
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Resolve a module path by asking the Python interpreter for `__file__`.
135
+ * Caches and returns the resolved path, or `null` when the interpreter is
136
+ * unavailable, reports a builtin, or fails.
137
+ */
138
+ private async resolveViaInterpreter(moduleName: string): Promise<string | null> {
139
+ if (!processUtils.isAvailable()) {
140
+ return null;
141
+ }
142
+
143
+ try {
144
+ const pythonPath = await this.getPythonExecutable();
145
+ const result = await processUtils.exec(
146
+ pythonPath,
147
+ [
148
+ '-c',
149
+ `import ${moduleName}; print(${moduleName}.__file__ if hasattr(${moduleName}, '__file__') else '${moduleName}.__path__[0]' if hasattr(${moduleName}, '__path__') else 'builtin')`,
150
+ ],
151
+ { timeoutMs: this.options.timeoutMs }
152
+ );
108
153
 
109
- if (result.code === 0 && result.stdout.trim() !== 'builtin') {
110
- const modulePath = result.stdout.trim();
154
+ if (result.code === 0 && result.stdout.trim() !== 'builtin') {
155
+ const modulePath = result.stdout.trim();
111
156
 
112
- // Cache the result
113
- this.moduleCache.set(moduleName, {
114
- name: moduleName,
115
- path: modulePath,
116
- isPackage: modulePath.endsWith('__init__.py'),
117
- dependencies: [],
118
- });
157
+ // Cache the result
158
+ this.moduleCache.set(moduleName, {
159
+ name: moduleName,
160
+ path: modulePath,
161
+ isPackage: modulePath.endsWith('__init__.py'),
162
+ dependencies: [],
163
+ });
119
164
 
120
- return modulePath;
121
- }
122
- } catch (error) {
123
- log.warn('Failed to resolve module', { moduleName, error: String(error) });
165
+ return modulePath;
124
166
  }
167
+ } catch (error) {
168
+ log.warn('Failed to resolve module', { moduleName, error: String(error) });
125
169
  }
126
170
 
127
- // Fallback: search in known Python paths
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Fallback resolution: probe `<path>/<module>.py` and
176
+ * `<path>/<module>/__init__.py` across the known Python search paths.
177
+ * Returns the first existing candidate, or `null`.
178
+ */
179
+ private async resolveViaSearchPaths(moduleName: string): Promise<string | null> {
128
180
  const searchPaths = await this.getPythonSearchPaths();
129
181
 
130
182
  for (const searchPath of searchPaths) {
@@ -208,40 +260,33 @@ export class ModuleDiscovery {
208
260
  */
209
261
  parseDependenciesFromSource(source: string): string[] {
210
262
  const dependencies: string[] = [];
211
- const lines = source.split('\n');
212
263
 
213
- for (const line of lines) {
214
- const trimmed = line.trim();
215
-
216
- // Skip comments and empty lines
217
- if (trimmed.startsWith('#') || trimmed === '') {
218
- continue;
264
+ for (const line of source.split('\n')) {
265
+ const moduleName = this.extractImportedModule(line.trim());
266
+ if (moduleName) {
267
+ dependencies.push(moduleName);
219
268
  }
269
+ }
220
270
 
221
- // Match import statements
222
- const importMatch = trimmed.match(/^import\s+([a-zA-Z_][a-zA-Z0-9_\.]*)/);
223
- if (importMatch?.[1]) {
224
- const fullModuleName = importMatch[1];
225
- const moduleName = fullModuleName.split('.')[0]; // Get top-level module
226
- if (moduleName) {
227
- dependencies.push(moduleName);
228
- }
229
- continue;
230
- }
271
+ return [...new Set(dependencies)]; // Remove duplicates
272
+ }
231
273
 
232
- // Match from ... import statements
233
- const fromMatch = trimmed.match(/^from\s+([a-zA-Z_][a-zA-Z0-9_\.]*)\s+import/);
234
- if (fromMatch?.[1]) {
235
- const fullModuleName = fromMatch[1];
236
- const moduleName = fullModuleName.split('.')[0]; // Get top-level module
237
- if (moduleName) {
238
- dependencies.push(moduleName);
239
- }
240
- continue;
241
- }
274
+ /**
275
+ * Extract the top-level module name imported by a single (trimmed) source
276
+ * line, or `null` when the line is a comment, blank, or not an import.
277
+ */
278
+ private extractImportedModule(trimmed: string): string | null {
279
+ // Skip comments and empty lines
280
+ if (trimmed.startsWith('#') || trimmed === '') {
281
+ return null;
242
282
  }
243
283
 
244
- return [...new Set(dependencies)]; // Remove duplicates
284
+ // Match `import x.y.z` and `from x.y.z import ...` statements.
285
+ const match =
286
+ trimmed.match(/^import\s+([a-zA-Z_][a-zA-Z0-9_\.]*)/) ??
287
+ trimmed.match(/^from\s+([a-zA-Z_][a-zA-Z0-9_\.]*)\s+import/);
288
+
289
+ return match?.[1]?.split('.')[0] ?? null; // Get top-level module
245
290
  }
246
291
 
247
292
  /**
@@ -383,6 +428,9 @@ export class ModuleDiscovery {
383
428
  * Get module version information
384
429
  */
385
430
  async getModuleVersion(moduleName: string): Promise<string | undefined> {
431
+ // Reject names that could break out of the `python -c` expression below.
432
+ assertSafeModuleName(moduleName);
433
+
386
434
  if (!processUtils.isAvailable()) {
387
435
  return undefined;
388
436
  }