nodepyx 1.0.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 (184) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +399 -0
  3. package/binding.gyp +73 -0
  4. package/dist/core/PyCallable.d.ts +65 -0
  5. package/dist/core/PyCallable.d.ts.map +1 -0
  6. package/dist/core/PyCallable.js +109 -0
  7. package/dist/core/PyCallable.js.map +1 -0
  8. package/dist/core/PyContext.d.ts +76 -0
  9. package/dist/core/PyContext.d.ts.map +1 -0
  10. package/dist/core/PyContext.js +228 -0
  11. package/dist/core/PyContext.js.map +1 -0
  12. package/dist/core/PyIterator.d.ts +84 -0
  13. package/dist/core/PyIterator.d.ts.map +1 -0
  14. package/dist/core/PyIterator.js +243 -0
  15. package/dist/core/PyIterator.js.map +1 -0
  16. package/dist/core/PyModule.d.ts +55 -0
  17. package/dist/core/PyModule.d.ts.map +1 -0
  18. package/dist/core/PyModule.js +172 -0
  19. package/dist/core/PyModule.js.map +1 -0
  20. package/dist/core/PyProxy.d.ts +65 -0
  21. package/dist/core/PyProxy.d.ts.map +1 -0
  22. package/dist/core/PyProxy.js +483 -0
  23. package/dist/core/PyProxy.js.map +1 -0
  24. package/dist/core/PyRuntime.d.ts +105 -0
  25. package/dist/core/PyRuntime.d.ts.map +1 -0
  26. package/dist/core/PyRuntime.js +438 -0
  27. package/dist/core/PyRuntime.js.map +1 -0
  28. package/dist/env/CondaManager.d.ts +118 -0
  29. package/dist/env/CondaManager.d.ts.map +1 -0
  30. package/dist/env/CondaManager.js +401 -0
  31. package/dist/env/CondaManager.js.map +1 -0
  32. package/dist/env/PackageInstaller.d.ts +233 -0
  33. package/dist/env/PackageInstaller.d.ts.map +1 -0
  34. package/dist/env/PackageInstaller.js +609 -0
  35. package/dist/env/PackageInstaller.js.map +1 -0
  36. package/dist/env/PythonDetector.d.ts +103 -0
  37. package/dist/env/PythonDetector.d.ts.map +1 -0
  38. package/dist/env/PythonDetector.js +381 -0
  39. package/dist/env/PythonDetector.js.map +1 -0
  40. package/dist/env/VenvManager.d.ts +117 -0
  41. package/dist/env/VenvManager.d.ts.map +1 -0
  42. package/dist/env/VenvManager.js +331 -0
  43. package/dist/env/VenvManager.js.map +1 -0
  44. package/dist/index.d.ts +169 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +393 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/plugins/Plugin.interface.d.ts +41 -0
  49. package/dist/plugins/Plugin.interface.d.ts.map +1 -0
  50. package/dist/plugins/Plugin.interface.js +12 -0
  51. package/dist/plugins/Plugin.interface.js.map +1 -0
  52. package/dist/plugins/PluginManager.d.ts +26 -0
  53. package/dist/plugins/PluginManager.d.ts.map +1 -0
  54. package/dist/plugins/PluginManager.js +174 -0
  55. package/dist/plugins/PluginManager.js.map +1 -0
  56. package/dist/plugins/builtin/NumpyPlugin.d.ts +17 -0
  57. package/dist/plugins/builtin/NumpyPlugin.d.ts.map +1 -0
  58. package/dist/plugins/builtin/NumpyPlugin.js +41 -0
  59. package/dist/plugins/builtin/NumpyPlugin.js.map +1 -0
  60. package/dist/plugins/builtin/PandasPlugin.d.ts +19 -0
  61. package/dist/plugins/builtin/PandasPlugin.d.ts.map +1 -0
  62. package/dist/plugins/builtin/PandasPlugin.js +57 -0
  63. package/dist/plugins/builtin/PandasPlugin.js.map +1 -0
  64. package/dist/plugins/builtin/TorchPlugin.d.ts +23 -0
  65. package/dist/plugins/builtin/TorchPlugin.d.ts.map +1 -0
  66. package/dist/plugins/builtin/TorchPlugin.js +50 -0
  67. package/dist/plugins/builtin/TorchPlugin.js.map +1 -0
  68. package/dist/plugins/index.d.ts +7 -0
  69. package/dist/plugins/index.d.ts.map +1 -0
  70. package/dist/plugins/index.js +12 -0
  71. package/dist/plugins/index.js.map +1 -0
  72. package/dist/serialization/DataFrameBridge.d.ts +141 -0
  73. package/dist/serialization/DataFrameBridge.d.ts.map +1 -0
  74. package/dist/serialization/DataFrameBridge.js +355 -0
  75. package/dist/serialization/DataFrameBridge.js.map +1 -0
  76. package/dist/serialization/MsgPackSerializer.d.ts +45 -0
  77. package/dist/serialization/MsgPackSerializer.d.ts.map +1 -0
  78. package/dist/serialization/MsgPackSerializer.js +242 -0
  79. package/dist/serialization/MsgPackSerializer.js.map +1 -0
  80. package/dist/serialization/NumpyBridge.d.ts +96 -0
  81. package/dist/serialization/NumpyBridge.d.ts.map +1 -0
  82. package/dist/serialization/NumpyBridge.js +323 -0
  83. package/dist/serialization/NumpyBridge.js.map +1 -0
  84. package/dist/serialization/Serializer.d.ts +78 -0
  85. package/dist/serialization/Serializer.d.ts.map +1 -0
  86. package/dist/serialization/Serializer.js +281 -0
  87. package/dist/serialization/Serializer.js.map +1 -0
  88. package/dist/types/PythonTypeMapper.d.ts +87 -0
  89. package/dist/types/PythonTypeMapper.d.ts.map +1 -0
  90. package/dist/types/PythonTypeMapper.js +449 -0
  91. package/dist/types/PythonTypeMapper.js.map +1 -0
  92. package/dist/types/StubCache.d.ts +109 -0
  93. package/dist/types/StubCache.d.ts.map +1 -0
  94. package/dist/types/StubCache.js +333 -0
  95. package/dist/types/StubCache.js.map +1 -0
  96. package/dist/types/TypeGenerator.d.ts +139 -0
  97. package/dist/types/TypeGenerator.d.ts.map +1 -0
  98. package/dist/types/TypeGenerator.js +372 -0
  99. package/dist/types/TypeGenerator.js.map +1 -0
  100. package/dist/types/addon.d.ts +114 -0
  101. package/dist/types/addon.d.ts.map +1 -0
  102. package/dist/types/addon.js +32 -0
  103. package/dist/types/addon.js.map +1 -0
  104. package/dist/types/config.d.ts +175 -0
  105. package/dist/types/config.d.ts.map +1 -0
  106. package/dist/types/config.js +35 -0
  107. package/dist/types/config.js.map +1 -0
  108. package/dist/types/index.d.ts +10 -0
  109. package/dist/types/index.d.ts.map +1 -0
  110. package/dist/types/index.js +12 -0
  111. package/dist/types/index.js.map +1 -0
  112. package/dist/types/python.d.ts +235 -0
  113. package/dist/types/python.d.ts.map +1 -0
  114. package/dist/types/python.js +7 -0
  115. package/dist/types/python.js.map +1 -0
  116. package/dist/utils/ErrorTranslator.d.ts +83 -0
  117. package/dist/utils/ErrorTranslator.d.ts.map +1 -0
  118. package/dist/utils/ErrorTranslator.js +210 -0
  119. package/dist/utils/ErrorTranslator.js.map +1 -0
  120. package/dist/utils/Logger.d.ts +27 -0
  121. package/dist/utils/Logger.d.ts.map +1 -0
  122. package/dist/utils/Logger.js +115 -0
  123. package/dist/utils/Logger.js.map +1 -0
  124. package/dist/utils/MemoryMonitor.d.ts +44 -0
  125. package/dist/utils/MemoryMonitor.d.ts.map +1 -0
  126. package/dist/utils/MemoryMonitor.js +143 -0
  127. package/dist/utils/MemoryMonitor.js.map +1 -0
  128. package/package.json +177 -0
  129. package/python/error_handler.py +433 -0
  130. package/python/nodepyx_runtime.py +575 -0
  131. package/python/serializer.py +379 -0
  132. package/python/type_inspector.py +288 -0
  133. package/scripts/build-native.js +68 -0
  134. package/scripts/download-prebuilds.js +99 -0
  135. package/scripts/generate-stubs.js +405 -0
  136. package/scripts/install.js +260 -0
  137. package/src/core/PyCallable.ts +137 -0
  138. package/src/core/PyContext.ts +296 -0
  139. package/src/core/PyIterator.ts +294 -0
  140. package/src/core/PyModule.ts +194 -0
  141. package/src/core/PyProxy.ts +605 -0
  142. package/src/core/PyRuntime.ts +504 -0
  143. package/src/env/CondaManager.ts +451 -0
  144. package/src/env/PackageInstaller.ts +738 -0
  145. package/src/env/PythonDetector.ts +414 -0
  146. package/src/env/VenvManager.ts +396 -0
  147. package/src/index.ts +425 -0
  148. package/src/native/gil_guard.cpp +26 -0
  149. package/src/native/gil_guard.h +175 -0
  150. package/src/native/nodepyx_addon.cpp +886 -0
  151. package/src/native/python_bridge.cpp +790 -0
  152. package/src/native/python_bridge.h +257 -0
  153. package/src/native/thread_pool.cpp +336 -0
  154. package/src/native/thread_pool.h +175 -0
  155. package/src/native/type_converter.cpp +901 -0
  156. package/src/native/type_converter.h +272 -0
  157. package/src/nextjs/PyProvider.tsx +123 -0
  158. package/src/nextjs/index.ts +21 -0
  159. package/src/nextjs/usePython.ts +106 -0
  160. package/src/nextjs/withnodepyx.ts +88 -0
  161. package/src/plugins/Plugin.interface.ts +51 -0
  162. package/src/plugins/PluginManager.ts +155 -0
  163. package/src/plugins/builtin/NumpyPlugin.ts +36 -0
  164. package/src/plugins/builtin/PandasPlugin.ts +49 -0
  165. package/src/plugins/builtin/TorchPlugin.ts +56 -0
  166. package/src/plugins/index.ts +7 -0
  167. package/src/serialization/DataFrameBridge.ts +398 -0
  168. package/src/serialization/MsgPackSerializer.ts +220 -0
  169. package/src/serialization/NumpyBridge.ts +332 -0
  170. package/src/serialization/Serializer.ts +320 -0
  171. package/src/types/PythonTypeMapper.ts +495 -0
  172. package/src/types/StubCache.ts +340 -0
  173. package/src/types/TypeGenerator.ts +491 -0
  174. package/src/types/addon.ts +170 -0
  175. package/src/types/config.ts +226 -0
  176. package/src/types/index.ts +55 -0
  177. package/src/types/python.ts +309 -0
  178. package/src/types/stubs/numpy.d.ts +441 -0
  179. package/src/types/stubs/pandas.d.ts +575 -0
  180. package/src/types/stubs/sklearn.d.ts +728 -0
  181. package/src/types/stubs/torch.d.ts +694 -0
  182. package/src/utils/ErrorTranslator.ts +220 -0
  183. package/src/utils/Logger.ts +119 -0
  184. package/src/utils/MemoryMonitor.ts +175 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * nodepyx — PythonDetector
