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
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
- import { writeFile } from 'node:fs/promises';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import yargs, { type Argv, type ArgumentsCamelCase } from 'yargs';
6
6
  import { hideBin } from 'yargs/helpers';
@@ -99,6 +99,46 @@ ${moduleLines}
99
99
  `;
100
100
  }
101
101
 
102
+ async function addRecommendedScriptsToPackageJson(cwd: string): Promise<void> {
103
+ const packageJsonPath = resolve(cwd, 'package.json');
104
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is derived from cwd
105
+ if (!existsSync(packageJsonPath)) {
106
+ return;
107
+ }
108
+ try {
109
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is derived from cwd
110
+ const raw = await readFile(packageJsonPath, 'utf-8');
111
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
112
+ const scripts =
113
+ typeof pkg.scripts === 'object' && pkg.scripts !== null && !Array.isArray(pkg.scripts)
114
+ ? (pkg.scripts as Record<string, unknown>)
115
+ : {};
116
+ const nextScripts: Record<string, unknown> = { ...scripts };
117
+ let changed = false;
118
+
119
+ if (nextScripts['tywrap:generate'] === undefined) {
120
+ nextScripts['tywrap:generate'] = 'tywrap generate';
121
+ changed = true;
122
+ }
123
+ if (nextScripts['tywrap:check'] === undefined) {
124
+ nextScripts['tywrap:check'] = 'tywrap generate --check';
125
+ changed = true;
126
+ }
127
+
128
+ if (!changed) {
129
+ return;
130
+ }
131
+
132
+ pkg.scripts = nextScripts;
133
+ const out = `${JSON.stringify(pkg, null, 2)}\n`;
134
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is derived from cwd
135
+ await writeFile(packageJsonPath, out, 'utf-8');
136
+ } catch (err: unknown) {
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ log.warn('Failed to update package.json scripts', { error: message });
139
+ }
140
+ }
141
+
102
142
  async function main(): Promise<void> {
103
143
  await yargs(hideBin(process.argv))
104
144
  .scriptName('tywrap')
@@ -157,6 +197,11 @@ async function main(): Promise<void> {
157
197
  default: false,
158
198
  describe: 'Exit with code 2 if generation emits warnings',
159
199
  })
200
+ .option('check', {
201
+ type: 'boolean',
202
+ default: false,
203
+ describe: 'Check whether generated wrappers are up to date without writing files',
204
+ })
160
205
  .strict(),
161
206
  async (
162
207
  argv: ArgumentsCamelCase<{
@@ -171,6 +216,7 @@ async function main(): Promise<void> {
171
216
  useCache?: boolean;
172
217
  debug?: boolean;
173
218
  failOnWarn: boolean;
219
+ check: boolean;
174
220
  }>
175
221
  ) => {
176
222
  const { configPath, explicit } = resolveConfigPath(argv.config);
@@ -212,7 +258,9 @@ async function main(): Promise<void> {
212
258
  }
213
259
 
214
260
  if (!configPath && modules.length === 0) {
215
- log.error('No config file found and no modules provided. Create a config with `tywrap init` or pass --modules.');
261
+ log.error(
262
+ 'No config file found and no modules provided. Create a config with `tywrap init` or pass --modules.'
263
+ );
216
264
  process.exit(1);
217
265
  }
218
266
 
@@ -230,10 +278,28 @@ async function main(): Promise<void> {
230
278
  process.exit(1);
231
279
  }
232
280
 
233
- const res = await generate(options);
234
- process.stdout.write(`Generated: ${res.written.join(', ')}\n`);
281
+ const res = await generate(options, { check: argv.check });
282
+ if (argv.check) {
283
+ const outOfDate = res.outOfDate ?? [];
284
+ if (outOfDate.length === 0) {
285
+ process.stdout.write('Generated wrappers are up to date.\n');
286
+ } else {
287
+ process.stderr.write('Generated wrappers are out of date:\n');
288
+ for (const file of outOfDate) {
289
+ process.stderr.write(`- ${file}\n`);
290
+ }
291
+ process.stderr.write('\nRun `tywrap generate` to update.\n');
292
+ if (!(argv.failOnWarn && res.warnings.length > 0)) {
293
+ process.exit(3);
294
+ }
295
+ }
296
+ } else {
297
+ process.stdout.write(`Generated: ${res.written.join(', ')}\n`);
298
+ }
235
299
  if (argv.failOnWarn && res.warnings.length > 0) {
236
- log.error(`Warnings encountered (count ${res.warnings.length}). Failing due to --fail-on-warn.`);
300
+ log.error(
301
+ `Warnings encountered (count ${res.warnings.length}). Failing due to --fail-on-warn.`
302
+ );
237
303
  process.exit(2);
238
304
  }
239
305
  } catch (err: unknown) {
@@ -279,6 +345,12 @@ async function main(): Promise<void> {
279
345
  default: false,
280
346
  describe: 'Overwrite existing config file',
281
347
  })
348
+ .option('scripts', {
349
+ type: 'boolean',
350
+ default: true,
351
+ describe:
352
+ 'Add recommended tywrap scripts to package.json (use --no-scripts to disable)',
353
+ })
282
354
  .strict(),
283
355
  async (
284
356
  argv: ArgumentsCamelCase<{
@@ -288,6 +360,7 @@ async function main(): Promise<void> {
288
360
  runtime: RuntimeStrategy;
289
361
  outputDir: string;
290
362
  force: boolean;
363
+ scripts: boolean;
291
364
  }>
292
365
  ) => {
293
366
  const modules = parseModules(argv.modules);
@@ -311,6 +384,9 @@ async function main(): Promise<void> {
311
384
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- config path is user-controlled
312
385
  await writeFile(targetPath, content, 'utf-8');
313
386
  process.stdout.write(`Created ${targetPath}\n`);
387
+ if (argv.scripts) {
388
+ await addRecommendedScriptsToPackageJson(process.cwd());
389
+ }
314
390
  } catch (err: unknown) {
315
391
  const message = err instanceof Error ? err.message : String(err);
316
392
  log.error('Failed to write config', { error: message });
@@ -5,8 +5,9 @@
5
5
  * with basic validation of known options.
6
6
  */
7
7
 
8
+ import { randomUUID } from 'node:crypto';
8
9
  import { existsSync } from 'node:fs';
9
- import { readFile } from 'node:fs/promises';
10
+ import { readFile, rm, writeFile } from 'node:fs/promises';
10
11
  import { createRequire } from 'node:module';
11
12
  import { dirname, extname, resolve } from 'node:path';
12
13
  import { pathToFileURL } from 'node:url';
@@ -149,7 +150,15 @@ function validateConfig(config: TywrapConfig): void {
149
150
 
150
151
  if (config.types) {
151
152
  const presets = config.types.presets;
152
- const validPresets = new Set(['numpy', 'pandas', 'pydantic', 'stdlib', 'scipy', 'torch', 'sklearn']);
153
+ const validPresets = new Set([
154
+ 'numpy',
155
+ 'pandas',
156
+ 'pydantic',
157
+ 'stdlib',
158
+ 'scipy',
159
+ 'torch',
160
+ 'sklearn',
161
+ ]);
153
162
  if (presets !== undefined) {
154
163
  if (!Array.isArray(presets) || presets.some(p => typeof p !== 'string')) {
155
164
  throw new Error('types.presets must be an array of strings');
@@ -250,27 +259,69 @@ export async function loadConfigFile(configFile: string): Promise<Partial<Tywrap
250
259
  if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
251
260
  const ts = await import('typescript');
252
261
  const source = await safeReadFileAsync(resolved);
262
+ // Why: many configs want to `import { defineConfig } from 'tywrap'`. The tywrap package is ESM,
263
+ // so evaluating a transpiled CommonJS config would try `require('tywrap')` and fail. Treat
264
+ // `.ts`/`.mts` configs as ESM, and only treat `.cts` as CommonJS to match Node conventions.
265
+ const emitCommonJs = ext === '.cts';
253
266
  const output = ts.transpileModule(source, {
254
267
  compilerOptions: {
255
- module: ts.ModuleKind.CommonJS,
268
+ module: emitCommonJs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ES2020,
256
269
  target: ts.ScriptTarget.ES2020,
257
270
  esModuleInterop: true,
258
271
  },
259
272
  fileName: resolved,
260
273
  });
261
- const require = createRequire(import.meta.url);
262
- const nodeModule = require('module') as typeof import('module');
263
- const moduleCtor = nodeModule.Module as unknown as typeof import('module').Module & {
264
- _nodeModulePaths: (path: string) => string[];
265
- };
266
- const mod = new moduleCtor(resolved) as import('module').Module & {
267
- _compile: (code: string, filename: string) => void;
268
- };
269
- mod.filename = resolved;
270
- mod.paths = moduleCtor._nodeModulePaths(dirname(resolved));
271
- mod._compile(output.outputText, resolved);
272
- const loaded = (mod.exports as Record<string, unknown>).default ?? mod.exports;
273
- return ensureConfigObject(loaded ?? {}, resolved);
274
+
275
+ if (emitCommonJs) {
276
+ // Why: `.cts` is explicitly CommonJS. We evaluate the transpiled output in-memory using
277
+ // Node's Module internals (`Module._compile` / `_nodeModulePaths`) to avoid writing an extra
278
+ // temp file. These are private Node APIs, so we keep this tooling path scoped and rely on
279
+ // supported Node versions (see package.json engines).
280
+ const require = createRequire(import.meta.url);
281
+ const nodeModule = require('module') as typeof import('module');
282
+ const moduleCtor = nodeModule.Module as unknown as typeof import('module').Module & {
283
+ _nodeModulePaths?: (path: string) => string[];
284
+ };
285
+ if (typeof moduleCtor !== 'function') {
286
+ throw new Error(
287
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node Module constructor'
288
+ );
289
+ }
290
+ const nodeModulePaths = moduleCtor._nodeModulePaths;
291
+ if (typeof nodeModulePaths !== 'function') {
292
+ throw new Error(
293
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._nodeModulePaths'
294
+ );
295
+ }
296
+ const mod = new moduleCtor(resolved) as import('module').Module & {
297
+ _compile?: (code: string, filename: string) => void;
298
+ };
299
+ const compile = mod._compile;
300
+ if (typeof compile !== 'function') {
301
+ throw new Error(
302
+ '[tywrap] Unable to evaluate .cts config in-memory (emitCommonJs=true): missing Node private API Module._compile'
303
+ );
304
+ }
305
+ mod.filename = resolved;
306
+ mod.paths = nodeModulePaths(dirname(resolved));
307
+ compile.call(mod, output.outputText, resolved);
308
+ const loaded = (mod.exports as Record<string, unknown>).default ?? mod.exports;
309
+ return ensureConfigObject(loaded ?? {}, resolved);
310
+ }
311
+
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`);
316
+ 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');
319
+ const mod = (await import(pathToFileURL(tmpPath).href)) as Record<string, unknown>;
320
+ const loaded = mod.default ?? mod;
321
+ return ensureConfigObject(loaded ?? {}, resolved);
322
+ } finally {
323
+ await rm(tmpPath, { force: true });
324
+ }
274
325
  }
