tywrap 0.1.0 → 0.1.2

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 (63) hide show
  1. package/README.md +54 -221
  2. package/dist/cli.js +70 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +56 -12
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +6 -2
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/core/mapper.d.ts.map +1 -1
  11. package/dist/core/mapper.js +20 -5
  12. package/dist/core/mapper.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/bridge-core.d.ts +64 -0
  18. package/dist/runtime/bridge-core.d.ts.map +1 -0
  19. package/dist/runtime/bridge-core.js +344 -0
  20. package/dist/runtime/bridge-core.js.map +1 -0
  21. package/dist/runtime/node.d.ts +5 -6
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +95 -193
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +4 -7
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +117 -186
  28. package/dist/runtime/optimized-node.js.map +1 -1
  29. package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
  30. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
  31. package/dist/runtime/timed-out-request-tracker.js +48 -0
  32. package/dist/runtime/timed-out-request-tracker.js.map +1 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/tywrap.d.ts +17 -4
  36. package/dist/tywrap.d.ts.map +1 -1
  37. package/dist/tywrap.js +68 -35
  38. package/dist/tywrap.js.map +1 -1
  39. package/dist/utils/codec.d.ts +22 -0
  40. package/dist/utils/codec.d.ts.map +1 -1
  41. package/dist/utils/codec.js +245 -55
  42. package/dist/utils/codec.js.map +1 -1
  43. package/dist/utils/logger.d.ts.map +1 -1
  44. package/dist/utils/logger.js +7 -4
  45. package/dist/utils/logger.js.map +1 -1
  46. package/dist/utils/python.d.ts.map +1 -1
  47. package/dist/utils/python.js.map +1 -1
  48. package/package.json +11 -2
  49. package/runtime/python_bridge.py +450 -69
  50. package/src/cli.ts +81 -5
  51. package/src/config/index.ts +67 -16
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +21 -8
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +117 -224
  57. package/src/runtime/optimized-node.ts +160 -235
  58. package/src/runtime/timed-out-request-tracker.ts +58 -0
  59. package/src/types/index.ts +3 -0
  60. package/src/tywrap.ts +91 -36
  61. package/src/utils/codec.ts +306 -83
  62. package/src/utils/logger.ts +11 -7
  63. package/src/utils/python.ts +0 -1
@@ -5,34 +5,25 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createRequire } from 'node:module';
8
9
 
9
- import { decodeValueAsync } from '../utils/codec.js';
10
+ import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
10
11
  import { getDefaultPythonPath } from '../utils/python.js';
11
12
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
12
13
  import type { BridgeInfo } from '../types/index.js';
13
14
 
14
15
  import { RuntimeBridge } from './base.js';
16
+ import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
15
17
  import {
16
- BridgeDisposedError,
17
- BridgeExecutionError,
18
- BridgeProtocolError,
19
- BridgeTimeoutError,
20
- } from './errors.js';
21
- import { TYWRAP_PROTOCOL, TYWRAP_PROTOCOL_VERSION } from './protocol.js';
22
-
23
- interface RpcRequest {
24
- id: number;
25
- protocol: string;
26
- method: 'call' | 'instantiate' | 'call_method' | 'dispose_instance' | 'meta';
27
- params: unknown;
28
- }
29
-
30
- interface RpcResponse<T = unknown> {
31
- id: number;
32
- protocol: string;
33
- result?: T;
34
- error?: { type: string; message: string; traceback?: string };
35
- }
18
+ BridgeCore,
19
+ type RpcRequest,
20
+ ensureJsonFallback,
21
+ ensurePythonEncoding,
22
+ getMaxLineLengthFromEnv,
23
+ getPathKey,
24
+ normalizeEnv,
25
+ validateBridgeInfo,
26
+ } from './bridge-core.js';
36
27
 
