pynect 0.2.6 → 0.2.9

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 (70) hide show
  1. package/README.md +50 -4
  2. package/binding.gyp +2 -1
  3. package/dist/cli/codegen.d.ts +3 -0
  4. package/dist/cli/codegen.d.ts.map +1 -1
  5. package/dist/cli/codegen.js +12 -0
  6. package/dist/cli/codegen.js.map +1 -1
  7. package/dist/config/project.d.ts +7 -0
  8. package/dist/config/project.d.ts.map +1 -1
  9. package/dist/config/project.js +23 -2
  10. package/dist/config/project.js.map +1 -1
  11. package/dist/core/py-proxy.d.ts +1 -1
  12. package/dist/core/py-proxy.d.ts.map +1 -1
  13. package/dist/core/py-proxy.js +40 -7
  14. package/dist/core/py-proxy.js.map +1 -1
  15. package/dist/core/py-runtime.d.ts +7 -0
  16. package/dist/core/py-runtime.d.ts.map +1 -1
  17. package/dist/core/py-runtime.js +50 -3
  18. package/dist/core/py-runtime.js.map +1 -1
  19. package/dist/core/pynect-callback.d.ts +25 -0
  20. package/dist/core/pynect-callback.d.ts.map +1 -0
  21. package/dist/core/pynect-callback.js +99 -0
  22. package/dist/core/pynect-callback.js.map +1 -0
  23. package/dist/env/project-environment.d.ts.map +1 -1
  24. package/dist/env/project-environment.js +1 -0
  25. package/dist/env/project-environment.js.map +1 -1
  26. package/dist/index.d.ts +7 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/native/native-addon-loader.d.ts.map +1 -1
  31. package/dist/native/native-addon-loader.js +34 -10
  32. package/dist/native/native-addon-loader.js.map +1 -1
  33. package/dist/types/addon.d.ts +10 -0
  34. package/dist/types/addon.d.ts.map +1 -1
  35. package/dist/types/addon.js.map +1 -1
  36. package/dist/types/config.d.ts +10 -0
  37. package/dist/types/config.d.ts.map +1 -1
  38. package/dist/types/config.js +6 -0
  39. package/dist/types/config.js.map +1 -1
  40. package/dist/types/index.d.ts +1 -1
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/dist/types/index.js.map +1 -1
  43. package/dist/types/python.d.ts +2 -0
  44. package/dist/types/python.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/prebuilds/darwin-arm64/pynect-prebuild.json +5 -4
  47. package/prebuilds/darwin-arm64/pynect_addon.node +0 -0
  48. package/prebuilds/darwin-x64/pynect-prebuild.json +5 -4
  49. package/prebuilds/darwin-x64/pynect_addon.node +0 -0
  50. package/prebuilds/linux-arm64/pynect-prebuild.json +5 -4
  51. package/prebuilds/linux-arm64/pynect_addon.node +0 -0
  52. package/prebuilds/linux-x64/pynect-prebuild.json +5 -4
  53. package/prebuilds/linux-x64/pynect_addon.node +0 -0
  54. package/prebuilds/win32-arm64/pynect-prebuild.json +5 -4
  55. package/prebuilds/win32-arm64/pynect_addon.node +0 -0
  56. package/prebuilds/win32-x64/pynect-prebuild.json +5 -4
  57. package/prebuilds/win32-x64/pynect_addon.node +0 -0
  58. package/python/type_inspector.py +12 -0
  59. package/scripts/build-native.js +9 -3
  60. package/scripts/ensure-native.js +8 -2
  61. package/scripts/smoke-python-boundary.js +71 -1
  62. package/src/native/callback_bridge.h +11 -0
  63. package/src/native/native-addon-loader.ts +39 -11
  64. package/src/native/pynect_addon.cpp +354 -4
  65. package/src/native/python_bridge.cpp +117 -40
  66. package/src/native/python_bridge.h +13 -0
  67. package/src/native/thread_pool.cpp +62 -55
  68. package/src/native/thread_pool.h +13 -0
  69. package/src/native/type_converter.cpp +32 -22
  70. package/src/native/type_converter.h +2 -0
@@ -14,6 +14,12 @@ async function main() {
14
14
  pythonPath: process.env.PYNECT_PYTHON,
15
15
  });
16
16
  const embedding = python.embedding;
