@microsoft/m365-copilot-eval 1.5.0-preview.1 → 1.7.0-preview.1
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.
- package/README.md +19 -1
- package/package.json +4 -3
- package/schema/CHANGELOG.md +7 -0
- package/schema/v1/eval-document.schema.json +144 -333
- package/schema/v1/examples/invalid/error-result-with-score.json +16 -0
- package/schema/v1/examples/invalid/missing-error-on-error.json +13 -0
- package/schema/v1/examples/valid/multi-turn-output.json +2 -0
- package/schema/v1/examples/valid/scenarios-with-mixed-errors.json +239 -0
- package/schema/version.json +1 -1
- package/src/clients/cli/api_clients/A2A/a2a_client.py +57 -10
- package/src/clients/cli/auth/auth_handler.py +21 -1
- package/src/clients/cli/common.py +8 -14
- package/src/clients/cli/error_messages.py +91 -0
- package/src/clients/cli/evaluation_runner.py +108 -97
- package/src/clients/cli/evaluator_resolver.py +8 -33
- package/src/clients/cli/generate_report.py +125 -96
- package/src/clients/cli/main.py +2 -1
- package/src/clients/cli/readme.md +1 -1
- package/src/clients/cli/result_writer.py +129 -110
- package/src/clients/cli/status_derivation.py +91 -0
- package/src/clients/node-js/bin/runevals.js +31 -9
- package/src/clients/node-js/config/default.js +1 -1
- package/src/clients/node-js/lib/env-loader.js +20 -13
- package/src/clients/node-js/lib/python-runtime.js +137 -65
- package/src/clients/node-js/lib/venv-manager.js +3 -2
- package/src/clients/node-js/lib/version-check.js +268 -0
|
@@ -5,6 +5,10 @@ import crypto from 'crypto';
|
|
|
5
5
|
import { pipeline } from 'stream/promises';
|
|
6
6
|
import { createWriteStream } from 'fs';
|
|
7
7
|
import { Transform } from 'stream';
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
import { promisify } from 'util';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
8
12
|
import fetch from 'node-fetch';
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -14,7 +18,7 @@ import fetch from 'node-fetch';
|
|
|
14
18
|
|
|
15
19
|
// PBS release version (latest release with Python 3.13.10)
|
|
16
20
|
const PBS_VERSION = '20251202';
|
|
17
|
-
const PYTHON_VERSION = '3.13.10';
|
|
21
|
+
export const PYTHON_VERSION = '3.13.10';
|
|
18
22
|
|
|
19
23
|
// Base URL for PBS releases
|
|
20
24
|
const PBS_BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${PBS_VERSION}`;
|
|
@@ -212,66 +216,111 @@ async function extractTarGz(archivePath, destDir, onProgress) {
|
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* @
|
|
218
|
-
* @param {Function} [onProgress] - Optional progress callback
|
|
219
|
+
* Validate that a Python executable is the expected version.
|
|
220
|
+
* @param {string} pythonPath - Path to a Python executable
|
|
221
|
+
* @returns {{ isMatch: boolean, version: string }} Version info
|
|
219
222
|
*/
|
|
220
|
-
export async function
|
|
221
|
-
const
|
|
222
|
-
const distribution = PBS_DISTRIBUTIONS[platformKey];
|
|
223
|
-
|
|
224
|
-
const cacheDir = getCacheDir();
|
|
225
|
-
const pythonDir = path.join(
|
|
226
|
-
cacheDir,
|
|
227
|
-
'python',
|
|
228
|
-
`${PYTHON_VERSION}-${platformKey}`
|
|
229
|
-
);
|
|
230
|
-
const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
|
|
231
|
-
|
|
232
|
-
// Determine Python executable path based on platform
|
|
233
|
-
const pythonExe =
|
|
234
|
-
process.platform === 'win32'
|
|
235
|
-
? path.join(pythonDir, 'python.exe')
|
|
236
|
-
: path.join(pythonDir, 'bin', 'python3');
|
|
237
|
-
|
|
238
|
-
// Check if Python is already installed (silent skip per FR-007)
|
|
223
|
+
export async function _validatePythonVersion(pythonPath) {
|
|
224
|
+
const expectedMajorMinor = PYTHON_VERSION.split('.').slice(0, 2).join('.');
|
|
239
225
|
try {
|
|
240
|
-
await
|
|
241
|
-
|
|
242
|
-
|
|
226
|
+
const { stdout } = await execFileAsync(pythonPath, ['--version'], {
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
timeout: 10_000,
|
|
229
|
+
});
|
|
230
|
+
// Output format: "Python 3.13.10"
|
|
231
|
+
const match = stdout.trim().match(/Python\s+(\d+\.\d+\.\d+)/);
|
|
232
|
+
if (!match) {
|
|
233
|
+
return { isMatch: false, version: 'unknown' };
|
|
243
234
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return pythonExe;
|
|
235
|
+
const version = match[1];
|
|
236
|
+
const majorMinor = version.split('.').slice(0, 2).join('.');
|
|
237
|
+
return { isMatch: majorMinor === expectedMajorMinor, version };
|
|
248
238
|
} catch {
|
|
249
|
-
|
|
239
|
+
return { isMatch: false, version: 'unknown' };
|
|
250
240
|
}
|
|
241
|
+
}
|
|
251
242
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Attempt to resolve a fallback Python from the PYTHON_PATH environment variable.
|
|
245
|
+
* @returns {Promise<{ pythonPath: string, version: string, isMatch: boolean }>}
|
|
246
|
+
*/
|
|
247
|
+
export async function _resolveFallbackPython() {
|
|
248
|
+
const pythonPath = process.env.PYTHON_PATH;
|
|
249
|
+
if (!pythonPath) {
|
|
250
|
+
throw new Error('PYTHON_PATH environment variable is not set.');
|
|
256
251
|
}
|
|
257
252
|
|
|
258
|
-
|
|
259
|
-
|
|
253
|
+
await fs.access(pythonPath);
|
|
254
|
+
const { isMatch, version } = await _validatePythonVersion(pythonPath);
|
|
255
|
+
return { pythonPath, version, isMatch };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Download and setup Python Build Standalone runtime
|
|
260
|
+
* Returns the path to the Python executable and version info.
|
|
261
|
+
* On download failure, falls back to PYTHON_PATH env var if available.
|
|
262
|
+
* @param {boolean} [verbose=false] - Enable verbose output
|
|
263
|
+
* @param {Function} [onProgress] - Optional progress callback
|
|
264
|
+
* @returns {Promise<{ pythonPath: string, version: string, isMatch: boolean }>}
|
|
265
|
+
*/
|
|
266
|
+
export async function ensurePythonRuntime(verbose = false, onProgress) {
|
|
267
|
+
// Attempt PBS download + extraction; falls back to PYTHON_PATH on any failure
|
|
268
|
+
// (including unsupported platform where getPlatformKey() throws)
|
|
269
|
+
let currentPhase = 'download';
|
|
260
270
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
const platformKey = getPlatformKey();
|
|
272
|
+
const distribution = PBS_DISTRIBUTIONS[platformKey];
|
|
273
|
+
|
|
274
|
+
const cacheDir = getCacheDir();
|
|
275
|
+
const pythonDir = path.join(
|
|
276
|
+
cacheDir,
|
|
277
|
+
'python',
|
|
278
|
+
`${PYTHON_VERSION}-${platformKey}`
|
|
279
|
+
);
|
|
280
|
+
const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
|
|
281
|
+
|
|
282
|
+
// Determine Python executable path based on platform
|
|
283
|
+
const pythonExe =
|
|
284
|
+
process.platform === 'win32'
|
|
285
|
+
? path.join(pythonDir, 'python.exe')
|
|
286
|
+
: path.join(pythonDir, 'bin', 'python3');
|
|
287
|
+
|
|
288
|
+
// Check if Python is already installed (silent skip per FR-007)
|
|
289
|
+
try {
|
|
290
|
+
await fs.access(pythonExe);
|
|
291
|
+
if (verbose) {
|
|
292
|
+
console.log(`Using cached Python runtime: ${pythonExe}`);
|
|
293
|
+
}
|
|
294
|
+
// Skip both download and extract phases silently
|
|
295
|
+
onProgress?.({ type: 'skip', phaseId: 'download' });
|
|
296
|
+
onProgress?.({ type: 'skip', phaseId: 'extract' });
|
|
297
|
+
return { pythonPath: pythonExe, version: PYTHON_VERSION, isMatch: true };
|
|
298
|
+
} catch {
|
|
299
|
+
// Python not found, proceed with download and extraction
|
|
264
300
|
}
|
|
265
|
-
// Skip download phase silently
|
|
266
|
-
onProgress?.({ type: 'skip', phaseId: 'download' });
|
|
267
|
-
} catch {
|
|
268
|
-
needsDownload = true;
|
|
269
|
-
}
|
|
270
301
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
302
|
+
if (!onProgress) {
|
|
303
|
+
console.log(
|
|
304
|
+
`Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Download if not cached
|
|
309
|
+
let needsDownload = false;
|
|
274
310
|
try {
|
|
311
|
+
await fs.access(archivePath);
|
|
312
|
+
if (verbose) {
|
|
313
|
+
console.log('Using cached download');
|
|
314
|
+
}
|
|
315
|
+
// Skip download phase silently
|
|
316
|
+
onProgress?.({ type: 'skip', phaseId: 'download' });
|
|
317
|
+
} catch {
|
|
318
|
+
needsDownload = true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (needsDownload) {
|
|
322
|
+
// Start download phase
|
|
323
|
+
onProgress?.({ type: 'start', phaseId: 'download' });
|
|
275
324
|
const downloadUrl = `${PBS_BASE_URL}/${distribution.filename}`;
|
|
276
325
|
await downloadFile(
|
|
277
326
|
downloadUrl,
|
|
@@ -280,29 +329,52 @@ export async function ensurePythonRuntime(verbose = false, onProgress) {
|
|
|
280
329
|
onProgress
|
|
281
330
|
);
|
|
282
331
|
onProgress?.({ type: 'complete', phaseId: 'download' });
|
|
283
|
-
} catch (error) {
|
|
284
|
-
onProgress?.({ type: 'error', phaseId: 'download', error });
|
|
285
|
-
throw error;
|
|
286
332
|
}
|
|
287
|
-
}
|
|
288
333
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
// Extract phase
|
|
335
|
+
currentPhase = 'extract';
|
|
336
|
+
onProgress?.({ type: 'start', phaseId: 'extract' });
|
|
292
337
|
await extractTarGz(archivePath, pythonDir, onProgress);
|
|
293
338
|
onProgress?.({ type: 'complete', phaseId: 'extract' });
|
|
294
|
-
} catch (error) {
|
|
295
|
-
onProgress?.({ type: 'error', phaseId: 'extract', error });
|
|
296
|
-
throw error;
|
|
297
|
-
}
|
|
298
339
|
|
|
299
|
-
|
|
300
|
-
|
|
340
|
+
// Verify Python executable exists
|
|
341
|
+
await fs.access(pythonExe);
|
|
301
342
|
|
|
302
|
-
|
|
303
|
-
|
|
343
|
+
if (!onProgress) {
|
|
344
|
+
console.log(`Python runtime ready: ${pythonExe}`);
|
|
345
|
+
}
|
|
346
|
+
return { pythonPath: pythonExe, version: PYTHON_VERSION, isMatch: true };
|
|
347
|
+
} catch (pbsError) {
|
|
348
|
+
// PBS setup failed (unsupported platform, download, or extract) — attempt PYTHON_PATH fallback
|
|
349
|
+
onProgress?.({ type: 'error', phaseId: currentPhase, error: pbsError });
|
|
350
|
+
|
|
351
|
+
if (verbose) {
|
|
352
|
+
console.error(
|
|
353
|
+
`Python setup failed: ${pbsError.message}. Checking PYTHON_PATH fallback...`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const fallback = await _resolveFallbackPython();
|
|
359
|
+
if (verbose) {
|
|
360
|
+
console.log(
|
|
361
|
+
`Using PYTHON_PATH fallback: ${fallback.pythonPath} (Python ${fallback.version})`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
// Mark extract as skipped since we're using an external Python
|
|
365
|
+
onProgress?.({ type: 'skip', phaseId: 'extract' });
|
|
366
|
+
return fallback;
|
|
367
|
+
} catch (fallbackError) {
|
|
368
|
+
// Neither PBS nor PYTHON_PATH worked
|
|
369
|
+
const err = new Error(
|
|
370
|
+
`Python ${PYTHON_VERSION} could not be set up.\n` +
|
|
371
|
+
` PBS setup failed: ${pbsError.message}\n` +
|
|
372
|
+
` PYTHON_PATH fallback: ${fallbackError.message}`
|
|
373
|
+
);
|
|
374
|
+
err.code = 'PYTHON_NOT_FOUND';
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
304
377
|
}
|
|
305
|
-
return pythonExe;
|
|
306
378
|
}
|
|
307
379
|
|
|
308
380
|
/**
|
|
@@ -321,9 +321,10 @@ async function writeRequirementsMarker(venvDir, requirementsHash) {
|
|
|
321
321
|
export async function ensureVenv(
|
|
322
322
|
requirementsPath,
|
|
323
323
|
verbose = false,
|
|
324
|
-
onProgress
|
|
324
|
+
onProgress,
|
|
325
|
+
pythonExePath
|
|
325
326
|
) {
|
|
326
|
-
const pythonExe = await getPythonExecutable();
|
|
327
|
+
const pythonExe = pythonExePath || (await getPythonExecutable());
|
|
327
328
|
const venvDir = getVenvDir();
|
|
328
329
|
const requirementsHash = await getRequirementsHash(requirementsPath);
|
|
329
330
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { PYTHON_VERSION } from './python-runtime.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimum Python major.minor that is still receiving security updates.
|
|
5
|
+
*
|
|
6
|
+
* This MUST be reviewed against the official Python release schedule
|
|
7
|
+
* (https://devguide.python.org/versions/) when bumping PYTHON_VERSION
|
|
8
|
+
* or whenever a previously supported branch reaches end-of-life.
|
|
9
|
+
*
|
|
10
|
+
* Versions older than this are hard-blocked because they no longer
|
|
11
|
+
* receive security patches and pose a supply-chain risk to users.
|
|
12
|
+
*/
|
|
13
|
+
export const MIN_SUPPORTED_PYTHON_MAJOR_MINOR = '3.10';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a version string into { major, minor }, or null if it cannot
|
|
17
|
+
* be parsed (e.g. the literal string 'unknown').
|
|
18
|
+
* @param {string} version
|
|
19
|
+
* @returns {{ major: number, minor: number } | null}
|
|
20
|
+
*/
|
|
21
|
+
function parseMajorMinor(version) {
|
|
22
|
+
const match = String(version).match(/^(\d+)\.(\d+)/);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true when the detected Python version is older than the
|
|
29
|
+
* minimum supported major.minor and is therefore considered EOL.
|
|
30
|
+
*
|
|
31
|
+
* Returns false for unparseable versions (e.g. 'unknown') so the
|
|
32
|
+
* caller's regular version-mismatch flow can handle them.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} version - Detected Python version string (e.g. "3.8.10")
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function _isPythonVersionEol(version) {
|
|
38
|
+
const detected = parseMajorMinor(version);
|
|
39
|
+
if (!detected) return false;
|
|
40
|
+
const min = parseMajorMinor(MIN_SUPPORTED_PYTHON_MAJOR_MINOR);
|
|
41
|
+
if (detected.major < min.major) return true;
|
|
42
|
+
if (detected.major === min.major && detected.minor < min.minor) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the user-facing error message for an EOL Python version that
|
|
48
|
+
* has been hard-blocked. EOL versions are blocked unconditionally
|
|
49
|
+
* (no override prompt) because they no longer receive security fixes.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} version - Detected Python version string
|
|
52
|
+
* @returns {string[]} Array of lines to print
|
|
53
|
+
*/
|
|
54
|
+
export function _formatEolPythonError(version) {
|
|
55
|
+
return [
|
|
56
|
+
`\n❌ Python ${version} has reached end-of-life and is not supported.`,
|
|
57
|
+
' Continuing would expose you to known security vulnerabilities,',
|
|
58
|
+
' so this version is blocked unconditionally.\n',
|
|
59
|
+
` Minimum supported version: Python ${MIN_SUPPORTED_PYTHON_MAJOR_MINOR}`,
|
|
60
|
+
` Recommended version: Python ${PYTHON_VERSION}`,
|
|
61
|
+
' Python release schedule: https://devguide.python.org/versions/',
|
|
62
|
+
' Download a supported build: https://www.python.org/downloads/\n',
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the user-facing error message for PYTHON_NOT_FOUND failures.
|
|
68
|
+
* @param {string} platform - process.platform value
|
|
69
|
+
* @returns {string[]} Array of lines to print
|
|
70
|
+
*/
|
|
71
|
+
export function _formatPythonNotFoundError(platform) {
|
|
72
|
+
const lines = [
|
|
73
|
+
`\n❌ Python ${PYTHON_VERSION} is required but could not be found.\n`,
|
|
74
|
+
'The automatic download failed, and no valid fallback was detected.',
|
|
75
|
+
'To provide Python manually, set the PYTHON_PATH environment variable:\n',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
if (platform === 'win32') {
|
|
79
|
+
lines.push(' set PYTHON_PATH=C:\\path\\to\\python.exe');
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(' export PYTHON_PATH=/path/to/python3');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
lines.push(
|
|
85
|
+
`\nRequired version: Python ${PYTHON_VERSION}`,
|
|
86
|
+
'Download: https://www.python.org/downloads/\n'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Evaluate what action to take when the resolved Python version doesn't match.
|
|
94
|
+
*
|
|
95
|
+
* EOL versions are hard-rejected even when the user answers "yes" — this
|
|
96
|
+
* defends against the y/n prompt being used to bypass the security gate
|
|
97
|
+
* and is checked here in addition to the upfront check in the CLI.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} version - The detected Python version string
|
|
100
|
+
* @param {{ isInteractive: boolean, promptAnswer?: string }} options
|
|
101
|
+
* @returns {{ action: 'continue' | 'reject', reason: string }}
|
|
102
|
+
*/
|
|
103
|
+
export function _evaluateVersionMismatch(
|
|
104
|
+
version,
|
|
105
|
+
{ isInteractive, promptAnswer }
|
|
106
|
+
) {
|
|
107
|
+
if (_isPythonVersionEol(version)) {
|
|
108
|
+
return {
|
|
109
|
+
action: 'reject',
|
|
110
|
+
reason: `Python ${version} has reached end-of-life and cannot be used. Install Python ${MIN_SUPPORTED_PYTHON_MAJOR_MINOR} or newer (Python ${PYTHON_VERSION} recommended).`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isInteractive) {
|
|
115
|
+
return {
|
|
116
|
+
action: 'reject',
|
|
117
|
+
reason: `Python version mismatch (found ${version}). In non-interactive environments, PYTHON_PATH must point to Python ${PYTHON_VERSION}.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const answer = (promptAnswer || '').trim().toLowerCase();
|
|
122
|
+
if (answer === 'y' || answer === 'yes') {
|
|
123
|
+
return { action: 'continue', reason: 'User accepted version mismatch.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
action: 'reject',
|
|
128
|
+
reason: `Aborted. Please install Python ${PYTHON_VERSION} or set PYTHON_PATH to a compatible version.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle a Python version mismatch with side effects routed through
|
|
134
|
+
* injected callbacks so the full decision tree is unit-testable without
|
|
135
|
+
* touching real stdout/stderr, readline, or process.exit.
|
|
136
|
+
*
|
|
137
|
+
* Behaviour mirrors the inline block previously in runevals.js:
|
|
138
|
+
* - EOL versions are hard-blocked (no prompt) regardless of interactivity.
|
|
139
|
+
* - Interactive terminals warn, prompt, and respect the user's answer
|
|
140
|
+
* (subject to the EOL block enforced inside _evaluateVersionMismatch).
|
|
141
|
+
* - Non-interactive environments auto-reject.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} args
|
|
144
|
+
* @param {{ version: string, isMatch: boolean }} args.runtime - Detected runtime info.
|
|
145
|
+
* @param {boolean} args.isInteractive - Whether we are in an interactive terminal.
|
|
146
|
+
* @param {() => Promise<string>} [args.promptForContinue] - Async function that
|
|
147
|
+
* resolves with the user's y/n answer. Required when isInteractive is true.
|
|
148
|
+
* @param {(msg: string) => void} args.warn - Sink for warning messages.
|
|
149
|
+
* @param {(msg: string) => void} args.error - Sink for error messages.
|
|
150
|
+
* @returns {Promise<{ shouldExit: boolean, exitCode?: number }>}
|
|
151
|
+
* When `shouldExit` is true the caller should `process.exit(exitCode)`.
|
|
152
|
+
* When false, initialization may continue with the mismatched runtime.
|
|
153
|
+
*/
|
|
154
|
+
export async function _handlePythonVersionMismatch({
|
|
155
|
+
runtime,
|
|
156
|
+
isInteractive,
|
|
157
|
+
promptForContinue,
|
|
158
|
+
warn,
|
|
159
|
+
error,
|
|
160
|
+
}) {
|
|
161
|
+
if (runtime.isMatch) {
|
|
162
|
+
return { shouldExit: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (_isPythonVersionEol(runtime.version)) {
|
|
166
|
+
for (const line of _formatEolPythonError(runtime.version)) {
|
|
167
|
+
error(line);
|
|
168
|
+
}
|
|
169
|
+
return { shouldExit: true, exitCode: 1 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isInteractive) {
|
|
173
|
+
warn(
|
|
174
|
+
`\n⚠️ Python version mismatch. Expected ${PYTHON_VERSION}, found ${runtime.version}.`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const answer = await promptForContinue();
|
|
178
|
+
|
|
179
|
+
const result = _evaluateVersionMismatch(runtime.version, {
|
|
180
|
+
isInteractive: true,
|
|
181
|
+
promptAnswer: answer,
|
|
182
|
+
});
|
|
183
|
+
if (result.action === 'reject') {
|
|
184
|
+
error(`\n${result.reason}`);
|
|
185
|
+
error('Download: https://www.python.org/downloads/\n');
|
|
186
|
+
return { shouldExit: true, exitCode: 1 };
|
|
187
|
+
}
|
|
188
|
+
return { shouldExit: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Non-interactive (CI/CD) — auto-reject version mismatch
|
|
192
|
+
const result = _evaluateVersionMismatch(runtime.version, {
|
|
193
|
+
isInteractive: false,
|
|
194
|
+
});
|
|
195
|
+
error(`\n❌ ${result.reason}`);
|
|
196
|
+
return { shouldExit: true, exitCode: 1 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build the user-facing error guidance lines printed when Python
|
|
201
|
+
* environment initialization fails. Returns the lines as an array so
|
|
202
|
+
* the caller can route them to console.error and the result is testable
|
|
203
|
+
* without intercepting console.
|
|
204
|
+
*
|
|
205
|
+
* If `error.code === 'PYTHON_NOT_FOUND'`, the platform-specific
|
|
206
|
+
* PYTHON_NOT_FOUND guidance is included before the generic troubleshooting
|
|
207
|
+
* tips.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ error: { code?: string }, platform: string }} args
|
|
210
|
+
* @returns {string[]}
|
|
211
|
+
*/
|
|
212
|
+
export function _buildInitializationFailureLines({ error, platform }) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
|
|
215
|
+
if (error && error.code === 'PYTHON_NOT_FOUND') {
|
|
216
|
+
for (const line of _formatPythonNotFoundError(platform)) {
|
|
217
|
+
lines.push(line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lines.push(
|
|
222
|
+
'Troubleshooting:',
|
|
223
|
+
' - Check your internet connection',
|
|
224
|
+
' - If behind a proxy, set HTTP_PROXY/HTTPS_PROXY environment variables',
|
|
225
|
+
' - For SSL issues, set NODE_EXTRA_CA_CERTS or PIP_CERT',
|
|
226
|
+
' - Run with --log-level debug for detailed output'
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Prompt the user to continue past a Python version mismatch.
|
|
234
|
+
*
|
|
235
|
+
* The readline factory is injected so this can be unit-tested with a
|
|
236
|
+
* fake interface. The default factory uses Node's built-in readline
|
|
237
|
+
* bound to process.stdin/process.stdout. The interface is always
|
|
238
|
+
* closed via try/finally so a Ctrl+C does not leave the event loop
|
|
239
|
+
* holding stdin open.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} [deps]
|
|
242
|
+
* @param {() => { question: (q: string, cb: (answer: string) => void) => void, close: () => void }} [deps.createInterface]
|
|
243
|
+
* Factory returning a readline-compatible interface.
|
|
244
|
+
* @returns {Promise<string>} The user's raw answer (untrimmed).
|
|
245
|
+
*/
|
|
246
|
+
export async function _promptForContinueWithMismatch(deps = {}) {
|
|
247
|
+
const createInterface =
|
|
248
|
+
deps.createInterface ||
|
|
249
|
+
(async () => {
|
|
250
|
+
const readline = await import('readline');
|
|
251
|
+
return readline.createInterface({
|
|
252
|
+
input: process.stdin,
|
|
253
|
+
output: process.stdout,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const rl = await createInterface();
|
|
258
|
+
try {
|
|
259
|
+
return await new Promise((resolve) => {
|
|
260
|
+
rl.question(
|
|
261
|
+
'Would you like to continue with unsupported Python version? (y/n): ',
|
|
262
|
+
resolve
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
} finally {
|
|
266
|
+
rl.close();
|
|
267
|
+
}
|
|
268
|
+
}
|