37
28
  export interface NodeBridgeOptions {
38
29
  pythonPath?: string;
@@ -40,6 +31,8 @@ export interface NodeBridgeOptions {
40
31
  virtualEnv?: string;
41
32
  cwd?: string;
42
33
  timeoutMs?: number;
34
+ maxLineLength?: number;
35
+ inheritProcessEnv?: boolean;
43
36
  /**
44
37
  * When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
45
38
  * for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
@@ -57,6 +50,8 @@ interface ResolvedNodeBridgeOptions {
57
50
  virtualEnv?: string;
58
51
  cwd: string;
59
52
  timeoutMs: number;
53
+ maxLineLength?: number;
54
+ inheritProcessEnv: boolean;
60
55
  enableJsonFallback: boolean;
61
56
  env: Record<string, string | undefined>;
62
57
  }
@@ -85,15 +80,9 @@ function resolveVirtualEnv(
85
80
 
86
81
  export class NodeBridge extends RuntimeBridge {
87
82
  private child?: import('child_process').ChildProcess;
88
- private nextId = 1;
89
- private readonly pending = new Map<
90
- number,
91
- { resolve: (v: unknown) => void; reject: (e: unknown) => void; timer?: NodeJS.Timeout }
92
- >();
83
+ private core?: BridgeCore;
93
84
  private readonly options: ResolvedNodeBridgeOptions;
94
- private stderrBuffer = '';
95
85
  private disposed = false;
96
- private protocolError = false;
97
86
  private initPromise?: Promise<void>;
98
87
  private bridgeInfo?: BridgeInfo;
99
88
 
@@ -105,14 +94,13 @@ export class NodeBridge extends RuntimeBridge {
105
94
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
106
95
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
107
96
  this.options = {
108
- pythonPath:
109
- options.pythonPath ??
110
- venv?.pythonPath ??
111
- getDefaultPythonPath(),
97
+ pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
112
98
  scriptPath: resolvedScriptPath,
113
99
  virtualEnv,
114
100
  cwd,
115
101
  timeoutMs: options.timeoutMs ?? 30000,
102
+ maxLineLength: options.maxLineLength,
103
+ inheritProcessEnv: options.inheritProcessEnv ?? false,
116
104
  enableJsonFallback: options.enableJsonFallback ?? false,
117
105
  env: options.env ?? {},
118
106
  };
@@ -184,111 +172,30 @@ export class NodeBridge extends RuntimeBridge {
184
172
 
185
173
  async dispose(): Promise<void> {
186
174
  this.disposed = true;
187
- this.initPromise = undefined;
188
- this.bridgeInfo = undefined;
189
- if (!this.child) {
190
- return;
191
- }
192
- this.child.kill('SIGTERM');
193
- this.child = undefined;
175
+ this.core?.handleProcessExit();
176
+ this.resetProcess();
194
177
  }
195
178
 
196
179
  private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
197
180
  if (this.disposed) {
198
181
  throw new BridgeDisposedError('Bridge has been disposed');
199
182
  }
200
- const id = this.nextId++;
201
- const message: RpcRequest = { id, protocol: TYWRAP_PROTOCOL, ...payload } as RpcRequest;
202
- const text = `${JSON.stringify(message)}\n`;
203
- let timer: NodeJS.Timeout | undefined;
204
- const promise = new Promise<T>((resolvePromise, reject) => {
205
- timer = setTimeout(() => {
206
- this.pending.delete(id);
207
- const stderrTail = this.stderrBuffer.trim();
208
- const msg = stderrTail
209
- ? `Python call timed out. Recent stderr from Python:\n${stderrTail}`
210
- : 'Python call timed out';
211
- reject(new BridgeTimeoutError(msg));
212
- }, this.options.timeoutMs);
213
- const resolveWrapped = (v: unknown): void => {
214
- resolvePromise(v as T);
215
- };
216
- this.pending.set(id, { resolve: resolveWrapped, reject, timer });
217
- });
218
- try {
219
- if (!this.child?.stdin) {
220
- throw new BridgeProtocolError('Python process not available');
221
- }
222
- this.child.stdin.write(text);
223
- } catch (err) {
224
- this.pending.delete(id);
225
- if (timer) {
226
- clearTimeout(timer);
227
- }
228
- throw new BridgeProtocolError(`IPC failure: ${(err as Error).message}`);
229
- }
230
- return promise;
231
- }
232
-
233
- private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
234
- const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
235
- e.traceback = err.traceback;
236
- return e;
237
- }
238
-
239
- private handleProtocolError(details: string, line?: string): void {
240
- if (this.protocolError) {
241
- return;
242
- }
243
- this.protocolError = true;
244
- const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}…` : line) : undefined;
245
- const hint =
246
- 'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
247
- const msg = snippet
248
- ? `Protocol error from Python bridge. ${details}\n${hint}\nOffending line: ${snippet}`
249
- : `Protocol error from Python bridge. ${details}\n${hint}`;
250
- const error = new BridgeProtocolError(msg);
251
- for (const [, p] of this.pending) {
252
- p.reject(error);
183
+ if (!this.core) {
184
+ throw new BridgeProtocolError('Python process not available');
253
185
  }
254
- this.pending.clear();
255
- this.child?.kill('SIGTERM');
256
- this.child = undefined;
257
- this.initPromise = undefined;
258
- this.bridgeInfo = undefined;
186
+ return this.core.send<T>(payload);
259
187
  }
260
188
 
261
189
  private async startProcess(): Promise<void> {
262
190
  try {
191
+ const require = createRequire(import.meta.url);
192
+ await autoRegisterArrowDecoder({
193
+ loader: () => require('apache-arrow'),
194
+ });
263
195
  const { spawn } = await import('child_process');
264
- const allowedPrefixes = ['TYWRAP_'];
265
- const allowedKeys = new Set(['PATH', 'PYTHONPATH', 'VIRTUAL_ENV', 'PYTHONHOME']);
266
- const baseEnv = new Map<string, string | undefined>();
267
- for (const [k, v] of Object.entries(process.env)) {
268
- if (allowedKeys.has(k) || allowedPrefixes.some(p => k.startsWith(p))) {
269
- baseEnv.set(k, v);
270
- }
271
- }
272
- const env: NodeJS.ProcessEnv = {
273
- ...(Object.fromEntries(baseEnv) as NodeJS.ProcessEnv),
274
- ...this.options.env,
275
- };
276
- if (this.options.virtualEnv) {
277
- const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
278
- env.VIRTUAL_ENV = venv.venvPath;
279
- const currentPath = env.PATH ?? process.env.PATH ?? '';
280
- env.PATH = `${venv.binDir}${delimiter}${currentPath}`;
281
- }
282
- if (!env.PYTHONUTF8) {
283
- env.PYTHONUTF8 = '1';
284
- }
285
- if (!env.PYTHONIOENCODING) {
286
- env.PYTHONIOENCODING = 'UTF-8';
287
- }
288
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
289
- if (this.options.enableJsonFallback && !env.TYWRAP_CODEC_FALLBACK) {
290
- env.TYWRAP_CODEC_FALLBACK = 'json';
291
- }
196
+
197
+ const env = this.buildEnv();
198
+ const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
292
199
 
293
200
  let child: ReturnType<typeof spawn>;
294
201
  try {
@@ -310,122 +217,108 @@ export class NodeBridge extends RuntimeBridge {
310
217
  }
311
218
 
312
219
  this.child = child;
313
- this.protocolError = false;
314
-
315
- this.child?.on('error', err => {
316
- const msg = `Python process error: ${err instanceof Error ? err.message : String(err)}`;
317
- for (const [, p] of this.pending) {
318
- p.reject(new BridgeProtocolError(msg));
220
+ this.core = new BridgeCore(
221
+ {
222
+ write: (data: string): void => {
223
+ if (!this.child?.stdin) {
224
+ throw new BridgeProtocolError('Python process not available');
225
+ }
226
+ this.child.stdin.write(data);
227
+ },
228
+ },
229
+ {
230
+ timeoutMs: this.options.timeoutMs,
231
+ maxLineLength,
232
+ decodeValue: decodeValueAsync,
233
+ onFatalError: (): void => this.resetProcess(),
319
234
  }
320
- this.pending.clear();
321
- this.child = undefined;
322
- this.initPromise = undefined;
323
- this.bridgeInfo = undefined;
235
+ );
236
+
237
+ this.child.stdout?.on('data', chunk => {
238
+ this.core?.handleStdoutData(chunk);
324
239
  });
325
240
 
326
- let buffer = '';
327
- this.child.stdout?.on('data', (chunk: Buffer): void => {
328
- buffer += chunk.toString();
329
- let idx: number;
330
- while ((idx = buffer.indexOf('\n')) !== -1) {
331
- const line = buffer.slice(0, idx);
332
- buffer = buffer.slice(idx + 1);
333
- if (!line.trim()) {
334
- continue;
335
- }
336
- (async (): Promise<void> => {
337
- try {
338
- const msg = JSON.parse(line) as RpcResponse;
339
- if (msg.protocol !== TYWRAP_PROTOCOL) {
340
- this.handleProtocolError(
341
- `Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(
342
- msg.protocol
343
- )}`,
344
- line
345
- );
346
- return;
347
- }
348
- if (typeof msg.id !== 'number') {
349
- this.handleProtocolError('Invalid response id', line);
350
- return;
351
- }
352
- const pending = this.pending.get(msg.id);
353
- if (!pending) {
354
- this.handleProtocolError(`Unexpected response id ${msg.id}`, line);
355
- return;
356
- }
357
- this.pending.delete(msg.id);
358
- if (pending.timer) {
359
- clearTimeout(pending.timer);
360
- }
361
- if (msg.error) {
362
- pending.reject(this.errorFrom(msg.error));
363
- } else {
364
- try {
365
- const decoded = await decodeValueAsync(msg.result);
366
- pending.resolve(decoded);
367
- } catch (err) {
368
- pending.reject(
369
- new BridgeProtocolError(
370
- `Failed to decode Python response: ${
371
- err instanceof Error ? err.message : String(err)
372
- }`
373
- )
374
- );
375
- }
376
- }
377
- } catch (err) {
378
- const parseMessage = err instanceof Error ? err.message : String(err);
379
- this.handleProtocolError(`Invalid JSON: ${parseMessage}`, line);
380
- }
381
- })().catch(() => {
382
- /* ignore */
383
- });
384
- }
241
+ this.child.stderr?.on('data', chunk => {
242
+ this.core?.handleStderrData(chunk);
385
243
  });