17
+ const stableAbiLibrary = process.platform === 'win32'
18
+ ? embedding.stableAbiLibraryPath || path.join(embedding.libraryDirectory, 'python3.lib')
19
+ : '';
20
+ const linkLibrary = stableAbiLibrary && fs.existsSync(stableAbiLibrary)
21
+ ? stableAbiLibrary
22
+ : embedding.libraryPath;
17
23
  const nodeGyp = require.resolve('node-gyp/bin/node-gyp.js');
18
24
  const mode = process.argv.includes('--debug') ? '--debug' : '--release';
19
25
  const args = [nodeGyp, 'rebuild', mode];
@@ -24,8 +30,8 @@ async function main() {
24
30
  npm_config_python: python.executable,
25
31
  PYTHON: python.executable,
26
32
  PYNECT_PYTHON_INCLUDE_DIR: embedding.includeDirectory,
27
- PYNECT_PYTHON_LIBRARY: embedding.libraryPath,
28
- PYNECT_PYTHON_LIBRARY_DIR: embedding.libraryDirectory,
33
+ PYNECT_PYTHON_LIBRARY: linkLibrary,
34
+ PYNECT_PYTHON_LIBRARY_DIR: path.dirname(linkLibrary),
29
35
  PATH: [embedding.libraryDirectory, path.dirname(python.executable), process.env.PATH || '']
30
36
  .filter(Boolean)
31
37
  .join(path.delimiter),
@@ -40,7 +46,7 @@ async function main() {
40
46
  schemaVersion: 1,
41
47
  pythonExecutable: path.resolve(python.executable),
42
48
  pythonVersion: python.version,
43
- pythonLibrary: path.resolve(embedding.libraryPath),
49
+ pythonLibrary: path.resolve(linkLibrary),
44
50
  buildType: mode === '--debug' ? 'Debug' : 'Release',
45
51
  }, null, 2)}\n`,
46
52
  );
@@ -7,6 +7,7 @@ const { spawnSync } = require('child_process');
7
7
  const ROOT = path.resolve(__dirname, '..');
8
8
  const BUILD_DIRECTORY = path.join(ROOT, 'build');
9
9
  const METADATA = path.join(BUILD_DIRECTORY, 'pynect-python.json');
10
+ let lastLoadError;
10
11
 
11
12
  function normalize(value) {
12
13
  const resolved = path.resolve(value);
@@ -33,12 +34,14 @@ function matchingAddonPath() {
33
34
  }
34
35
 
35
36
  function loads() {
37
+ lastLoadError = undefined;
36
38
  const addon = matchingAddonPath();
37
39
  if (!addon || !fs.existsSync(addon)) return false;
38
40
  try {
39
41
  require(addon);
40
42
  return true;
41
- } catch {
43
+ } catch (error) {
44
+ lastLoadError = error;
42
45
  return false;
43
46
  }
44
47
  }
@@ -55,7 +58,10 @@ function build() {
55
58
  if (result.status !== 0) {
56
59
  throw new Error(`Native addon build failed with exit code ${result.status}.`);
57
60
  }
58
- if (!loads()) throw new Error('Native addon did not load after it was built.');
61
+ if (!loads()) {
62
+ const detail = lastLoadError instanceof Error ? ` ${lastLoadError.message}` : '';
63
+ throw new Error(`Native addon did not load after it was built.${detail}`);
64
+ }
59
65
  }
60
66
 
61
67
  async function main() {
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ process.env.PYNECT_PREFER_LOCAL ??= '1';
4
+
3
5
  const path = require('path');
4
6
  const { ensurePynectEnvironment } = require('./pyfig');
5
7
 
@@ -40,6 +42,22 @@ async function main() {
40
42
  );
41
43
  assertSuccess(inspector, 'codegen inspector');
42
44
  JSON.parse(inspector.stdout);
45
+ const callbackInspection = client.pythonSync(
46
+ [path.join(ROOT, 'python', 'type_inspector.py'), 'pynect_callback_fixture'],
47
+ {
48
+ encoding: 'utf8',
49
+ env: {
50
+ ...process.env,
51
+ PYTHONPATH: path.join(ROOT, 'tests', 'fixtures'),
52
+ },
53
+ },
54
+ );
55
+ assertSuccess(callbackInspection, 'callback type inspector');
56
+ const inspectedCallback = JSON.parse(callbackInspection.stdout).functions
57
+ .find(item => item.name === 'invoke')?.parameters?.[0]?.type;
58
+ if (inspectedCallback !== 'Callable[[Any], Any]') {
59
+ throw new Error(`Callback annotation was inspected as ${String(inspectedCallback)}.`);
60
+ }
43
61
 
44
62
  const worker = client.pythonSync(
45
63
  [path.join(ROOT, 'python', 'isolated_worker.py')],
@@ -69,15 +87,67 @@ async function main() {
69
87
  projectRoot: ROOT,
70
88
  allowUnsafeCode: true,
71
89
  logLevel: 'error',
90
+ pythonPath_extra: [path.join(ROOT, 'tests', 'fixtures')],
91
+ threadPool: { size: 1 },
92
+ callbacks: { maxDepth: 2 },
72
93
  });
73
94
  try {
74
95
  const embedded = await pynect.py.eval('__import__("sys").executable');
75
96
  assertSamePython(embedded, expected, 'embedded CPython');
97
+ const fixture = await pynect.importModule('pynect_callback_fixture');
98
+ await assertRejects(
99
+ () => fixture.invoke((value) => value, 1),
100
+ /ctx\.callback\(\)/,
101
+ 'unregistered callback',
102
+ );
103
+
104
+ let disposedCallback;
105
+ await pynect.withCallbacks(async ctx => {
106
+ const asyncCallback = ctx.callback(async value => {
107
+ await Promise.resolve();
108
+ return value * 3;
109
+ });
110
+ if (await fixture.invoke(asyncCallback, 4) !== 12) {
111
+ throw new Error('Async JavaScript callback returned the wrong value.');
112
+ }
113
+
114
+ const reentrantCallback = ctx.callback(async value => fixture.double(value));
115
+ if (await fixture.invoke(reentrantCallback, 5) !== 10) {
116
+ throw new Error('Reentrant JavaScript callback returned the wrong value.');
117
+ }
118
+
119
+ let recursiveCallback;
120
+ recursiveCallback = ctx.callback(async depth => {
121
+ if (depth <= 0) return 0;
122
+ return fixture.invoke(recursiveCallback, depth - 1);
123
+ });
124
+ await assertRejects(
125
+ () => fixture.invoke(recursiveCallback, 2),
126
+ /recursion depth 3 exceeds the configured maximum of 2/,
127
+ 'callback recursion limit',
128
+ );
129
+ disposedCallback = asyncCallback;
130
+ });
131
+ await assertRejects(
132
+ () => fixture.invoke(disposedCallback, 1),
133
+ /disposed/,
134
+ 'callback context disposal',
135
+ );
76
136
  } finally {
77
137
  await pynect.shutdown();
78
138
  }
79
139
 
80
- console.log(`Python boundary verified: ${python.executable}`);
140
+ console.log(`Python ${python.version} boundary verified: ${python.executable}`);
141
+ }
142
+
143
+ async function assertRejects(action, pattern, label) {
144
+ try {
145
+ await action();
146
+ } catch (error) {
147
+ if (pattern.test(String(error?.message || error))) return;
148
+ throw new Error(`${label} failed with an unexpected error: ${error?.message || error}`);
149
+ }
150
+ throw new Error(`${label} did not reject.`);
81
151
  }
82
152
 
83
153
  function assertSuccess(result, label) {
@@ -0,0 +1,11 @@
1
+ #pragma once
2
+
3
+ #include <Python.h>
4
+ #include <cstdint>
5
+
6
+ namespace pynect {
7
+
8
+ /** Create a Python callable backed by a registered JavaScript callback. */
9
+ PyObject* createPythonCallback(uint32_t callbackId);
10
+
11
+ } // namespace pynect
@@ -18,15 +18,24 @@ export interface NativeAddonBuildOptions {
18
18
  export function loadOrBuildNativeAddon(options: NativeAddonBuildOptions): NativeAddon {
19
19
  let initialError: unknown = new Error('Native rebuild explicitly requested.');
20
20
  if (process.env['PYNECT_FORCE_BUILD'] !== '1') {
21
+ if (process.env['PYNECT_PREFER_LOCAL'] === '1') {
22
+ try {
23
+ return loadMatchingLocalAddon(options);
24
+ } catch (error) {
25
+ initialError = error;
26
+ }
27
+ }
21
28
  try {
22
29
  return loadMatchingPrebuild(options);
23
30
  } catch (error) {
24
31
  initialError = error;
25
32
  }
26
- try {
27
- return loadMatchingLocalAddon(options);
28
- } catch (error) {
29
- initialError = new AggregateError([initialError, error], 'No compatible native addon loaded.');
33
+ if (process.env['PYNECT_PREFER_LOCAL'] !== '1') {
34
+ try {
35
+ return loadMatchingLocalAddon(options);
36
+ } catch (error) {
37
+ initialError = new AggregateError([initialError, error], 'No compatible native addon loaded.');
38
+ }
30
39
  }
31
40
  }
32
41
 
@@ -72,10 +81,11 @@ interface NativeBuildMetadata {
72
81
  }
73
82
 
74
83
  interface NativePrebuildMetadata {
75
- schemaVersion: 1;
84
+ schemaVersion: 2;
76
85
  platform: NodeJS.Platform;
77
86
  architecture: string;
78
- pythonMajorMinor: string;
87
+ pythonMinimumVersion: string;
88
+ pythonAbi: string;
79
89
  napi: number;
80
90
  glibc?: string;
81
91
  pythonLibraryName: string;
@@ -91,17 +101,22 @@ function loadMatchingPrebuild(options: NativeAddonBuildOptions): NativeAddon {
91
101
  } catch (error) {
92
102
  throw new Error(`Native prebuild metadata is unavailable at ${metadataPath}.`, { cause: error });
93
103
  }
94
- const pythonMajorMinor = options.pythonVersion.split('.').slice(0, 2).join('.');
104
+ const pythonVersion = majorMinor(options.pythonVersion);
105
+ const minimumVersion = typeof metadata.pythonMinimumVersion === 'string'
106
+ ? majorMinor(metadata.pythonMinimumVersion)
107
+ : [Number.POSITIVE_INFINITY, 0] as const;
95
108
  if (
96
- metadata.schemaVersion !== 1 ||
109
+ metadata.schemaVersion !== 2 ||
97
110
  metadata.platform !== process.platform ||
98
111
  metadata.architecture !== process.arch ||
99
- metadata.pythonMajorMinor !== pythonMajorMinor ||
112
+ metadata.pythonAbi !== 'cp310-abi3' ||
113
+ pythonVersion[0] !== 3 ||
114
+ compareMajorMinor(pythonVersion, minimumVersion) < 0 ||
100
115
  metadata.napi > Number(process.versions.napi ?? 0)
101
116
  ) {
102
117
  throw new Error(
103
118
  `Native prebuild does not match ${process.platform}-${process.arch}, ` +
104
- `Python ${pythonMajorMinor}, N-API ${process.versions.napi ?? 'unknown'}.`,
119
+ `Python ${options.pythonVersion}, N-API ${process.versions.napi ?? 'unknown'}.`,
105
120
  );
106
121
  }
107
122
  if (process.platform === 'linux' && metadata.glibc) {
@@ -137,7 +152,7 @@ function materializePortablePrebuild(
137
152
  'node_modules',
138
153
  '.pynect',
139
154
  'native',
140
- `${process.platform}-${process.arch}-python${metadata.pythonMajorMinor}-${identity}`,
155
+ `${process.platform}-${process.arch}-${metadata.pythonAbi}-${identity}`,
141
156
  );
142
157
  const addon = path.join(directory, 'pynect_addon.node');
143
158
  const library = path.join(directory, metadata.pythonLibraryName);
@@ -216,3 +231,16 @@ function compareVersions(left: string, right: string): number {
216
231
  }
217
232
  return 0;
218
233
  }
234
+
235
+ function majorMinor(version: string): readonly [number, number] {
236
+ const match = version.match(/^(\d+)\.(\d+)/u);
237
+ if (!match) throw new Error(`Invalid Python version: ${version}`);
238
+ return [Number(match[1]), Number(match[2])];
239
+ }
240
+
241
+ function compareMajorMinor(
242
+ left: readonly [number, number],
243
+ right: readonly [number, number],
244
+ ): number {
245
+ return left[0] - right[0] || left[1] - right[1];
246
+ }