tywrap 0.2.0 → 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 (163) 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 +23 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +23 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/base.d.ts +17 -7
  33. package/dist/runtime/base.d.ts.map +1 -1
  34. package/dist/runtime/base.js +18 -1
  35. package/dist/runtime/base.js.map +1 -1
  36. package/dist/runtime/bounded-context.d.ts +252 -0
  37. package/dist/runtime/bounded-context.d.ts.map +1 -0
  38. package/dist/runtime/bounded-context.js +454 -0
  39. package/dist/runtime/bounded-context.js.map +1 -0
  40. package/dist/runtime/bridge-core.d.ts +1 -0
  41. package/dist/runtime/bridge-core.d.ts.map +1 -1
  42. package/dist/runtime/bridge-core.js +52 -17
  43. package/dist/runtime/bridge-core.js.map +1 -1
  44. package/dist/runtime/bridge-protocol.d.ts +184 -0
  45. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  46. package/dist/runtime/bridge-protocol.js +344 -0
  47. package/dist/runtime/bridge-protocol.js.map +1 -0
  48. package/dist/runtime/disposable.d.ts +40 -0
  49. package/dist/runtime/disposable.d.ts.map +1 -0
  50. package/dist/runtime/disposable.js +49 -0
  51. package/dist/runtime/disposable.js.map +1 -0
  52. package/dist/runtime/errors.d.ts +10 -0
  53. package/dist/runtime/errors.d.ts.map +1 -1
  54. package/dist/runtime/errors.js +9 -0
  55. package/dist/runtime/errors.js.map +1 -1
  56. package/dist/runtime/http-io.d.ts +91 -0
  57. package/dist/runtime/http-io.d.ts.map +1 -0
  58. package/dist/runtime/http-io.js +195 -0
  59. package/dist/runtime/http-io.js.map +1 -0
  60. package/dist/runtime/http.d.ts +48 -13
  61. package/dist/runtime/http.d.ts.map +1 -1
  62. package/dist/runtime/http.js +60 -73
  63. package/dist/runtime/http.js.map +1 -1
  64. package/dist/runtime/node.d.ts +97 -130
  65. package/dist/runtime/node.d.ts.map +1 -1
  66. package/dist/runtime/node.js +392 -521
  67. package/dist/runtime/node.js.map +1 -1
  68. package/dist/runtime/optimized-node.d.ts +1 -1
  69. package/dist/runtime/optimized-node.d.ts.map +1 -1
  70. package/dist/runtime/optimized-node.js +1 -1
  71. package/dist/runtime/optimized-node.js.map +1 -1
  72. package/dist/runtime/pooled-transport.d.ts +135 -0
  73. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  74. package/dist/runtime/pooled-transport.js +177 -0
  75. package/dist/runtime/pooled-transport.js.map +1 -0
  76. package/dist/runtime/process-io.d.ts +209 -0
  77. package/dist/runtime/process-io.d.ts.map +1 -0
  78. package/dist/runtime/process-io.js +729 -0
  79. package/dist/runtime/process-io.js.map +1 -0
  80. package/dist/runtime/pyodide-io.d.ts +154 -0
  81. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  82. package/dist/runtime/pyodide-io.js +459 -0
  83. package/dist/runtime/pyodide-io.js.map +1 -0
  84. package/dist/runtime/pyodide.d.ts +51 -19
  85. package/dist/runtime/pyodide.d.ts.map +1 -1
  86. package/dist/runtime/pyodide.js +57 -186
  87. package/dist/runtime/pyodide.js.map +1 -1
  88. package/dist/runtime/safe-codec.d.ts +103 -0
  89. package/dist/runtime/safe-codec.d.ts.map +1 -0
  90. package/dist/runtime/safe-codec.js +519 -0
  91. package/dist/runtime/safe-codec.js.map +1 -0
  92. package/dist/runtime/transport.d.ts +191 -0
  93. package/dist/runtime/transport.d.ts.map +1 -0
  94. package/dist/runtime/transport.js +86 -0
  95. package/dist/runtime/transport.js.map +1 -0
  96. package/dist/runtime/validators.d.ts +120 -0
  97. package/dist/runtime/validators.d.ts.map +1 -0
  98. package/dist/runtime/validators.js +229 -0
  99. package/dist/runtime/validators.js.map +1 -0
  100. package/dist/runtime/worker-pool.d.ts +208 -0
  101. package/dist/runtime/worker-pool.d.ts.map +1 -0
  102. package/dist/runtime/worker-pool.js +419 -0
  103. package/dist/runtime/worker-pool.js.map +1 -0
  104. package/dist/types/index.d.ts +51 -1
  105. package/dist/types/index.d.ts.map +1 -1
  106. package/dist/tywrap.d.ts.map +1 -1
  107. package/dist/tywrap.js +227 -328
  108. package/dist/tywrap.js.map +1 -1
  109. package/dist/utils/cache.d.ts.map +1 -1
  110. package/dist/utils/cache.js +53 -5
  111. package/dist/utils/cache.js.map +1 -1
  112. package/dist/utils/codec.d.ts.map +1 -1
  113. package/dist/utils/codec.js +114 -1
  114. package/dist/utils/codec.js.map +1 -1
  115. package/dist/utils/ir-cache.d.ts +28 -0
  116. package/dist/utils/ir-cache.d.ts.map +1 -0
  117. package/dist/utils/ir-cache.js +29 -0
  118. package/dist/utils/ir-cache.js.map +1 -0
  119. package/dist/utils/parallel-processor.d.ts +1 -1
  120. package/dist/utils/parallel-processor.d.ts.map +1 -1
  121. package/dist/utils/parallel-processor.js +111 -40
  122. package/dist/utils/parallel-processor.js.map +1 -1
  123. package/dist/utils/runtime.d.ts +5 -1
  124. package/dist/utils/runtime.d.ts.map +1 -1
  125. package/dist/utils/runtime.js +112 -18
  126. package/dist/utils/runtime.js.map +1 -1
  127. package/package.json +40 -18
  128. package/runtime/python_bridge.py +108 -15
  129. package/runtime/safe_codec.py +344 -0
  130. package/src/cli.ts +61 -2
  131. package/src/config/index.ts +63 -17
  132. package/src/core/analyzer.ts +52 -15
  133. package/src/core/annotation-parser.ts +500 -0
  134. package/src/core/discovery.ts +26 -12
  135. package/src/core/generator.ts +956 -116
  136. package/src/core/mapper.ts +158 -51
  137. package/src/index.ts +78 -6
  138. package/src/runtime/base.ts +18 -26
  139. package/src/runtime/bounded-context.ts +608 -0
  140. package/src/runtime/bridge-core.ts +58 -18
  141. package/src/runtime/bridge-protocol.ts +483 -0
  142. package/src/runtime/disposable.ts +65 -0
  143. package/src/runtime/errors.ts +14 -0
  144. package/src/runtime/http-io.ts +245 -0
  145. package/src/runtime/http.ts +76 -115
  146. package/src/runtime/node.ts +563 -676
  147. package/src/runtime/optimized-node.ts +4 -1
  148. package/src/runtime/pooled-transport.ts +263 -0
  149. package/src/runtime/process-io.ts +930 -0
  150. package/src/runtime/pyodide-io.ts +551 -0
  151. package/src/runtime/pyodide.ts +75 -215
  152. package/src/runtime/safe-codec.ts +651 -0
  153. package/src/runtime/transport.ts +278 -0
  154. package/src/runtime/validators.ts +252 -0
  155. package/src/runtime/worker-pool.ts +559 -0
  156. package/src/types/global.d.ts +11 -1
  157. package/src/types/index.ts +63 -0
  158. package/src/tywrap.ts +279 -358
  159. package/src/utils/cache.ts +59 -5
  160. package/src/utils/codec.ts +122 -1
  161. package/src/utils/ir-cache.ts +51 -0
  162. package/src/utils/parallel-processor.ts +119 -43
  163. package/src/utils/runtime.ts +129 -18
