tywrap 0.1.1 → 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 +23 -3
  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 +47 -11
  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 +16 -4
  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 +94 -190
  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 -194
  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 +1 -0
  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 +75 -3
  51. package/src/config/index.ts +58 -15
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +16 -4
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +116 -220
  57. package/src/runtime/optimized-node.ts +160 -243
  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 +299 -82
  62. package/src/utils/logger.ts +1 -0
  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
 
@@ -110,6 +99,8 @@ export class NodeBridge extends RuntimeBridge {
110
99
  virtualEnv,
111
100
  cwd,
112
101
  timeoutMs: options.timeoutMs ?? 30000,
102
+ maxLineLength: options.maxLineLength,
103
+ inheritProcessEnv: options.inheritProcessEnv ?? false,
113
104
  enableJsonFallback: options.enableJsonFallback ?? false,
114
105
  env: options.env ?? {},
115
106
  };
@@ -181,111 +172,30 @@ export class NodeBridge extends RuntimeBridge {
181
172
 
182
173
  async dispose(): Promise<void> {
183
174
  this.disposed = true;
184
- this.initPromise = undefined;
185
- this.bridgeInfo = undefined;
186
- if (!this.child) {
187
- return;
188
- }
189
- this.child.kill('SIGTERM');
190
- this.child = undefined;
175
+ this.core?.handleProcessExit();
176
+ this.resetProcess();
191
177
  }
192
178
 
193
179
  private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
194
180
  if (this.disposed) {
195
181
  throw new BridgeDisposedError('Bridge has been disposed');
196
182
  }
197
- const id = this.nextId++;
198
- const message: RpcRequest = { id, protocol: TYWRAP_PROTOCOL, ...payload } as RpcRequest;
199
- const text = `${JSON.stringify(message)}\n`;
200
- let timer: NodeJS.Timeout | undefined;
201
- const promise = new Promise<T>((resolvePromise, reject) => {
202
- timer = setTimeout(() => {
203
- this.pending.delete(id);
204
- const stderrTail = this.stderrBuffer.trim();
205
- const msg = stderrTail
206
- ? `Python call timed out. Recent stderr from Python:\n${stderrTail}`
207
- : 'Python call timed out';
208
- reject(new BridgeTimeoutError(msg));
209
- }, this.options.timeoutMs);
210
- const resolveWrapped = (v: unknown): void => {
211
- resolvePromise(v as T);
212
- };
213
- this.pending.set(id, { resolve: resolveWrapped, reject, timer });
214
- });
215
- try {
216
- if (!this.child?.stdin) {
217
- throw new BridgeProtocolError('Python process not available');
218
- }
219
- this.child.stdin.write(text);
220
- } catch (err) {
221
- this.pending.delete(id);
222
- if (timer) {
223
- clearTimeout(timer);
224
- }
225
- throw new BridgeProtocolError(`IPC failure: ${(err as Error).message}`);
226
- }
227
- return promise;
228
- }
229
-
230
- private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
231
- const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
232
- e.traceback = err.traceback;
233
- return e;
234
- }
235
-
236
- private handleProtocolError(details: string, line?: string): void {
237
- if (this.protocolError) {
238
- return;
239
- }
240
- this.protocolError = true;
241
- const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}…` : line) : undefined;
242
- const hint =
243
- 'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
244
- const msg = snippet
245
- ? `Protocol error from Python bridge. ${details}\n${hint}\nOffending line: ${snippet}`
246
- : `Protocol error from Python bridge. ${details}\n${hint}`;
247
- const error = new BridgeProtocolError(msg);
248
- for (const [, p] of this.pending) {
249
- p.reject(error);
183
+ if (!this.core) {
184
+ throw new BridgeProtocolError('Python process not available');
250
185
  }
251
- this.pending.clear();
252
- this.child?.kill('SIGTERM');
253
- this.child = undefined;
254
- this.initPromise = undefined;
255
- this.bridgeInfo = undefined;
186
+ return this.core.send<T>(payload);
256
187
  }
257
188
 
258
189
  private async startProcess(): Promise<void> {
259
190
  try {
191
+ const require = createRequire(import.meta.url);
192
+ await autoRegisterArrowDecoder({
193
+ loader: () => require('apache-arrow'),
194
+ });
260
195
  const { spawn } = await import('child_process');
261
- const allowedPrefixes = ['TYWRAP_'];
262
- const allowedKeys = new Set(['PATH', 'PYTHONPATH', 'VIRTUAL_ENV', 'PYTHONHOME']);
263
- const baseEnv = new Map<string, string | undefined>();
264
- for (const [k, v] of Object.entries(process.env)) {
265
- if (allowedKeys.has(k) || allowedPrefixes.some(p => k.startsWith(p))) {
266
- baseEnv.set(k, v);
267
- }
268
- }
269
- const env: NodeJS.ProcessEnv = {
270
- ...(Object.fromEntries(baseEnv) as NodeJS.ProcessEnv),
271
- ...this.options.env,
272
- };
273
- if (this.options.virtualEnv) {
274
- const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
275
- env.VIRTUAL_ENV = venv.venvPath;
276
- const currentPath = env.PATH ?? process.env.PATH ?? '';
277
- env.PATH = `${venv.binDir}${delimiter}${currentPath}`;
278
- }
279
- if (!env.PYTHONUTF8) {
280
- env.PYTHONUTF8 = '1';
281
- }
282
- if (!env.PYTHONIOENCODING) {
283
- env.PYTHONIOENCODING = 'UTF-8';
284
- }
285
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
286
- if (this.options.enableJsonFallback && !env.TYWRAP_CODEC_FALLBACK) {
287
- env.TYWRAP_CODEC_FALLBACK = 'json';
288
- }
196
+
197
+ const env = this.buildEnv();
198
+ const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
289
199
 
290
200
  let child: ReturnType<typeof spawn>;
291
201
  try {
@@ -307,122 +217,108 @@ export class NodeBridge extends RuntimeBridge {
307
217
  }
308
218
 
309
219
  this.child = child;
310
- this.protocolError = false;
311
-
312
- this.child?.on('error', err => {
313
- const msg = `Python process error: ${err instanceof Error ? err.message : String(err)}`;
314
- for (const [, p] of this.pending) {
315
- 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(),
316
234
  }
317
- this.pending.clear();
318
- this.child = undefined;
319
- this.initPromise = undefined;
320
- this.bridgeInfo = undefined;
235
+ );
236
+
237
+ this.child.stdout?.on('data', chunk => {
238
+ this.core?.handleStdoutData(chunk);
321
239
  });
322
240
 
323
- let buffer = '';
324
- this.child.stdout?.on('data', (chunk: Buffer): void => {
325
- buffer += chunk.toString();
326
- let idx: number;
327
- while ((idx = buffer.indexOf('\n')) !== -1) {
328
- const line = buffer.slice(0, idx);
329
- buffer = buffer.slice(idx + 1);
330
- if (!line.trim()) {
331
- continue;
332
- }
333
- (async (): Promise<void> => {
334
- try {
335
- const msg = JSON.parse(line) as RpcResponse;
336
- if (msg.protocol !== TYWRAP_PROTOCOL) {
337
- this.handleProtocolError(
338
- `Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(
339
- msg.protocol
340
- )}`,
341
- line
342
- );
343
- return;
344
- }
345
- if (typeof msg.id !== 'number') {
346
- this.handleProtocolError('Invalid response id', line);
347
- return;
348
- }
349
- const pending = this.pending.get(msg.id);
350
- if (!pending) {
351
- this.handleProtocolError(`Unexpected response id ${msg.id}`, line);
352
- return;
353
- }
354
- this.pending.delete(msg.id);
355
- if (pending.timer) {
356
- clearTimeout(pending.timer);
357
- }
358
- if (msg.error) {
359
- pending.reject(this.errorFrom(msg.error));
360
- } else {
361
- try {
362
- const decoded = await decodeValueAsync(msg.result);
363
- pending.resolve(decoded);
364
- } catch (err) {
365
- pending.reject(
366
- new BridgeProtocolError(
367
- `Failed to decode Python response: ${
368
- err instanceof Error ? err.message : String(err)
369
- }`
370
- )
371
- );
372
- }
373
- }
374
- } catch (err) {
375
- const parseMessage = err instanceof Error ? err.message : String(err);
376
- this.handleProtocolError(`Invalid JSON: ${parseMessage}`, line);
377
- }
378
- })().catch(() => {
379
- /* ignore */
380
- });
381
- }
241
+ this.child.stderr?.on('data', chunk => {
242
+ this.core?.handleStderrData(chunk);
382
243
  });
383
244
 
384
- this.child?.stderr?.on('data', (chunk: Buffer) => {
385
- // Buffer stderr for better error diagnostics on failures/exits
386
- try {
387
- this.stderrBuffer += chunk.toString();
388
- // Truncate to last 8KB to avoid unbounded growth
389
- const MAX = 8 * 1024;
390
- if (this.stderrBuffer.length > MAX) {
391
- this.stderrBuffer = this.stderrBuffer.slice(this.stderrBuffer.length - MAX);
392
- }
393
- } catch {
394
- // ignore
395
- }
245
+ this.child.on('error', err => {
246
+ this.core?.handleProcessError(err);
396
247
  });
397
248
 
398
- this.child?.on('exit', () => {
399
- for (const [, p] of this.pending) {
400
- const stderrTail = this.stderrBuffer.trim();
401
- const msg = stderrTail
402
- ? `Python process exited. Stderr:\n${stderrTail}`
403
- : 'Python process exited';
404
- p.reject(new BridgeProtocolError(msg));
405
- }
406
- this.pending.clear();
407
- this.child = undefined;
408
- this.initPromise = undefined;
409
- this.bridgeInfo = undefined;
249
+ this.child.on('exit', () => {
250
+ this.core?.handleProcessExit();
251
+ this.resetProcess();
410
252
  });
411
253
 
412
254
  await this.refreshBridgeInfo();
413
255
  } catch (err) {
414
- this.child?.kill('SIGTERM');
415
- this.child = undefined;
416
- this.initPromise = undefined;
256
+ this.resetProcess();
417
257
  throw err;
418
258
  }
419
259
  }
420
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
+
421
319
  private async refreshBridgeInfo(): Promise<void> {
422
320
  const info = await this.send<BridgeInfo>({ method: 'meta', params: {} });
423
- if (info.protocol !== TYWRAP_PROTOCOL || info.protocolVersion !== TYWRAP_PROTOCOL_VERSION) {
424
- throw new BridgeProtocolError('Invalid bridge info payload');
425
- }
321
+ validateBridgeInfo(info);
426
322
  this.bridgeInfo = info;
427
323
  }
428
324
  }