3
+ * Locates a usable Python interpreter on the host system.
4
+ *
5
+ * Detection order:
6
+ * 1. nodepyx_PYTHON environment variable
7
+ * 2. Explicit pythonPath from nodepyxConfig
8
+ * 3. Virtualenv / Conda path from config
9
+ * 4. python3 / python3.x on PATH
10
+ * 5. python on PATH
11
+ * 6. Common installation locations (Windows/macOS/Linux)
12
+ *
13
+ * Validates:
14
+ * - Python version ≥ 3.8
15
+ * - Not a stub (pyenv shim without real installation)
16
+ * - Has required modules available (when requested)
17
+ */
18
+
19
+ import { execSync, spawnSync } from 'child_process';
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import { Logger } from '../utils/Logger';
23
+
24
+ const logger = new Logger('PythonDetector');
25
+
26
+ // ─── Types ─────────────────────────────────────────────────────────────────
27
+
28
+ export interface PythonInfo {
29
+ /** Absolute path to Python executable */
30
+ executable: string;
31
+ /** Python version string (e.g. '3.11.4') */
32
+ version: string;
33
+ /** Major.minor as numbers */
34
+ major: number;
35
+ minor: number;
36
+ patch: number;
37
+ /** Absolute path to Python prefix (sys.prefix) */
38
+ prefix: string;
39
+ /** Whether this is inside a virtualenv */
40
+ isVirtualenv: boolean;
41
+ /** Whether this is inside a Conda environment */
42
+ isConda: boolean;
43
+ /** conda environment name (if applicable) */
44
+ condaEnv?: string;
45
+ /** Platform: 'linux' | 'darwin' | 'win32' */
46
+ platform: string;
47
+ /** Architecture: 'x64' | 'arm64' */
48
+ arch: string;
49
+ }
50
+
51
+ export interface DetectorOptions {
52
+ /** Minimum Python version required. Default: [3, 8] */
53
+ minVersion?: [number, number];
54
+ /** Explicit path to check first */
55
+ pythonPath?: string;
56
+ /** Timeout for subprocess calls (ms). Default: 10000 */
57
+ timeout?: number;
58
+ /** Skip PATH scanning and only check explicit path. Default: false */
59
+ explicitOnly?: boolean;
60
+ /** Modules that must be importable. Default: [] */
61
+ requiredModules?: string[];
62
+ }
63
+
64
+ // ─── Platform-specific common locations ─────────────────────────────────────
65
+
66
+ const COMMON_LOCATIONS: Readonly<Record<NodeJS.Platform, string[]>> = {
67
+ linux: [
68
+ '/usr/bin/python3',
69
+ '/usr/local/bin/python3',
70
+ '/opt/conda/bin/python3',
71
+ '/usr/bin/python3.12',
72
+ '/usr/bin/python3.11',
73
+ '/usr/bin/python3.10',
74
+ '/usr/bin/python3.9',
75
+ '/usr/bin/python3.8',
76
+ '/usr/bin/python',
77
+ ],
78
+ darwin: [
79
+ '/usr/local/bin/python3',
80
+ '/opt/homebrew/bin/python3',
81
+ '/opt/homebrew/bin/python3.12',
82
+ '/opt/homebrew/bin/python3.11',
83
+ '/opt/homebrew/bin/python3.10',
84
+ '/Library/Frameworks/Python.framework/Versions/3.12/bin/python3',
85
+ '/Library/Frameworks/Python.framework/Versions/3.11/bin/python3',
86
+ '/Library/Frameworks/Python.framework/Versions/3.10/bin/python3',
87
+ '/usr/bin/python3',
88
+ '/usr/bin/python',
89
+ ],
90
+ win32: [
91
+ 'C:\\Python312\\python.exe',
92
+ 'C:\\Python311\\python.exe',
93
+ 'C:\\Python310\\python.exe',
94
+ 'C:\\Python39\\python.exe',
95
+ 'C:\\Python38\\python.exe',
96
+ `${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
97
+ `${process.env.LOCALAPPDATA}\\Programs\\Python\\Python311\\python.exe`,
98
+ `${process.env.LOCALAPPDATA}\\Programs\\Python\\Python310\\python.exe`,
99
+ `${process.env.APPDATA}\\Python\\Python312\\python.exe`,
100
+ ],
101
+ aix: [],
102
+ android: [],
103
+ cygwin: [],
104
+ freebsd: [],
105
+ haiku: [],
106
+ netbsd: [],
107
+ openbsd: [],
108
+ sunos: [],
109
+ } as const;
110
+
111
+ // Python info extraction script
112
+ const PROBE_SCRIPT = `
113
+ import sys, os, json
114
+ prefix = sys.prefix
115
+ is_venv = (
116
+ hasattr(sys, 'real_prefix') or
117
+ (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)
118
+ )
119
+ is_conda = 'conda' in sys.version or os.path.exists(os.path.join(prefix, 'conda-meta'))
120
+ conda_env = os.environ.get('CONDA_DEFAULT_ENV')
121
+ info = {
122
+ 'version': '.'.join(map(str, sys.version_info[:3])),
123
+ 'major': sys.version_info.major,
124
+ 'minor': sys.version_info.minor,
125
+ 'patch': sys.version_info.micro,
126
+ 'prefix': prefix,
127
+ 'is_venv': is_venv,
128
+ 'is_conda': is_conda,
129
+ 'conda_env': conda_env,
130
+ 'platform': sys.platform,
131
+ 'arch': __import__('platform').machine(),
132
+ }
133
+ print(json.dumps(info))
134
+ `.trim();
135
+
136
+ /**
137
+ * PythonDetector — locates a usable Python interpreter.
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const detector = new PythonDetector({ minVersion: [3, 9] });
142
+ * const info = await detector.detect();
143
+ * console.log(info.executable); // /usr/bin/python3
144
+ * console.log(info.version); // 3.11.4
145
+ * ```
146
+ */
147
+ export class PythonDetector {
148
+ private readonly _opts: Required<DetectorOptions>;
149
+
150
+ constructor(options: DetectorOptions = {}) {
151
+ this._opts = {
152
+ minVersion: options.minVersion ?? [3, 8],
153
+ pythonPath: options.pythonPath ?? '',
154
+ timeout: options.timeout ?? 10_000,
155
+ explicitOnly: options.explicitOnly ?? false,
156
+ requiredModules: options.requiredModules ?? [],
157
+ };
158
+ }
159
+
160
+ // ─── Public API ──────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Detect and validate a Python interpreter.
164
+ * Returns the first suitable interpreter found.
165
+ * Throws if no suitable interpreter is found.
166
+ */
167
+ async detect(): Promise<PythonInfo> {
168
+ const candidates = this._buildCandidates();
169
+
170
+ for (const candidate of candidates) {
171
+ const info = this._probe(candidate);
172
+ if (!info) {continue;}
173
+
174
+ if (!this._meetsVersionRequirement(info)) {
175
+ logger.debug(`PythonDetector: ${candidate} — version ${info.version} < ${this._opts.minVersion.join('.')}`);
176
+ continue;
177
+ }
178
+
179
+ if (this._opts.requiredModules.length > 0) {
180
+ const missing = this._checkModules(info.executable, this._opts.requiredModules);
181
+ if (missing.length > 0) {
182
+ logger.debug(`PythonDetector: ${candidate} — missing modules: ${missing.join(', ')}`);
183
+ continue;
184
+ }
185
+ }
186
+
187
+ logger.info(`PythonDetector: using ${info.executable} (${info.version})`);
188
+ return info;
189
+ }
190
+
191
+ throw new PythonNotFoundError(
192
+ `Could not find a suitable Python interpreter (>= ${this._opts.minVersion.join('.')}).` +
193
+ '\nSolutions:' +
194
+ '\n 1. Install Python 3.8+: https://python.org' +
195
+ '\n 2. Set nodepyx_PYTHON env var to the Python executable path' +
196
+ '\n 3. Pass pythonPath in the nodepyx config' +
197
+ '\n 4. Create a virtualenv: nodepyx.init({ virtualenv: { path: ".venv", packages: [] } })',
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Probe a specific Python executable and return its info.
203
+ * Returns null if the executable is invalid or doesn't exist.
204
+ */
205
+ probe(executablePath: string): PythonInfo | null {
206
+ return this._probe(executablePath);
207
+ }
208
+
209
+ /**
210
+ * Check if a Python executable meets the minimum version requirement.
211
+ */
212
+ isCompatible(info: PythonInfo): boolean {
213
+ return this._meetsVersionRequirement(info);
214
+ }
215
+
216
+ /**
217
+ * Check which required modules are missing for a given Python.
218
+ * Returns an empty array if all modules are available.
219
+ */
220
+ checkModules(executable: string, modules: string[]): string[] {
221
+ return this._checkModules(executable, modules);
222
+ }
223
+
224
+ /**
225
+ * List all Python executables found in common locations and PATH.
226
+ */
227
+ async listAll(): Promise<PythonInfo[]> {
228
+ const results: PythonInfo[] = [];
229
+ const candidates = this._buildCandidates();
230
+
231
+ for (const candidate of candidates) {
232
+ const info = this._probe(candidate);
233
+ if (info) {results.push(info);}
234
+ }
235
+
236
+ return results;
237
+ }
238
+
239
+ // ─── Private ─────────────────────────────────────────────────────────────
240
+
241
+ private _buildCandidates(): string[] {
242
+ const candidates: string[] = [];
243
+
244
+ // 1. nodepyx_PYTHON env var (highest priority)
245
+ if (process.env.nodepyx_PYTHON) {
246
+ candidates.push(process.env.nodepyx_PYTHON);
247
+ }
248
+
249
+ // 2. Explicit config path
250
+ if (this._opts.pythonPath) {
251
+ candidates.push(this._opts.pythonPath);
252
+ // Also try bin/python inside a venv/conda root
253
+ const binDir = process.platform === 'win32' ? 'Scripts' : 'bin';
254
+ candidates.push(path.join(this._opts.pythonPath, binDir, 'python3'));
255
+ candidates.push(path.join(this._opts.pythonPath, binDir, 'python'));
256
+ }
257
+
258
+ if (this._opts.explicitOnly) {return candidates;}
259
+
260
+ // 3. PATH candidates
261
+ const pathCandidates = [
262
+ 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8',
263
+ 'python3', 'python',
264
+ ];
265
+ for (const name of pathCandidates) {
266
+ const found = this._which(name);
267
+ if (found) {candidates.push(found);}
268
+ }
269
+
270
+ // 4. Common installation locations
271
+ const platformKey = process.platform as NodeJS.Platform;
272
+ for (const loc of COMMON_LOCATIONS[platformKey] ?? []) {
273
+ if (loc) {candidates.push(loc);}
274
+ }
275
+
276
+ // Deduplicate while preserving order
277
+ return [...new Set(candidates)];
278
+ }
279
+
280
+ private _probe(executable: string): PythonInfo | null {
281
+ if (!executable) {return null;}
282
+
283
+ try {
284
+ // Resolve Windows executable extension
285
+ const exec = this._resolveExecutable(executable);
286
+ if (!exec) {return null;}
287
+
288
+ const result = spawnSync(exec, ['-c', PROBE_SCRIPT], {
289
+ encoding: 'utf8',
290
+ timeout: this._opts.timeout,
291
+ maxBuffer: 1024 * 1024,
292
+ env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
293
+ });
294
+
295
+ if (result.status !== 0 || result.error || !result.stdout.trim()) {
296
+ return null;
297
+ }
298
+
299
+ const raw = JSON.parse(result.stdout.trim()) as {
300
+ version: string;
301
+ major: number;
302
+ minor: number;
303
+ patch: number;
304
+ prefix: string;
305
+ is_venv: boolean;
306
+ is_conda: boolean;
307
+ conda_env: string | null;
308
+ platform: string;
309
+ arch: string;
310
+ };
311
+
312
+ return {
313
+ executable: exec,
314
+ version: raw.version,
315
+ major: raw.major,
316
+ minor: raw.minor,
317
+ patch: raw.patch,
318
+ prefix: raw.prefix,
319
+ isVirtualenv: raw.is_venv,
320
+ isConda: raw.is_conda,
321
+ condaEnv: raw.conda_env ?? undefined,
322
+ platform: raw.platform,
323
+ arch: raw.arch,
324
+ };
325
+ } catch (err) {
326
+ logger.debug(`PythonDetector._probe(${executable}) failed`, err);
327
+ return null;
328
+ }
329
+ }
330
+
331
+ private _resolveExecutable(exec: string): string | null {
332
+ // Absolute path — check existence
333
+ if (path.isAbsolute(exec)) {
334
+ if (fs.existsSync(exec)) {return exec;}
335
+ // Try with .exe on Windows
336
+ if (process.platform === 'win32' && !exec.endsWith('.exe')) {
337
+ const withExt = exec + '.exe';
338
+ if (fs.existsSync(withExt)) {return withExt;}
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // Relative name — resolve via which
344
+ return this._which(exec);
345
+ }
346
+
347
+ private _which(name: string): string | null {
348
+ try {
349
+ const cmd = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
350
+ const result = execSync(cmd, {
351
+ encoding: 'utf8',
352
+ timeout: 3000,
353
+ stdio: ['pipe', 'pipe', 'pipe'],
354
+ }).trim();
355
+
356
+ // `where` returns multiple lines; take the first
357
+ const first = result.split(/\r?\n/)[0]?.trim();
358
+ return first || null;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ private _meetsVersionRequirement(info: PythonInfo): boolean {
365
+ const [minMajor, minMinor] = this._opts.minVersion;
366
+ if (info.major !== minMajor!) {return info.major > minMajor!;}
367
+ return info.minor >= minMinor!;
368
+ }
369
+
370
+ private _checkModules(executable: string, modules: string[]): string[] {
371
+ if (modules.length === 0) {return [];}
372
+
373
+ const script = modules
374
+ .map(m => `try:\n import ${m}\nexcept ImportError:\n print("MISSING:${m}")`)
375
+ .join('\n');
376
+
377
+ try {
378
+ const result = spawnSync(executable, ['-c', script], {
379
+ encoding: 'utf8',
380
+ timeout: this._opts.timeout,
381
+ maxBuffer: 1024 * 256,
382
+ });
383
+
384
+ const missing: string[] = [];
385
+ for (const line of (result.stdout ?? '').split('\n')) {
386
+ const match = line.match(/^MISSING:(.+)$/);
387
+ if (match) {missing.push(match[1]!);}
388
+ }
389
+ return missing;
390
+ } catch {
391
+ return modules; // assume all missing if we can't check
392
+ }
393
+ }
394
+ }
395
+
396
+ // ─── Error types ─────────────────────────────────────────────────────────────
397
+
398
+ export class PythonNotFoundError extends Error {
399
+ constructor(message: string) {
400
+ super(message);
401
+ this.name = 'PythonNotFoundError';
402
+ }
403
+ }
404
+
405
+ export class PythonVersionError extends Error {
406
+ constructor(found: string, required: string) {
407
+ super(
408
+ `Python ${found} is below the minimum required version ${required}. ` +
409
+ `Please upgrade Python.`,
410
+ );
411
+ this.name = 'PythonVersionError';
412
+ }
413
+ }
414
+