@microsoft/m365-copilot-eval 1.5.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.
- package/README.md +15 -0
- package/package.json +4 -3
- package/schema/CHANGELOG.md +7 -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/main.py +2 -1
- 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/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
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ A CLI for evaluating M365 Copilot agents. Send prompts to your agent, get respon
|
|
|
24
24
|
- **M365 Copilot License** for your tenant
|
|
25
25
|
- **M365 Copilot Agent** deployed to your tenant (can be created with [M365 Agents Toolkit](https://learn.microsoft.com/en-us/microsoft-365/developer/overview-m365-agents-toolkit) or any other method)
|
|
26
26
|
- **Node.js 24.12.0+** (check: `node --version`)
|
|
27
|
+
- **Python 3.13.x** is downloaded automatically. If the download fails (e.g., network restrictions), set `PYTHON_PATH` to a local Python 3.13.x installation (see [Troubleshooting](#-troubleshooting))
|
|
27
28
|
- **Environment file** with your credentials and agent ID (see [Environment Setup](#-environment-setup) below)
|
|
28
29
|
- **Your Tenant ID** - get your tenant id using the instructions [here](https://learn.microsoft.com/en-us/azure/azure-portal/get-subscription-tenant-id)
|
|
29
30
|
- Admin approval to run WORKIQ Client App for your tenant [here](https://github.com/microsoft/work-iq/blob/main/ADMIN-INSTRUCTIONS.md)
|
|
@@ -460,6 +461,20 @@ runevals cache-dir
|
|
|
460
461
|
chmod -R u+w $(runevals cache-dir)
|
|
461
462
|
```
|
|
462
463
|
|
|
464
|
+
### Custom Python Runtime (PYTHON_PATH)
|
|
465
|
+
|
|
466
|
+
If the automatic Python download fails (e.g., network restrictions, unsupported platform), provide your own Python installation:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
# Windows
|
|
470
|
+
set PYTHON_PATH=C:\Python313\python.exe
|
|
471
|
+
|
|
472
|
+
# macOS/Linux
|
|
473
|
+
export PYTHON_PATH=/usr/local/bin/python3.13
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Python 3.13.x is the tested version. If a different version is found, you'll be prompted to confirm before proceeding. In CI/CD, a version mismatch fails automatically.
|
|
477
|
+
|
|
463
478
|
## š Advanced Documentation
|
|
464
479
|
|
|
465
480
|
- **[CI/CD Integration](./CICD_CACHE_GUIDE.md)** - GitHub Actions, Azure DevOps caching
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@microsoft/m365-copilot-eval",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0-preview.1",
|
|
4
4
|
"minCliVersion": "1.0.1-preview.1",
|
|
5
5
|
"description": "Zero-config Node.js wrapper for M365 Copilot Agent Evaluations CLI (Python-based Azure AI Evaluation SDK)",
|
|
6
|
-
"publishDate": "2026-
|
|
6
|
+
"publishDate": "2026-05-07",
|
|
7
7
|
"main": "src/clients/node-js/lib/index.js",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
@@ -80,8 +80,9 @@
|
|
|
80
80
|
"README.md",
|
|
81
81
|
"LICENSE"
|
|
82
82
|
],
|
|
83
|
+
"homepage": "https://github.com/microsoft/m365-copilot-eval",
|
|
83
84
|
"repository": {
|
|
84
85
|
"type": "git",
|
|
85
|
-
"url": "https://github.com/microsoft/
|
|
86
|
+
"url": "https://github.com/microsoft/m365-copilot-eval.git"
|
|
86
87
|
}
|
|
87
88
|
}
|
package/schema/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to the eval document schema will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.0](https://github.com/microsoft/M365-Copilot-Agent-Evals/compare/schema-v1.2.0...schema-v1.3.0) (2026-04-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Added similarity evaluator for compatibility with MCS Evals. ([#228](https://github.com/microsoft/M365-Copilot-Agent-Evals/issues/228)) ([0fe8315](https://github.com/microsoft/M365-Copilot-Agent-Evals/commit/0fe8315abc8e0422d1ac9117fe9f29195f29044f))
|
|
14
|
+
|
|
8
15
|
## [1.2.0](https://github.com/microsoft/M365-Copilot-Agent-Evals/compare/schema-v1.1.0...schema-v1.2.0) (2026-04-22)
|
|
9
16
|
|
|
10
17
|
|
package/schema/version.json
CHANGED
|
@@ -8,7 +8,7 @@ import re
|
|
|
8
8
|
import urllib.error
|
|
9
9
|
import urllib.request
|
|
10
10
|
import uuid
|
|
11
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
12
12
|
|
|
13
13
|
from api_clients.base_agent_client import BaseAgentClient
|
|
14
14
|
from cli_logging.console_diagnostics import emit_structured_log
|
|
@@ -35,6 +35,7 @@ class A2AClient(BaseAgentClient):
|
|
|
35
35
|
access_token: str,
|
|
36
36
|
logger: Optional[logging.Logger] = None,
|
|
37
37
|
diagnostic_records: Optional[List[Dict[str, Any]]] = None,
|
|
38
|
+
token_refresh_fn: Optional[Callable[[], str]] = None,
|
|
38
39
|
) -> None:
|
|
39
40
|
"""
|
|
40
41
|
Args:
|
|
@@ -42,11 +43,15 @@ class A2AClient(BaseAgentClient):
|
|
|
42
43
|
access_token: Bearer token for A2A authentication.
|
|
43
44
|
logger: Logger to use. Defaults to a module-level logger if not provided.
|
|
44
45
|
diagnostic_records: List to accumulate structured log entries.
|
|
46
|
+
token_refresh_fn: Optional callable that returns a fresh access token string.
|
|
47
|
+
When provided, a single HTTP 401 response will trigger a token refresh
|
|
48
|
+
and one automatic retry, making the refresh invisible to the caller.
|
|
45
49
|
"""
|
|
46
50
|
self._endpoint = a2a_endpoint.rstrip("/")
|
|
47
51
|
self._access_token = access_token
|
|
48
52
|
self._logger = logger or logging.getLogger(__name__)
|
|
49
53
|
self._diagnostic_records = diagnostic_records
|
|
54
|
+
self._token_refresh_fn = token_refresh_fn
|
|
50
55
|
self._resolved_agent_url: Optional[str] = None
|
|
51
56
|
|
|
52
57
|
# ------------------------------------------------------------------ #
|
|
@@ -261,6 +266,12 @@ class A2AClient(BaseAgentClient):
|
|
|
261
266
|
) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
|
262
267
|
"""Send a JSON-RPC message to the agent and parse the response.
|
|
263
268
|
|
|
269
|
+
When a ``token_refresh_fn`` was supplied at construction time and the
|
|
270
|
+
server responds with HTTP 401 (Unauthorized), the token is refreshed
|
|
271
|
+
automatically and the request is retried exactly once. This keeps
|
|
272
|
+
long-running eval sessions alive beyond the initial token lifetime
|
|
273
|
+
without requiring any user interaction.
|
|
274
|
+
|
|
264
275
|
Returns:
|
|
265
276
|
A tuple of (result_dict, raw_result) where result_dict is the
|
|
266
277
|
normalized response dict (raw_response_text, display_response_text,
|
|
@@ -275,15 +286,51 @@ class A2AClient(BaseAgentClient):
|
|
|
275
286
|
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_SECS) as resp:
|
|
276
287
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
277
288
|
except urllib.error.HTTPError as e:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
if e.code == 401 and self._token_refresh_fn is not None:
|
|
290
|
+
emit_structured_log(
|
|
291
|
+
"info",
|
|
292
|
+
"[A2A] Access token expired (HTTP 401); refreshing token and retrying.",
|
|
293
|
+
Operation.AUTHENTICATE,
|
|
294
|
+
logger=self._logger,
|
|
295
|
+
diagnostic_records=self._diagnostic_records,
|
|
296
|
+
)
|
|
297
|
+
new_token = self._token_refresh_fn()
|
|
298
|
+
if not new_token:
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
"A2A request failed (HTTP 401 Unauthorized) and token refresh returned no token."
|
|
301
|
+
) from e
|
|
302
|
+
self._access_token = new_token
|
|
303
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
304
|
+
retry_req = urllib.request.Request(
|
|
305
|
+
agent_url, data=payload, headers=headers, method="POST"
|
|
306
|
+
)
|
|
307
|
+
try:
|
|
308
|
+
with urllib.request.urlopen(retry_req, timeout=_REQUEST_TIMEOUT_SECS) as resp:
|
|
309
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
310
|
+
except urllib.error.HTTPError as retry_e:
|
|
311
|
+
body = ""
|
|
312
|
+
try:
|
|
313
|
+
body = retry_e.read().decode("utf-8", errors="replace")
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
f"A2A request failed (HTTP {retry_e.code} {retry_e.reason}) after token refresh."
|
|
318
|
+
+ (f" Body: {body[:500]}" if body else "")
|
|
319
|
+
) from retry_e
|
|
320
|
+
except urllib.error.URLError as retry_e:
|
|
321
|
+
raise RuntimeError(
|
|
322
|
+
f"A2A connection error after token refresh: {getattr(retry_e, 'reason', str(retry_e))}"
|
|
323
|
+
) from retry_e
|
|
324
|
+
else:
|
|
325
|
+
body = ""
|
|
326
|
+
try:
|
|
327
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
raise RuntimeError(
|
|
331
|
+
f"A2A request failed (HTTP {e.code} {e.reason})."
|
|
332
|
+
+ (f" Body: {body[:500]}" if body else "")
|
|
333
|
+
) from e
|
|
287
334
|
except urllib.error.URLError as e:
|
|
288
335
|
raise RuntimeError(
|
|
289
336
|
f"A2A connection error: {getattr(e, 'reason', str(e))}"
|
|
@@ -13,7 +13,7 @@ https://github.com/AzureAD/microsoft-authentication-extensions-for-python
|
|
|
13
13
|
import os
|
|
14
14
|
import platform
|
|
15
15
|
import logging
|
|
16
|
-
from typing import Optional
|
|
16
|
+
from typing import Callable, Optional
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
import jwt
|
|
19
19
|
from msal import PublicClientApplication
|
|
@@ -260,3 +260,23 @@ class AuthHandler:
|
|
|
260
260
|
return oid
|
|
261
261
|
except jwt.DecodeError as e:
|
|
262
262
|
raise ValueError(f"Failed to decode token: {e}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def make_token_refresh_fn(auth_handler: "AuthHandler") -> Callable[[], str]:
|
|
266
|
+
"""Return a callable that silently refreshes the A2A access token.
|
|
267
|
+
|
|
268
|
+
On a 401 response the caller invokes this function. It first attempts a
|
|
269
|
+
silent refresh (using the MSAL refresh token) and falls back to interactive
|
|
270
|
+
authentication only when a silent refresh is not possible. The returned
|
|
271
|
+
string is the new access token; an empty string signals failure.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
auth_handler: An initialized AuthHandler instance to use for token acquisition.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
A zero-argument callable that returns a fresh access token string.
|
|
278
|
+
"""
|
|
279
|
+
def _refresh() -> str:
|
|
280
|
+
result = auth_handler.acquire_token_interactive() or {}
|
|
281
|
+
return result.get("access_token") or ""
|
|
282
|
+
return _refresh
|
package/src/clients/cli/main.py
CHANGED
|
@@ -16,7 +16,7 @@ from azure.ai.evaluation import AzureOpenAIModelConfiguration
|
|
|
16
16
|
from dotenv import load_dotenv
|
|
17
17
|
|
|
18
18
|
from api_clients.A2A import A2AClient
|
|
19
|
-
from auth.auth_handler import AuthHandler
|
|
19
|
+
from auth.auth_handler import AuthHandler, make_token_refresh_fn
|
|
20
20
|
from evaluator_resolver import resolve_default_evaluators
|
|
21
21
|
from version_check import check_min_version, get_cli_version
|
|
22
22
|
|
|
@@ -122,6 +122,7 @@ def main():
|
|
|
122
122
|
agent_client = A2AClient(
|
|
123
123
|
a2a_endpoint=a2a_endpoint,
|
|
124
124
|
access_token=a2a_access_token,
|
|
125
|
+
token_refresh_fn=make_token_refresh_fn(a2a_auth_handler),
|
|
125
126
|
logger=CLI_LOGGER,
|
|
126
127
|
diagnostic_records=DIAGNOSTIC_RECORDS,
|
|
127
128
|
)
|
|
@@ -9,8 +9,13 @@ import { ensureVenv, executePythonCli } from '../lib/venv-manager.js';
|
|
|
9
9
|
import { getCacheStats, clearCache, formatBytes } from '../lib/cache-utils.js';
|
|
10
10
|
import { checkPackageExpiry } from '../lib/expiry-check.js';
|
|
11
11
|
import { recordAcceptance, checkAcceptance } from '../lib/eula-manager.js';
|
|
12
|
-
import { ProgressReporter } from '../lib/progress.js';
|
|
12
|
+
import { ProgressReporter, isInteractiveTerminal } from '../lib/progress.js';
|
|
13
13
|
import { _loadEnvFile as loadEnvFile, _loadUserEnvOverride } from '../lib/env-loader.js';
|
|
14
|
+
import {
|
|
15
|
+
_handlePythonVersionMismatch,
|
|
16
|
+
_buildInitializationFailureLines,
|
|
17
|
+
_promptForContinueWithMismatch,
|
|
18
|
+
} from '../lib/version-check.js';
|
|
14
19
|
import { normalizeAgentId } from '../lib/agent-id.js';
|
|
15
20
|
|
|
16
21
|
// Check package expiry (exits if expired, warns if close to expiry)
|
|
@@ -130,10 +135,26 @@ async function initializePythonEnvironment(verbose = false, quiet = false) {
|
|
|
130
135
|
|
|
131
136
|
try {
|
|
132
137
|
// Step 1: Ensure Python runtime is available (handles download + extract phases)
|
|
133
|
-
await ensurePythonRuntime(verbose, onProgress);
|
|
138
|
+
const runtime = await ensurePythonRuntime(verbose, onProgress);
|
|
139
|
+
|
|
140
|
+
// Step 2: Handle version mismatch from PYTHON_PATH fallback.
|
|
141
|
+
// The decision tree (EOL block, interactive prompt, non-interactive
|
|
142
|
+
// auto-reject) lives in _handlePythonVersionMismatch so it is
|
|
143
|
+
// unit-testable without spawning the CLI; we only own the readline
|
|
144
|
+
// wiring and the actual process.exit here.
|
|
145
|
+
const mismatch = await _handlePythonVersionMismatch({
|
|
146
|
+
runtime,
|
|
147
|
+
isInteractive: isInteractiveTerminal(),
|
|
148
|
+
promptForContinue: _promptForContinueWithMismatch,
|
|
149
|
+
warn: (msg) => console.warn(msg),
|
|
150
|
+
error: (msg) => console.error(msg),
|
|
151
|
+
});
|
|
152
|
+
if (mismatch.shouldExit) {
|
|
153
|
+
process.exit(mismatch.exitCode ?? 1);
|
|
154
|
+
}
|
|
134
155
|
|
|
135
|
-
// Step
|
|
136
|
-
await ensureVenv(REQUIREMENTS_FILE, verbose, onProgress);
|
|
156
|
+
// Step 3: Ensure venv with dependencies is set up (handles venv + deps phases)
|
|
157
|
+
await ensureVenv(REQUIREMENTS_FILE, verbose, onProgress, runtime.pythonPath);
|
|
137
158
|
|
|
138
159
|
// Show completion summary
|
|
139
160
|
reporter.complete();
|
|
@@ -144,11 +165,12 @@ async function initializePythonEnvironment(verbose = false, quiet = false) {
|
|
|
144
165
|
console.error('\nFull error:', error);
|
|
145
166
|
}
|
|
146
167
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
168
|
+
for (const line of _buildInitializationFailureLines({
|
|
169
|
+
error,
|
|
170
|
+
platform: process.platform,
|
|
171
|
+
})) {
|
|
172
|
+
console.error(line);
|
|
173
|
+
}
|
|
152
174
|
|
|
153
175
|
process.exit(1);
|
|
154
176
|
}
|
|
@@ -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
|
+
}
|