386
244
 
387
- this.child?.stderr?.on('data', (chunk: Buffer) => {
388
- // Buffer stderr for better error diagnostics on failures/exits
389
- try {
390
- this.stderrBuffer += chunk.toString();
391
- // Truncate to last 8KB to avoid unbounded growth
392
- const MAX = 8 * 1024;
393
- if (this.stderrBuffer.length > MAX) {
394
- this.stderrBuffer = this.stderrBuffer.slice(this.stderrBuffer.length - MAX);
395
- }
396
- } catch {
397
- // ignore
398
- }
245
+ this.child.on('error', err => {
246
+ this.core?.handleProcessError(err);
399
247
  });
400
248
 
401
- this.child?.on('exit', () => {
402
- for (const [, p] of this.pending) {
403
- const stderrTail = this.stderrBuffer.trim();
404
- const msg = stderrTail
405
- ? `Python process exited. Stderr:\n${stderrTail}`
406
- : 'Python process exited';
407
- p.reject(new BridgeProtocolError(msg));
408
- }
409
- this.pending.clear();
410
- this.child = undefined;
411
- this.initPromise = undefined;
412
- this.bridgeInfo = undefined;
249
+ this.child.on('exit', () => {
250
+ this.core?.handleProcessExit();
251
+ this.resetProcess();
413
252
  });
414
253
 
415
254
  await this.refreshBridgeInfo();
416
255
  } catch (err) {
417
- this.child?.kill('SIGTERM');
418
- this.child = undefined;
419
- this.initPromise = undefined;
256
+ this.resetProcess();
420
257
  throw err;
421
258
  }
422
259
  }
423
260
 
261
+ private buildEnv(): NodeJS.ProcessEnv {
262
+ const allowedPrefixes = ['TYWRAP_'];
263
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
264
+ const baseEnv: Record<string, string | undefined> = {};
265
+ if (this.options.inheritProcessEnv) {
266
+ for (const [key, value] of Object.entries(process.env)) {
267
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
268
+ baseEnv[key] = value;
269
+ }
270
+ } else {
271
+ for (const [key, value] of Object.entries(process.env)) {
272
+ if (
273
+ allowedKeys.has(key.toLowerCase()) ||
274
+ allowedPrefixes.some(prefix => key.startsWith(prefix))
275
+ ) {
276
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
277
+ baseEnv[key] = value;
278
+ }
279
+ }
280
+ }
281
+
282
+ let env = normalizeEnv(baseEnv, this.options.env);
283
+
284
+ if (this.options.virtualEnv) {
285
+ const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
286
+ env.VIRTUAL_ENV = venv.venvPath;
287
+ const pathKey = getPathKey(env);
288
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
289
+ const currentPath = env[pathKey] ?? '';
290
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
291
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
292
+ }
293
+
294
+ ensurePythonEncoding(env);
295
+ // Respect explicit request for JSON fallback only; otherwise fast-fail by default
296
+ ensureJsonFallback(env, this.options.enableJsonFallback);
297
+
298
+ env = normalizeEnv(env, {});
299
+ return env;
300
+ }
301
+
302
+ private resetProcess(): void {
303
+ this.core?.clear();
304
+ this.core = undefined;
305
+ if (this.child) {
306
+ try {
307
+ if (this.child.exitCode === null) {
308
+ this.child.kill('SIGTERM');
309
+ }
310
+ } catch {
311
+ // ignore
312
+ }
313
+ }
314
+ this.child = undefined;
315
+ this.initPromise = undefined;
316
+ this.bridgeInfo = undefined;
317
+ }
318
+
424
319
  private async refreshBridgeInfo(): Promise<void> {
425
320
  const info = await this.send<BridgeInfo>({ method: 'meta', params: {} });
426
- if (info.protocol !== TYWRAP_PROTOCOL || info.protocolVersion !== TYWRAP_PROTOCOL_VERSION) {
427
- throw new BridgeProtocolError('Invalid bridge info payload');
428
- }
321
+ validateBridgeInfo(info);
429
322
  this.bridgeInfo = info;
430
323
  }
431
324
  }