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,396 @@
1
+ /**
2
+ * nodepyx — VenvManager
3
+ * Creates and manages Python virtualenvs for isolated package installation.
4
+ *
5
+ * Lifecycle:
6
+ * 1. Check if the venv directory exists and is valid.
7
+ * 2. If not, run `python3 -m venv <path>` to create it.
8
+ * 3. Upgrade pip to the latest version.
9
+ * 4. Install configured packages via pip.
10
+ * 5. Return the path to the Python executable inside the venv.
11
+ *
12
+ * State file (.nodepyx/venv-state.json) tracks installed packages
13
+ * to avoid re-running pip on every startup.
14
+ */
15
+
16
+ import { spawnSync } from 'child_process';
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { Logger } from '../utils/Logger';
20
+ import type { VirtualenvConfig } from '../types/config';
21
+
22
+ const logger = new Logger('VenvManager');
23
+
24
+ // ─── Types ─────────────────────────────────────────────────────────────────
25
+
26
+ export interface VenvState {
27
+ /** Venv creation timestamp */
28
+ createdAt: number;
29
+ /** Python version used to create the venv */
30
+ pythonVersion: string;
31
+ /** Packages that have been installed (pip-freeze style name==version) */
32
+ installedPackages: string[];
33
+ /** Last pip install timestamp */
34
+ lastInstallAt: number;
35
+ }
36
+
37
+ export interface VenvSetupResult {
38
+ /** Absolute path to Python executable inside the venv */
39
+ pythonExecutable: string;
40
+ /** Absolute path to pip executable */
41
+ pipExecutable: string;
42
+ /** Whether the venv was newly created */
43
+ created: boolean;
44
+ /** Whether packages were (re)installed */
45
+ packagesInstalled: boolean;
46
+ }
47
+
48
+ export interface VenvManagerOptions {
49
+ /** Virtualenv config from nodepyxConfig */
50
+ virtualenv: VirtualenvConfig;
51
+ /** Base Python executable to use for creating the venv */
52
+ basePython?: string;
53
+ /** Timeout for pip install in ms. Default: 300000 (5 minutes) */
54
+ installTimeout?: number;
55
+ /** Skip upgrading pip. Default: false */
56
+ skipPipUpgrade?: boolean;
57
+ }
58
+
59
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
60
+
61
+ function isWindows(): boolean {
62
+ return process.platform === 'win32';
63
+ }
64
+
65
+ function venvBinDir(venvPath: string): string {
66
+ return path.join(venvPath, isWindows() ? 'Scripts' : 'bin');
67
+ }
68
+
69
+ function venvPython(venvPath: string): string {
70
+ return path.join(
71
+ venvBinDir(venvPath),
72
+ isWindows() ? 'python.exe' : 'python3',
73
+ );
74
+ }
75
+
76
+ function venvPip(venvPath: string): string {
77
+ return path.join(
78
+ venvBinDir(venvPath),
79
+ isWindows() ? 'pip.exe' : 'pip3',
80
+ );
81
+ }
82
+
83
+ const STATE_FILENAME = '.nodepyx-venv-state.json';
84
+
85
+ /**
86
+ * VenvManager — creates and manages Python virtualenvs.
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * const manager = new VenvManager({
91
+ * virtualenv: {
92
+ * path: './.venv',
93
+ * packages: ['pandas', 'numpy', 'scikit-learn'],
94
+ * autoInstall: true,
95
+ * }
96
+ * });
97
+ *
98
+ * const result = await manager.setup();
99
+ * console.log(result.pythonExecutable); // ./.venv/bin/python3
100
+ * ```
101
+ */
102
+ export class VenvManager {
103
+ private readonly _config: VirtualenvConfig;
104
+ private readonly _basePython: string;
105
+ private readonly _installTimeout: number;
106
+ private readonly _skipPipUpgrade: boolean;
107
+ private readonly _venvPath: string;
108
+ private readonly _statePath: string;
109
+
110
+ constructor(options: VenvManagerOptions) {
111
+ this._config = options.virtualenv;
112
+ this._basePython = options.basePython ?? 'python3';
113
+ this._installTimeout = options.installTimeout ?? 300_000;
114
+ this._skipPipUpgrade = options.skipPipUpgrade ?? false;
115
+ this._venvPath = path.resolve(this._config.path);
116
+ this._statePath = path.join(this._venvPath, STATE_FILENAME);
117
+ }
118
+
119
+ // ─── Public API ──────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Set up the virtualenv and install packages if needed.
123
+ * Returns the path to the Python executable inside the venv.
124
+ */
125
+ async setup(): Promise<VenvSetupResult> {
126
+ let created = false;
127
+ let packagesInstalled = false;
128
+
129
+ // ── Create venv if it doesn't exist ──────────────────────────────────
130
+ if (!this._venvExists()) {
131
+ logger.info(`VenvManager: creating virtualenv at ${this._venvPath}`);
132
+ this._createVenv();
133
+ created = true;
134
+
135
+ if (!this._skipPipUpgrade) {
136
+ this._upgradePip();
137
+ }
138
+ }
139
+
140
+ const pythonExec = venvPython(this._venvPath);
141
+ const pipExec = venvPip(this._venvPath);
142
+
143
+ // ── Install packages ─────────────────────────────────────────────────
144
+ if (this._config.autoInstall && this._config.packages.length > 0) {
145
+ const missingPackages = this._findMissingPackages(pythonExec);
146
+
147
+ if (missingPackages.length > 0 || created) {
148
+ logger.info(`VenvManager: installing ${missingPackages.length > 0 ? missingPackages.join(', ') : this._config.packages.join(', ')}`);
149
+ this._installPackages(
150
+ pipExec,
151
+ missingPackages.length > 0 ? missingPackages : this._config.packages,
152
+ );
153
+ packagesInstalled = true;
154
+ this._updateState(pythonExec);
155
+ }
156
+ }
157
+
158
+ logger.info(`VenvManager: ready — ${pythonExec}`);
159
+
160
+ return {
161
+ pythonExecutable: pythonExec,
162
+ pipExecutable: pipExec,
163
+ created,
164
+ packagesInstalled,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Install additional packages into the venv.
170
+ */
171
+ async install(packages: string[]): Promise<void> {
172
+ if (!this._venvExists()) {
173
+ throw new Error(`VenvManager: venv does not exist at ${this._venvPath}`);
174
+ }
175
+ const pip = venvPip(this._venvPath);
176
+ this._installPackages(pip, packages);
177
+ this._updateState(venvPython(this._venvPath));
178
+ }
179
+
180
+ /**
181
+ * Check which of the configured packages are not installed in the venv.
182
+ */
183
+ getMissingPackages(): string[] {
184
+ if (!this._venvExists()) {return this._config.packages;}
185
+ return this._findMissingPackages(venvPython(this._venvPath));
186
+ }
187
+
188
+ /**
189
+ * Return the Python executable path inside the venv (without setup).
190
+ */
191
+ get pythonExecutable(): string {
192
+ return venvPython(this._venvPath);
193
+ }
194
+
195
+ /**
196
+ * Return true if the venv exists and appears valid.
197
+ */
198
+ isValid(): boolean {
199
+ return this._venvExists();
200
+ }
201
+
202
+ /**
203
+ * Destroy the venv directory.
204
+ */
205
+ destroy(): void {
206
+ if (fs.existsSync(this._venvPath)) {
207
+ fs.rmSync(this._venvPath, { recursive: true, force: true });
208
+ logger.info(`VenvManager: destroyed venv at ${this._venvPath}`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Return the list of installed packages (pip freeze format).
214
+ */
215
+ listInstalled(): string[] {
216
+ const state = this._loadState();
217
+ return state?.installedPackages ?? [];
218
+ }
219
+
220
+ // ─── Private ─────────────────────────────────────────────────────────────
221
+
222
+ private _venvExists(): boolean {
223
+ const pyExec = venvPython(this._venvPath);
224
+ return fs.existsSync(pyExec);
225
+ }
226
+
227
+ private _createVenv(): void {
228
+ fs.mkdirSync(path.dirname(this._venvPath), { recursive: true });
229
+
230
+ // Try with pyvenv-cfg check
231
+ const result = spawnSync(
232
+ this._basePython,
233
+ ['-m', 'venv', '--clear', this._venvPath],
234
+ {
235
+ encoding: 'utf8',
236
+ timeout: 60_000,
237
+ stdio: 'pipe',
238
+ },
239
+ );
240
+
241
+ if (result.status !== 0) {
242
+ const err = result.stderr?.trim() || result.error?.message || 'unknown error';
243
+ throw new VenvCreationError(
244
+ `Failed to create virtualenv at ${this._venvPath}: ${err}`,
245
+ );
246
+ }
247
+
248
+ logger.info(`VenvManager: virtualenv created at ${this._venvPath}`);
249
+ }
250
+
251
+ private _upgradePip(): void {
252
+ const pip = venvPip(this._venvPath);
253
+ try {
254
+ const result = spawnSync(
255
+ pip,
256
+ ['install', '--upgrade', 'pip', '--quiet'],
257
+ { encoding: 'utf8', timeout: 60_000, stdio: 'pipe' },
258
+ );
259
+ if (result.status === 0) {
260
+ logger.debug('VenvManager: pip upgraded');
261
+ }
262
+ } catch {
263
+ logger.warn('VenvManager: pip upgrade failed (continuing)');
264
+ }
265
+ }
266
+
267
+ private _installPackages(pip: string, packages: string[]): void {
268
+ if (packages.length === 0) {return;}
269
+
270
+ const args = ['install', '--quiet', ...packages];
271
+
272
+ if (this._config.pipIndexUrl) {
273
+ args.push('--index-url', this._config.pipIndexUrl);
274
+ }
275
+
276
+ if (this._config.pipArgs) {
277
+ args.push(...this._config.pipArgs);
278
+ }
279
+
280
+ logger.info(`VenvManager: running pip install ${packages.join(' ')}`);
281
+
282
+ const result = spawnSync(pip, args, {
283
+ encoding: 'utf8',
284
+ timeout: this._installTimeout,
285
+ stdio: ['ignore', 'pipe', 'pipe'],
286
+ });
287
+
288
+ if (result.status !== 0) {
289
+ const err = result.stderr?.trim() || 'pip install failed';
290
+ throw new PackageInstallError(packages, err);
291
+ }
292
+ }
293
+
294
+ private _findMissingPackages(pythonExec: string): string[] {
295
+ const state = this._loadState();
296
+ if (!state) {return this._config.packages;}
297
+
298
+ // Quick check via import
299
+ const missing: string[] = [];
300
+ for (const pkg of this._config.packages) {
301
+ // Strip version specifiers for import test
302
+ const importName = this._packageToImportName(pkg);
303
+ const result = spawnSync(
304
+ pythonExec,
305
+ ['-c', `import ${importName}`],
306
+ { encoding: 'utf8', timeout: 5000, stdio: 'pipe' },
307
+ );
308
+ if (result.status !== 0) {
309
+ missing.push(pkg);
310
+ }
311
+ }
312
+
313
+ return missing;
314
+ }
315
+
316
+ private _packageToImportName(pkg: string): string {
317
+ // Strip version specifiers: pandas>=2.0 → pandas
318
+ const stripped = pkg.replace(/[>=<!].*/g, '').trim();
319
+ // Normalize: scikit-learn → sklearn, Pillow → PIL
320
+ const importAliases: Record<string, string> = {
321
+ 'scikit-learn': 'sklearn',
322
+ 'scikit_learn': 'sklearn',
323
+ 'Pillow': 'PIL',
324
+ 'pillow': 'PIL',
325
+ 'opencv-python': 'cv2',
326
+ 'pyyaml': 'yaml',
327
+ 'beautifulsoup4': 'bs4',
328
+ };
329
+ return importAliases[stripped] ?? stripped;
330
+ }
331
+
332
+ private _updateState(pythonExec: string): void {
333
+ try {
334
+ const result = spawnSync(
335
+ pythonExec,
336
+ ['-m', 'pip', 'freeze', '--quiet'],
337
+ { encoding: 'utf8', timeout: 30_000, stdio: 'pipe' },
338
+ );
339
+ const installed = (result.stdout ?? '').trim().split('\n').filter(Boolean);
340
+
341
+ const state: VenvState = {
342
+ createdAt: this._loadState()?.createdAt ?? Date.now(),
343
+ pythonVersion: this._getPythonVersion(pythonExec),
344
+ installedPackages: installed,
345
+ lastInstallAt: Date.now(),
346
+ };
347
+
348
+ fs.writeFileSync(this._statePath, JSON.stringify(state, null, 2), 'utf8');
349
+ } catch (err) {
350
+ logger.warn('VenvManager: failed to update state file', err);
351
+ }
352
+ }
353
+
354
+ private _loadState(): VenvState | null {
355
+ try {
356
+ if (fs.existsSync(this._statePath)) {
357
+ return JSON.parse(fs.readFileSync(this._statePath, 'utf8')) as VenvState;
358
+ }
359
+ } catch {
360
+ // ignore
361
+ }
362
+ return null;
363
+ }
364
+
365
+ private _getPythonVersion(pythonExec: string): string {
366
+ try {
367
+ const result = spawnSync(pythonExec, ['--version'], {
368
+ encoding: 'utf8',
369
+ timeout: 5000,
370
+ stdio: 'pipe',
371
+ });
372
+ return (result.stdout || result.stderr || '').trim().replace('Python ', '');
373
+ } catch {
374
+ return 'unknown';
375
+ }
376
+ }
377
+ }
378
+
379
+ // ─── Error types ─────────────────────────────────────────────────────────────
380
+
381
+ export class VenvCreationError extends Error {
382
+ constructor(message: string) {
383
+ super(message);
384
+ this.name = 'VenvCreationError';
385
+ }
386
+ }
387
+
388
+ export class PackageInstallError extends Error {
389
+ readonly packages: string[];
390
+ constructor(packages: string[], detail: string) {
391
+ super(`Failed to install packages [${packages.join(', ')}]: ${detail}`);
392
+ this.name = 'PackageInstallError';
393
+ this.packages = packages;
394
+ }
395
+ }
396
+