@@ -0,0 +1,344 @@
1
+ """
2
+ Safe JSON codec with explicit edge case handling.
3
+
4
+ Provides bidirectional validation and serialization for the JS<->Python bridge.
5
+ This module handles special Python types and enforces payload size limits to
6
+ ensure predictable behavior at the language boundary.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import math
12
+ from datetime import date, datetime, time, timedelta
13
+ from decimal import Decimal
14
+ from pathlib import Path, PurePath
15
+ from typing import Any, Optional, Union
16
+ from uuid import UUID
17
+
18
+
19
+ class CodecError(Exception):
20
+ """Raised when encoding/decoding fails."""
21
+
22
+ pass
23
+
24
+
25
+ def _is_nan_or_inf(value: Any) -> bool:
26
+ """
27
+ Check if a numeric value is NaN or Infinity.
28
+
29
+ Args:
30
+ value: The value to check.
31
+
32
+ Returns:
33
+ True if the value is NaN, positive infinity, or negative infinity.
34
+ """
35
+ if not isinstance(value, (int, float)):
36
+ return False
37
+ try:
38
+ return math.isnan(value) or math.isinf(value)
39
+ except (TypeError, ValueError):
40
+ return False
41
+
42
+
43
+ def _is_numpy_scalar(obj: Any) -> bool:
44
+ """
45
+ Detect numpy scalar types when NumPy is installed.
46
+
47
+ Why: numpy scalars have an `.item()` method to extract Python primitives,
48
+ which is needed for JSON serialization.
49
+ """
50
+ try:
51
+ import numpy as np
52
+ except ImportError:
53
+ return False
54
+ return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0
55
+
56
+
57
+ def _is_pandas_scalar(obj: Any) -> bool:
58
+ """
59
+ Detect pandas scalar types (Timestamp, Timedelta, etc.).
60
+
61
+ Why: pandas wraps numpy scalars with additional metadata; extracting the
62
+ underlying value ensures clean JSON serialization.
63
+ """
64
+ try:
65
+ import pandas as pd
66
+ except ImportError:
67
+ return False
68
+ # Check for pandas Timestamp, Timedelta, NaT, etc.
69
+ return isinstance(
70
+ obj,
71
+ (
72
+ pd.Timestamp,
73
+ pd.Timedelta,
74
+ type(pd.NaT),
75
+ ),
76
+ )
77
+
78
+
79
+ def _has_pydantic_model_dump(obj: Any) -> bool:
80
+ """
81
+ Check if object is a Pydantic v2 model with model_dump method.
82
+
83
+ Why: Pydantic models should be serialized via their built-in mechanism
84
+ to respect field aliases and serialization modes.
85
+ """
86
+ model_dump = getattr(obj, 'model_dump', None)
87
+ return callable(model_dump)
88
+
89
+
90
+ class SafeCodec:
91
+ """
92
+ Safe JSON codec with explicit edge case handling.
93
+
94
+ This codec provides:
95
+ - Rejection of NaN/Infinity by default (configurable)
96
+ - Payload size limits to prevent memory exhaustion
97
+ - Automatic handling of common Python types (datetime, Decimal, UUID, etc.)
98
+ - Support for numpy/pandas scalars
99
+ - Pydantic model serialization
100
+
101
+ Args:
102
+ allow_nan: If False (default), reject NaN/Infinity values.
103
+ max_payload_bytes: Maximum payload size in bytes (default 10MB).
104
+
105
+ Example:
106
+ >>> codec = SafeCodec()
107
+ >>> codec.encode({"key": "value"})
108
+ '{"key": "value"}'
109
+ >>> codec.decode('{"key": "value"}')
110
+ {'key': 'value'}
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ allow_nan: bool = False,
116
+ max_payload_bytes: int = 10 * 1024 * 1024,
117
+ ) -> None:
118
+ """
119
+ Initialize the codec with configuration.
120
+
121
+ Args:
122
+ allow_nan: If False (default), reject NaN/Infinity values.
123
+ max_payload_bytes: Maximum payload size in bytes (default 10MB).
124
+ """
125
+ self.allow_nan = allow_nan
126
+ self.max_payload_bytes = max_payload_bytes
127
+
128
+ def encode(self, value: Any) -> str:
129
+ """
130
+ Encode a Python value to a JSON string.
131
+
132
+ Args:
133
+ value: The Python value to encode.
134
+
135
+ Returns:
136
+ A JSON string representation of the value.
137
+
138
+ Raises:
139
+ CodecError: If encoding fails due to:
140
+ - NaN/Infinity values when allow_nan is False
141
+ - Payload exceeds max_payload_bytes
142
+ - Value contains non-serializable types
143
+ """
144
+ try:
145
+ result = json.dumps(
146
+ value,
147
+ default=self._default_encoder,
148
+ allow_nan=self.allow_nan,
149
+ )
150
+ except ValueError as exc:
151
+ # json.dumps raises ValueError for NaN/Infinity when allow_nan=False
152
+ error_msg = str(exc).lower()
153
+ if 'nan' in error_msg or 'infinity' in error_msg or 'inf' in error_msg:
154
+ raise CodecError(
155
+ 'Cannot serialize NaN - NaN/Infinity not allowed in JSON'
156
+ ) from exc
157
+ raise CodecError(f'JSON encoding failed: {exc}') from exc
158
+ except TypeError as exc:
159
+ raise CodecError(f'JSON encoding failed: {exc}') from exc
160
+
161
+ # Check payload size
162
+ payload_bytes = len(result.encode('utf-8'))
163
+ if payload_bytes > self.max_payload_bytes:
164
+ raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
165
+
166
+ return result
167
+
168
+ def decode(self, payload: str) -> Any:
169
+ """
170
+ Decode a JSON string to a Python value.
171
+
172
+ Args:
173
+ payload: The JSON string to decode.
174
+
175
+ Returns:
176
+ The decoded Python value.
177
+
178
+ Raises:
179
+ CodecError: If decoding fails due to:
180
+ - Payload exceeds max_payload_bytes
181
+ - Invalid JSON syntax
182
+ """
183
+ # Check payload size first
184
+ payload_bytes = len(payload.encode('utf-8'))
185
+ if payload_bytes > self.max_payload_bytes:
186
+ raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
187
+
188
+ try:
189
+ return json.loads(payload)
190
+ except json.JSONDecodeError as exc:
191
+ raise CodecError(f'JSON decoding failed: {exc}') from exc
192
+
193
+ def _default_encoder(self, obj: Any) -> Any:
194
+ """
195
+ Handle special Python types during JSON encoding.
196
+
197
+ This method is called by json.dumps for objects that are not natively
198
+ JSON serializable.
199
+
200
+ Args:
201
+ obj: The object to encode.
202
+
203
+ Returns:
204
+ A JSON-serializable representation of the object.
205
+
206
+ Raises:
207
+ TypeError: If the object cannot be serialized.
208
+ CodecError: If the object contains NaN/Infinity when not allowed.
209
+ """
210
+ # Handle numpy/pandas scalars first (they need .item() extraction)
211
+ if _is_numpy_scalar(obj):
212
+ extracted = obj.item()
213
+ # Check for NaN/Infinity in extracted value
214
+ if not self.allow_nan and _is_nan_or_inf(extracted):
215
+ raise CodecError(
216
+ 'Cannot serialize NaN - NaN/Infinity not allowed in JSON'
217
+ )
218
+ return extracted
219
+
220
+ if _is_pandas_scalar(obj):
221
+ try:
222
+ import pandas as pd
223
+ except ImportError:
224
+ pass
225
+ else:
226
+ # Handle NaT (Not a Time)
227
+ if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):
228
+ return None
229
+ # Pandas Timestamp -> ISO string
230
+ if isinstance(obj, pd.Timestamp):
231
+ return obj.isoformat()
232
+ # Pandas Timedelta -> total seconds
233
+ if isinstance(obj, pd.Timedelta):
234
+ return obj.total_seconds()
235
+
236
+ # datetime types
237
+ if isinstance(obj, datetime):
238
+ return obj.isoformat()
239
+
240
+ if isinstance(obj, date):
241
+ return obj.isoformat()
242
+
243
+ if isinstance(obj, time):
244
+ return obj.isoformat()
245
+
246
+ # timedelta -> total seconds (consistent with python_bridge.py)
247
+ if isinstance(obj, timedelta):
248
+ return obj.total_seconds()
249
+
250
+ # Decimal -> string (preserves precision)
251
+ if isinstance(obj, Decimal):
252
+ return str(obj)
253
+
254
+ # UUID -> string
255
+ if isinstance(obj, UUID):
256
+ return str(obj)
257
+
258
+ # Path -> string
259
+ if isinstance(obj, (Path, PurePath)):
260
+ return str(obj)
261
+
262
+ # bytes/bytearray -> base64 with type marker
263
+ if isinstance(obj, (bytes, bytearray)):
264
+ return {
265
+ '__type__': 'bytes',
266
+ 'encoding': 'base64',
267
+ 'data': base64.b64encode(obj).decode('ascii'),
268
+ }
269
+
270
+ # Pydantic models
271
+ if _has_pydantic_model_dump(obj):
272
+ try:
273
+ return obj.model_dump(by_alias=True, mode='json')
274
+ except TypeError:
275
+ # Older Pydantic versions may not support mode='json'
276
+ return obj.model_dump(by_alias=True)
277
+
278
+ # Sets -> lists (common conversion)
279
+ if isinstance(obj, (set, frozenset)):
280
+ return list(obj)
281
+
282
+ # Complex numbers (rejected by default as they contain floats)
283
+ if isinstance(obj, complex):
284
+ raise TypeError(
285
+ f'Object of type {type(obj).__name__} is not JSON serializable'
286
+ )
287
+
288
+ # Fallback: raise TypeError with clear message
289
+ raise TypeError(
290
+ f'Object of type {type(obj).__name__} is not JSON serializable'
291
+ )
292
+
293
+
294
+ # Module-level convenience instance with default settings
295
+ _default_codec: Optional[SafeCodec] = None
296
+
297
+
298
+ def get_default_codec() -> SafeCodec:
299
+ """
300
+ Get or create the default SafeCodec instance.
301
+
302
+ Returns:
303
+ The default SafeCodec instance with standard settings.
304
+ """
305
+ global _default_codec
306
+ if _default_codec is None:
307
+ _default_codec = SafeCodec()
308
+ return _default_codec
309
+
310
+
311
+ def encode(value: Any, *, allow_nan: bool = False) -> str:
312
+ """
313
+ Convenience function to encode a value using default settings.
314
+
315
+ Args:
316
+ value: The Python value to encode.
317
+ allow_nan: If True, allow NaN/Infinity values.
318
+
319
+ Returns:
320
+ A JSON string representation of the value.
321
+
322
+ Raises:
323
+ CodecError: If encoding fails.
324
+ """
325
+ if allow_nan:
326
+ codec = SafeCodec(allow_nan=True)
327
+ return codec.encode(value)
328
+ return get_default_codec().encode(value)
329
+
330
+
331
+ def decode(payload: str) -> Any:
332
+ """
333
+ Convenience function to decode a JSON string using default settings.
334
+
335
+ Args:
336
+ payload: The JSON string to decode.
337
+
338
+ Returns:
339
+ The decoded Python value.
340
+
341
+ Raises:
342
+ CodecError: If decoding fails.
343
+ """
344
+ return get_default_codec().decode(payload)
package/src/cli.ts CHANGED
@@ -192,6 +192,11 @@ async function main(): Promise<void> {
192
192
  type: 'boolean',
193
193
  describe: 'Enable debug logging',
194
194
  })