275
326
 
276
327
  throw new Error(`Unsupported configuration file extension: ${ext}`);
@@ -141,12 +141,16 @@ export class CodeGenerator {
141
141
  const fname = this.escapeIdentifier(func.name);
142
142
  const moduleId = moduleName ?? '__main__';
143
143
 
144
- // Overloads: generate trailing optional parameter drop variants (exclude *args/**kwargs)
144
+ // Overloads: generate trailing optional parameter drop variants (exclude *args/**kwargs).
145
+ // Why: Python APIs frequently have many optional tail params. TypeScript callers expect
146
+ // `fn(a)`, `fn(a, b)`, ... all to typecheck. We emit a family of overloads that progressively
147
+ // "drop" optional tail args, but also include the full positional signature (<= length) so a
148
+ // call that supplies all args still matches an overload.
145
149
  const positional = filteredParams.filter(p => !p.varArgs && !p.kwArgs);
146
150
  const firstOptionalIndex = positional.findIndex(p => p.optional);
147
151
  const overloads: string[] = [];
148
152
  if (firstOptionalIndex >= 0) {
149
- for (let i = firstOptionalIndex; i < positional.length; i++) {
153
+ for (let i = firstOptionalIndex; i <= positional.length; i++) {
150
154
  const head = positional.slice(0, i);
151
155
  const rest = filteredParams.filter(p => p.varArgs || p.kwArgs);
152
156
  const sigParams = [...head, ...rest].map(renderParam).join(', ');
@@ -267,7 +267,8 @@ export class TypeMapper {
267
267
  }
268
268
 
269
269
  // Forward references and user types
270
- return { kind: 'custom', name: type.name, module: type.module };
270
+ const normalized = this.normalizeCustomType(type);
271
+ return { kind: 'custom', name: normalized.name, module: normalized.module };
271
272
  }
272
273
 
273
274
  mapCallableType(type: {
@@ -348,15 +349,26 @@ export class TypeMapper {
348
349
  module?: string;
349
350
  rawName: string;
350
351
  } {
352
+ // Why: the Python IR sometimes represents qualified names like "pandas.DataFrame" as a single
353
+ // `name` string (with dots) instead of splitting into { module, name }. Dots are not valid in
354
+ // TypeScript identifiers, and we want stable test expectations, so normalize to a leaf `name`
355
+ // plus a `module` path when possible.
351
356
  const rawName = type.name;
352
357
  if (type.module) {
353
358
  return { name: type.name, module: type.module, rawName };
354
359
  }
355
360
  if (rawName.includes('.')) {
356
- const parts = rawName.split('.');
361
+ const parts = rawName.split('.').filter(part => part.length > 0);
362
+ if (parts.length === 0) {
363
+ return { name: rawName, module: undefined, rawName };
364
+ }
365
+ const name = parts[parts.length - 1];
366
+ if (!name) {
367
+ return { name: rawName, module: undefined, rawName };
368
+ }
357
369
  return {
358
- name: parts[parts.length - 1] ?? rawName,
359
- module: parts.slice(0, -1).join('.'),
370
+ name,
371
+ module: parts.length > 1 ? parts.slice(0, -1).join('.') : undefined,
360
372
  rawName,
361
373
  };
362
374
  }
@@ -441,9 +453,7 @@ export class TypeMapper {
441
453
  const allowModule = !moduleName || moduleName.startsWith('scipy');
442
454
  if (allowModule) {
443
455
  const baseProps = [prop('shape', numberArray), prop('dtype', stringType, true)];
444
- const buildSparse = (
445
- format: 'csr' | 'csc' | 'coo'
446
- ): TSObjectType => ({
456
+ const buildSparse = (format: 'csr' | 'csc' | 'coo'): TSObjectType => ({
447
457
  kind: 'object',
448
458
  properties: [
449
459
  prop('format', { kind: 'literal', value: format }),
@@ -464,7 +474,10 @@ export class TypeMapper {
464
474
  return buildSparse('coo');
465
475
  }
466
476
  if (name === 'spmatrix') {
467
- return { kind: 'union', types: [buildSparse('csr'), buildSparse('csc'), buildSparse('coo')] };
477
+ return {
478
+ kind: 'union',
479
+ types: [buildSparse('csr'), buildSparse('csc'), buildSparse('coo')],
480
+ };
468
481
  }
469
482
  }
470
483
  }
package/src/index.ts CHANGED
@@ -69,12 +69,13 @@ export { detectRuntime, isNodejs, isDeno, isBun, isBrowser } from './utils/runti
69
69
  export {
70
70
  decodeValue,
71
71
  decodeValueAsync,
72
+ autoRegisterArrowDecoder,
72
73
  registerArrowDecoder,
73
74
  clearArrowDecoder,
74
75
  } from './utils/codec.js';
75
76
 
76
77
  // Version info
77
- export const VERSION = '0.1.0';
78
+ export const VERSION = '0.1.2';
78
79
 
79
80
  /**
80
81
  * Quick setup function for getting started