@microsoft/m365-copilot-eval 1.4.0-preview.1 → 1.6.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.
@@ -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
- * Download and setup Python Build Standalone runtime
216
- * Returns the path to the Python executable
217
- * @param {boolean} [verbose=false] - Enable verbose output
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 ensurePythonRuntime(verbose = false, onProgress) {
221
- const platformKey = getPlatformKey();
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 fs.access(pythonExe);
241
- if (verbose) {
242
- console.log(`Using cached Python runtime: ${pythonExe}`);
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
- // Skip both download and extract phases silently
245
- onProgress?.({ type: 'skip', phaseId: 'download' });
246
- onProgress?.({ type: 'skip', phaseId: 'extract' });
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
- // Python not found, proceed with download and extraction
239
+ return { isMatch: false, version: 'unknown' };
250
240
  }
241
+ }
251
242
 
252
- if (!onProgress) {
253
- console.log(
254
- `Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
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
- // Download if not cached
259
- let needsDownload = false;
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
- await fs.access(archivePath);
262
- if (verbose) {
263
- console.log('Using cached download');
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
- if (needsDownload) {
272
- // Start download phase
273
- onProgress?.({ type: 'start', phaseId: 'download' });
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
- // Extract phase
290
- onProgress?.({ type: 'start', phaseId: 'extract' });
291
- try {
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
- // Verify Python executable exists
300
- await fs.access(pythonExe);
340
+ // Verify Python executable exists
341
+ await fs.access(pythonExe);
301
342
 
302
- if (!onProgress) {
303
- console.log(`Python runtime ready: ${pythonExe}`);
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
+ }
@@ -1,3 +0,0 @@
1
- from .sydney_client import SydneyClient
2
-
3
- __all__ = ["SydneyClient"]