195
+ .option('verbose', {
196
+ alias: 'v',
197
+ type: 'boolean',
198
+ describe: 'Alias for --debug',
199
+ })
195
200
  .option('fail-on-warn', {
196
201
  type: 'boolean',
197
202
  default: false,
@@ -215,6 +220,7 @@ async function main(): Promise<void> {
215
220
  sourceMap?: boolean;
216
221
  useCache?: boolean;
217
222
  debug?: boolean;
223
+ verbose?: boolean;
218
224
  failOnWarn: boolean;
219
225
  check: boolean;
220
226
  }>
@@ -253,7 +259,9 @@ async function main(): Promise<void> {
253
259
  };
254
260
  }
255
261
 
256
- if (typeof argv.debug === 'boolean') {
262
+ if (argv.verbose === true) {
263
+ overrides.debug = true;
264
+ } else if (typeof argv.debug === 'boolean') {
257
265
  overrides.debug = argv.debug;
258
266
  }
259
267
 
@@ -279,11 +287,27 @@ async function main(): Promise<void> {
279
287
  }
280
288
 
281
289
  const res = await generate(options, { check: argv.check });
290
+ const warnings = res.warnings ?? [];
291
+ const moduleCount = Object.keys(options.pythonModules ?? {}).length;
292
+
293
+ const emitWarnings = (): void => {
294
+ if (warnings.length === 0) {
295
+ return;
296
+ }
297
+ process.stderr.write(`Warnings (count ${warnings.length}):\n`);
298
+ for (const w of warnings) {
299
+ process.stderr.write(`- ${w}\n`);
300
+ }
301
+ process.stderr.write('\n');
302
+ };
303
+
282
304
  if (argv.check) {
283
305
  const outOfDate = res.outOfDate ?? [];
284
306
  if (outOfDate.length === 0) {
307
+ emitWarnings();
285
308
  process.stdout.write('Generated wrappers are up to date.\n');
286
309
  } else {
310
+ emitWarnings();
287
311
  process.stderr.write('Generated wrappers are out of date:\n');
288
312
  for (const file of outOfDate) {
289
313
  process.stderr.write(`- ${file}\n`);
@@ -294,7 +318,42 @@ async function main(): Promise<void> {
294
318
  }
295
319
  }
296
320
  } else {
297
- process.stdout.write(`Generated: ${res.written.join(', ')}\n`);
321
+ emitWarnings();
322
+
323
+ const written = res.written ?? [];
324
+ if (written.length === 0) {
325
+ process.stderr.write(
326
+ `No files were generated. This usually means Python failed to import one or more modules.\n`
327
+ );
328
+ const modulesConfigured = Object.keys(options.pythonModules ?? {});
329
+ if (modulesConfigured.length > 0) {
330
+ process.stderr.write(`Modules: ${modulesConfigured.join(', ')}\n`);
331
+ }
332
+
333
+ const pythonImportPath = options.pythonImportPath ?? [];
334
+ if (pythonImportPath.length > 0) {
335
+ process.stderr.write(`pythonImportPath: ${pythonImportPath.join(', ')}\n`);
336
+ } else {
337
+ process.stderr.write(
338
+ `Tip: set "pythonImportPath" in your tywrap config to include your local module directories.\n`
339
+ );
340
+ }
341
+
342
+ const pyPath = process.env.PYTHONPATH;
343
+ if (typeof pyPath === 'string' && pyPath.trim().length > 0) {
344
+ process.stderr.write(`PYTHONPATH: ${pyPath}\n`);
345
+ }
346
+
347
+ process.stderr.write(`Tip: re-run with --debug for more context.\n`);
348
+ process.exit(1);
349
+ }
350
+
351
+ process.stdout.write(
352
+ `Generated: ${written.length} files from ${moduleCount} modules (warnings: ${warnings.length}).\n`
353
+ );
354
+ for (const file of written) {
355
+ process.stdout.write(`- ${file}\n`);
356
+ }
298
357
  }
299
358
  if (argv.failOnWarn && res.warnings.length > 0) {
300
359
  log.error(
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { existsSync } from 'node:fs';
10
- import { readFile, rm, writeFile } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { readFile, rm, writeFile, mkdtemp, symlink } from 'node:fs/promises';
11
12
  import { createRequire } from 'node:module';
12
13
  import { dirname, extname, resolve } from 'node:path';
13
14
  import { pathToFileURL } from 'node:url';
@@ -20,11 +21,6 @@ import type {
20
21
  } from '../types/index.js';
21
22
  import { getDefaultPythonPath } from '../utils/python.js';
22
23
 
23
- /**
24
- * Public configuration type. Currently identical to {@link TywrapOptions}.
25
- */
26
- export type TywrapConfig = TywrapOptions;
27
-
28
24
  type DeepPartial<T> = {
29
25
  [K in keyof T]?: T[K] extends Array<infer U>
30
26
  ? Array<U>
@@ -33,11 +29,22 @@ type DeepPartial<T> = {
33
29
  : T[K];
34
30
  };
35
31
 
32
+ /**
33
+ * User-authored configuration shape accepted by defineConfig() and config files.
34
+ */
35
+ export type TywrapConfig = DeepPartial<TywrapOptions>;
36
+
37
+ /**
38
+ * Fully resolved configuration shape returned by createConfig() and resolveConfig().
39
+ */
40
+ export type ResolvedTywrapConfig = TywrapOptions;
41
+
36
42
  /**
37
43
  * Default configuration values used when options are not supplied.
38
44
  */
39
- const DEFAULT_CONFIG: TywrapConfig = {
45
+ const DEFAULT_CONFIG: ResolvedTywrapConfig = {
40
46
  pythonModules: {},
47
+ pythonImportPath: [],
41
48
  output: { dir: './generated', format: 'esm', declaration: false, sourceMap: false },
42
49
  runtime: { node: { pythonPath: getDefaultPythonPath(), timeout: 30000 } },
43
50
  performance: { caching: false, batching: false, compression: 'none' },
@@ -85,9 +92,10 @@ function safeExists(path: string): boolean {
85
92
  /**
86
93
  * Validate configuration values and throw user-friendly errors when invalid.
87
94
  */
88
- function validateConfig(config: TywrapConfig): void {
95
+ function validateConfig(config: ResolvedTywrapConfig): void {
89
96
  const allowedTopLevel = new Set([
90
97
  'pythonModules',
98
+ 'pythonImportPath',
91
99
  'output',
92
100
  'runtime',
93
101
  'performance',
@@ -105,6 +113,15 @@ function validateConfig(config: TywrapConfig): void {
105
113
  if (typeof out.dir !== 'string') {
106
114
  throw new Error('output.dir must be a string');
107
115
  }
116
+
117
+ if (config.pythonImportPath !== undefined) {
118
+ if (
119
+ !Array.isArray(config.pythonImportPath) ||
120
+ config.pythonImportPath.some(p => typeof p !== 'string')
121
+ ) {
122
+ throw new Error('pythonImportPath must be an array of strings');
123
+ }
124
+ }
108
125
  if (!['esm', 'cjs', 'both'].includes(out.format)) {
109
126
  throw new Error('output.format must be one of "esm", "cjs" or "both"');
110
127
  }
@@ -183,7 +200,7 @@ function validateConfig(config: TywrapConfig): void {
183
200
  *
184
201
  * @param overrides CLI or programmatic overrides
185
202
  */
186
- export function createConfig(overrides: DeepPartial<TywrapOptions> = {}): TywrapConfig {
203
+ export function createConfig(overrides: DeepPartial<TywrapOptions> = {}): ResolvedTywrapConfig {
187
204
  const merged = merge(DEFAULT_CONFIG, overrides);
188
205
  validateConfig(merged);
189
206
  return merged;
@@ -200,7 +217,9 @@ export interface ResolveConfigOptions {
200
217
  * Resolve configuration by loading a config file (JSON/JS/TS) and merging
201
218
  * defaults with any overrides.
202
219
  */
203
- export async function resolveConfig(options: ResolveConfigOptions = {}): Promise<TywrapConfig> {
220
+ export async function resolveConfig(
221
+ options: ResolveConfigOptions = {}
222
+ ): Promise<ResolvedTywrapConfig> {
204
223
  const cwd = options.cwd ?? process.cwd();
205
224
  const overrides = options.overrides ?? {};
206
225
  const configFile = options.configFile ? resolve(cwd, options.configFile) : undefined;
@@ -272,6 +291,20 @@ export async function loadConfigFile(configFile: string): Promise<Partial<Tywrap
272
291
  fileName: resolved,
273
292
  });
274
293
 
294
+ let transpiledOutput = output.outputText;
295
+ if (!emitCommonJs) {
296
+ try {
297
+ // Preserve support for `import { defineConfig } from 'tywrap'` when ESM config
298
+ // is evaluated from an OS temp directory outside the package scope.
299
+ const tywrapEntryHref = await import.meta.resolve('tywrap');
300
+ transpiledOutput = transpiledOutput
301
+ .replaceAll("'tywrap'", `'${tywrapEntryHref}'`)
302
+ .replaceAll('\"tywrap\"', `\"${tywrapEntryHref}\"`);
303
+ } catch {
304
+ // Best-effort: leave source as-is when tywrap cannot be resolved.
305
+ }
306
+ }
307
+
275
308
  if (emitCommonJs) {
276
309
  // Why: `.cts` is explicitly CommonJS. We evaluate the transpiled output in-memory using
277
310
  // Node's Module internals (`Module._compile` / `_nodeModulePaths`) to avoid writing an extra
@@ -309,18 +342,31 @@ export async function loadConfigFile(configFile: string): Promise<Partial<Tywrap
309
342
  return ensureConfigObject(loaded ?? {}, resolved);
310
343
  }
311
344
 
312
- // Why: Node can't import ESM from a string without a custom loader. We write the transpiled
313
- // output to a temporary `.mjs` file next to the source config so relative imports and Node
314
- // resolution behave naturally, then clean it up immediately after loading.
315
- const tmpPath = resolve(dirname(resolved), `.tywrap.config.${randomUUID()}.mjs`);
345
+ // Why: Node can't import ESM from a string without a custom loader. We write transpiled
346
+ // output to a temporary `.mjs` file under the OS temp directory (not next to user config),
347
+ // then clean up both file and temporary directory after loading.
348
+ const tempDir = await mkdtemp(resolve(tmpdir(), 'tywrap-config-'));
349
+ const tmpPath = resolve(tempDir, `.tywrap.config.${randomUUID()}.mjs`);
316
350
  try {
317
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from config location
318
- await writeFile(tmpPath, output.outputText, 'utf-8');
351
+ const localNodeModules = resolve(process.cwd(), 'node_modules');
352
+ if (safeExists(localNodeModules)) {
353
+ const tempNodeModules = resolve(tempDir, 'node_modules');
354
+ try {
355
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
356
+ await symlink(localNodeModules, tempNodeModules, 'dir');
357
+ } catch {
358
+ // Best-effort only; regular resolution may still succeed without a symlink.
359
+ }
360
+ }
361
+
362
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp path is derived from OS temp directory
363
+ await writeFile(tmpPath, transpiledOutput, 'utf-8');
319
364
  const mod = (await import(pathToFileURL(tmpPath).href)) as Record<string, unknown>;
320
365
  const loaded = mod.default ?? mod;
321
366
  return ensureConfigObject(loaded ?? {}, resolved);
322
367
  } finally {
323
368
  await rm(tmpPath, { force: true });
369
+ await rm(tempDir, { recursive: true, force: true });
324
370
  }
325
371
  }
326
372
 
@@ -335,6 +381,6 @@ async function safeReadFileAsync(path: string): Promise<string> {
335
381
  /**
336
382
  * Type helper for authoring tywrap configs with full type inference.
337
383
  */
338
- export function defineConfig(config: TywrapConfig): TywrapConfig {
384
+ export function defineConfig<T extends TywrapConfig>(config: T): T {
339
385
  return config;
340
386
  }