@microsoft/m365-copilot-eval 1.0.1-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/LICENSE +21 -0
- package/README.md +415 -0
- package/TERMS.txt +65 -0
- package/package.json +82 -0
- package/src/clients/cli/auth/__init__.py +1 -0
- package/src/clients/cli/auth/auth_handler.py +262 -0
- package/src/clients/cli/custom_evaluators/CitationsEvaluator.py +136 -0
- package/src/clients/cli/custom_evaluators/ConcisenessNonLLMEvaluator.py +18 -0
- package/src/clients/cli/custom_evaluators/ExactMatchEvaluator.py +25 -0
- package/src/clients/cli/custom_evaluators/PII/PII.py +45 -0
- package/src/clients/cli/custom_evaluators/PartialMatchEvaluator.py +39 -0
- package/src/clients/cli/custom_evaluators/__init__.py +1 -0
- package/src/clients/cli/demo_usage.py +83 -0
- package/src/clients/cli/generate_report.py +251 -0
- package/src/clients/cli/main.py +766 -0
- package/src/clients/cli/readme.md +301 -0
- package/src/clients/cli/requirements.txt +10 -0
- package/src/clients/cli/response_extractor.py +589 -0
- package/src/clients/cli/samples/PartnerSuccess.json +122 -0
- package/src/clients/cli/samples/example_prompts.json +14 -0
- package/src/clients/cli/samples/example_prompts_alt.json +12 -0
- package/src/clients/cli/samples/prompts_ambiguity.json +22 -0
- package/src/clients/cli/samples/prompts_rag_grounding.json +22 -0
- package/src/clients/cli/samples/prompts_security_injection.json +22 -0
- package/src/clients/cli/samples/prompts_tool_use_negatives.json +22 -0
- package/src/clients/cli/samples/psaSample.json +18 -0
- package/src/clients/cli/samples/starter.json +10 -0
- package/src/clients/node-js/bin/runevals.js +505 -0
- package/src/clients/node-js/config/default.js +25 -0
- package/src/clients/node-js/lib/cache-utils.js +119 -0
- package/src/clients/node-js/lib/expiry-check.js +164 -0
- package/src/clients/node-js/lib/index.js +25 -0
- package/src/clients/node-js/lib/python-runtime.js +253 -0
- package/src/clients/node-js/lib/venv-manager.js +242 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package expiry check utility
|
|
3
|
+
* Checks if the package has expired based on publish date + 60 days
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const EXPIRY_DAYS = 60;
|
|
14
|
+
const EXPIRY_MESSAGE =
|
|
15
|
+
'This version of the M365 Evals CLI has stopped working and must be updated';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the package publish date from package.json
|
|
19
|
+
* @returns {Date|null} The publish date or null if not found
|
|
20
|
+
*/
|
|
21
|
+
function getPackagePublishDate() {
|
|
22
|
+
try {
|
|
23
|
+
const packageJsonPath = path.join(
|
|
24
|
+
__dirname,
|
|
25
|
+
'..',
|
|
26
|
+
'..',
|
|
27
|
+
'..',
|
|
28
|
+
'..',
|
|
29
|
+
'package.json'
|
|
30
|
+
);
|
|
31
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
32
|
+
|
|
33
|
+
// Check if publishDate is set in package.json
|
|
34
|
+
if (packageJson.publishDate) {
|
|
35
|
+
return new Date(packageJson.publishDate);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Fall back to package.json modification time as an approximation
|
|
39
|
+
const stats = fs.statSync(packageJsonPath);
|
|
40
|
+
return stats.mtime;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn('Warning: Could not determine package publish date');
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Calculate the expiry date (publish date + 60 days)
|
|
49
|
+
* @param {Date} publishDate - The package publish date
|
|
50
|
+
* @returns {Date} The expiry date
|
|
51
|
+
*/
|
|
52
|
+
function calculateExpiryDate(publishDate) {
|
|
53
|
+
const expiryDate = new Date(publishDate);
|
|
54
|
+
expiryDate.setDate(expiryDate.getDate() + EXPIRY_DAYS);
|
|
55
|
+
return expiryDate;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the package has expired
|
|
60
|
+
* @returns {boolean} True if expired, false otherwise
|
|
61
|
+
*/
|
|
62
|
+
export function isPackageExpired() {
|
|
63
|
+
const publishDate = getPackagePublishDate();
|
|
64
|
+
|
|
65
|
+
if (!publishDate) {
|
|
66
|
+
// If we can't determine the publish date, don't block execution
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const expiryDate = calculateExpiryDate(publishDate);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
|
|
73
|
+
return now > expiryDate;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get information about the package expiry status
|
|
78
|
+
* @returns {Object} Expiry information including dates and days remaining
|
|
79
|
+
*/
|
|
80
|
+
export function getExpiryInfo() {
|
|
81
|
+
const publishDate = getPackagePublishDate();
|
|
82
|
+
|
|
83
|
+
if (!publishDate) {
|
|
84
|
+
return {
|
|
85
|
+
expired: false,
|
|
86
|
+
publishDate: null,
|
|
87
|
+
expiryDate: null,
|
|
88
|
+
daysRemaining: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const expiryDate = calculateExpiryDate(publishDate);
|
|
93
|
+
const now = new Date();
|
|
94
|
+
const daysRemaining = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
expired: now > expiryDate,
|
|
98
|
+
publishDate: publishDate.toISOString().split('T')[0],
|
|
99
|
+
expiryDate: expiryDate.toISOString().split('T')[0],
|
|
100
|
+
daysRemaining: daysRemaining > 0 ? daysRemaining : 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Calculate expiry information for a given publish date
|
|
106
|
+
* @param {string|Date|null} publishDate - The publish date
|
|
107
|
+
* @returns {Object} Expiry information
|
|
108
|
+
*/
|
|
109
|
+
export function calculateExpiryInfo(publishDate) {
|
|
110
|
+
if (!publishDate) {
|
|
111
|
+
return {
|
|
112
|
+
expired: false,
|
|
113
|
+
publishDate: null,
|
|
114
|
+
expiryDate: null,
|
|
115
|
+
daysRemaining: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pubDate =
|
|
120
|
+
typeof publishDate === 'string' ? new Date(publishDate) : publishDate;
|
|
121
|
+
const expiryDate = new Date(pubDate);
|
|
122
|
+
expiryDate.setDate(expiryDate.getDate() + EXPIRY_DAYS);
|
|
123
|
+
|
|
124
|
+
const now = new Date();
|
|
125
|
+
const daysRemaining = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
expired: now >= expiryDate,
|
|
129
|
+
publishDate: pubDate.toISOString().split('T')[0],
|
|
130
|
+
expiryDate: expiryDate.toISOString().split('T')[0],
|
|
131
|
+
daysRemaining: daysRemaining > 0 ? daysRemaining : 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check package expiry based on expiry info and exit or warn accordingly
|
|
137
|
+
* @param {Object} info - Expiry information from calculateExpiryInfo()
|
|
138
|
+
*/
|
|
139
|
+
export function handleExpiryCheck(info) {
|
|
140
|
+
// If expired, show error and exit
|
|
141
|
+
if (info.expired) {
|
|
142
|
+
console.error('\n❌ ' + EXPIRY_MESSAGE);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// If close to expiry (within 7 days), show warning
|
|
147
|
+
if (info.daysRemaining !== null && info.daysRemaining <= 7) {
|
|
148
|
+
console.warn(
|
|
149
|
+
'\n⚠️ This version of the M365 Evals CLI will stop working soon and should be updated.'
|
|
150
|
+
);
|
|
151
|
+
console.warn(` Days remaining: ${info.daysRemaining}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check package expiry and exit if expired, or warn if close to expiry
|
|
157
|
+
* This is the main function that should be called at startup
|
|
158
|
+
* Displays appropriate message and exits with code 1 if expired
|
|
159
|
+
*/
|
|
160
|
+
export function checkPackageExpiry() {
|
|
161
|
+
const publishDate = getPackagePublishDate();
|
|
162
|
+
const info = calculateExpiryInfo(publishDate);
|
|
163
|
+
handleExpiryCheck(info);
|
|
164
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @microsoft/m365-copilot-eval
|
|
3
|
+
*
|
|
4
|
+
* Zero-config Node.js wrapper for M365 Copilot Agent Evaluations
|
|
5
|
+
* using Python Build Standalone and Azure AI Evaluation SDK
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
ensurePythonRuntime,
|
|
10
|
+
getPythonExecutable,
|
|
11
|
+
getCacheDir,
|
|
12
|
+
getPlatformKey,
|
|
13
|
+
} from './python-runtime.js';
|
|
14
|
+
|
|
15
|
+
export { ensureVenv, executePythonCli, getVenvDir } from './venv-manager.js';
|
|
16
|
+
|
|
17
|
+
export { getCacheStats, clearCache, formatBytes } from './cache-utils.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
checkPackageExpiry,
|
|
21
|
+
calculateExpiryInfo,
|
|
22
|
+
handleExpiryCheck,
|
|
23
|
+
isPackageExpired,
|
|
24
|
+
getExpiryInfo,
|
|
25
|
+
} from './expiry-check.js';
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
import { createWriteStream } from 'fs';
|
|
7
|
+
import fetch from 'node-fetch';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Python Build Standalone (PBS) configuration
|
|
11
|
+
* Using Python 3.13.10 builds from https://github.com/indygreg/python-build-standalone
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// PBS release version (latest release with Python 3.13.10)
|
|
15
|
+
const PBS_VERSION = '20251202';
|
|
16
|
+
const PYTHON_VERSION = '3.13.10';
|
|
17
|
+
|
|
18
|
+
// Base URL for PBS releases
|
|
19
|
+
const PBS_BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${PBS_VERSION}`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Platform-specific PBS distribution mapping
|
|
23
|
+
* Format: cpython-{version}+{build}-{arch}-{vendor}-{os}-{profile}.tar.{compression}
|
|
24
|
+
*/
|
|
25
|
+
const PBS_DISTRIBUTIONS = {
|
|
26
|
+
'darwin-x64': {
|
|
27
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-apple-darwin-install_only.tar.gz`,
|
|
28
|
+
sha256: 'a02761a4f189f71c0512e88df7ca2843696d61da659e47f8a5c8a9bd2c0d16f4', // Placeholder - needs actual hash
|
|
29
|
+
},
|
|
30
|
+
'darwin-arm64': {
|
|
31
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-apple-darwin-install_only.tar.gz`,
|
|
32
|
+
sha256: '37afe4e77ab62ac50f197b1cb1f3bc02c82735c6be893da0996afcde5dc41048', // Placeholder
|
|
33
|
+
},
|
|
34
|
+
'linux-x64': {
|
|
35
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-unknown-linux-gnu-install_only.tar.gz`,
|
|
36
|
+
sha256: '0cac1495fff920219904b1d573aaec0df54d549c226cb45f5c60cb6d2c72727a', // Placeholder
|
|
37
|
+
},
|
|
38
|
+
'linux-arm64': {
|
|
39
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-unknown-linux-gnu-install_only.tar.gz`,
|
|
40
|
+
sha256: 'c68280591cda1c9515a04809fa6926020177e8e5892300206e0496ea1d10290e', // Placeholder
|
|
41
|
+
},
|
|
42
|
+
'win32-x64': {
|
|
43
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-pc-windows-msvc-install_only.tar.gz`,
|
|
44
|
+
sha256: '8b00014c7c35f9ad4cb1c565f067500bacc4125c8bc30e4389ee0be9fd6ffa3d',
|
|
45
|
+
},
|
|
46
|
+
'win32-arm64': {
|
|
47
|
+
filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-pc-windows-msvc-install_only.tar.gz`,
|
|
48
|
+
sha256: '9060d644bd32ac0e0af970d0b21e207e6ff416b7c4dc26ffc4f9b043fb45b463',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the platform key for the current system
|
|
54
|
+
*/
|
|
55
|
+
export function getPlatformKey() {
|
|
56
|
+
const platform = process.platform;
|
|
57
|
+
const arch = process.arch;
|
|
58
|
+
|
|
59
|
+
const platformKey = `${platform}-${arch}`;
|
|
60
|
+
|
|
61
|
+
if (!PBS_DISTRIBUTIONS[platformKey]) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Unsupported platform: ${platform}-${arch}. ` +
|
|
64
|
+
`Supported platforms: ${Object.keys(PBS_DISTRIBUTIONS).join(', ')}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return platformKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get cache directory for Python runtime
|
|
73
|
+
* Priority: RUNEVALS_CACHE_DIR > XDG/platform cache > temp
|
|
74
|
+
*/
|
|
75
|
+
export function getCacheDir() {
|
|
76
|
+
if (process.env.RUNEVALS_CACHE_DIR) {
|
|
77
|
+
return path.resolve(process.env.RUNEVALS_CACHE_DIR);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const homeDir = os.homedir();
|
|
81
|
+
|
|
82
|
+
switch (process.platform) {
|
|
83
|
+
case 'darwin':
|
|
84
|
+
return path.join(homeDir, 'Library', 'Caches', 'm365-copilot-eval');
|
|
85
|
+
case 'win32':
|
|
86
|
+
return path.join(
|
|
87
|
+
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
|
88
|
+
'm365-copilot-eval'
|
|
89
|
+
);
|
|
90
|
+
default: // Linux and others
|
|
91
|
+
return path.join(
|
|
92
|
+
process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'),
|
|
93
|
+
'm365-copilot-eval'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calculate SHA256 hash of a file
|
|
100
|
+
*/
|
|
101
|
+
async function calculateFileHash(filePath) {
|
|
102
|
+
const hash = crypto.createHash('sha256');
|
|
103
|
+
const stream = (await import('fs')).createReadStream(filePath);
|
|
104
|
+
|
|
105
|
+
for await (const chunk of stream) {
|
|
106
|
+
hash.update(chunk);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return hash.digest('hex');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Download a file with progress indication
|
|
114
|
+
*/
|
|
115
|
+
async function downloadFile(url, destPath, expectedHash) {
|
|
116
|
+
console.log(`Downloading: ${url}`);
|
|
117
|
+
|
|
118
|
+
// Support proxy configuration
|
|
119
|
+
const fetchOptions = {};
|
|
120
|
+
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY) {
|
|
121
|
+
const { HttpsProxyAgent } = await import('https-proxy-agent');
|
|
122
|
+
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
|
123
|
+
fetchOptions.agent = new HttpsProxyAgent(proxy);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const response = await fetch(url, fetchOptions);
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Failed to download: ${response.status} ${response.statusText}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Create parent directory if needed
|
|
135
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
136
|
+
|
|
137
|
+
// Stream to file
|
|
138
|
+
const fileStream = createWriteStream(destPath);
|
|
139
|
+
await pipeline(response.body, fileStream);
|
|
140
|
+
|
|
141
|
+
// Verify checksum
|
|
142
|
+
console.log('Verifying checksum...');
|
|
143
|
+
const actualHash = await calculateFileHash(destPath);
|
|
144
|
+
|
|
145
|
+
if (actualHash !== expectedHash) {
|
|
146
|
+
await fs.unlink(destPath); // Remove corrupted file
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Checksum mismatch!\n` +
|
|
149
|
+
` Expected: ${expectedHash}\n` +
|
|
150
|
+
` Actual: ${actualHash}\n` +
|
|
151
|
+
`This could indicate a corrupted download or a supply chain attack.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log('Checksum verified ✓');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract tar.gz archive
|
|
160
|
+
*/
|
|
161
|
+
async function extractTarGz(archivePath, destDir) {
|
|
162
|
+
const tar = await import('tar');
|
|
163
|
+
|
|
164
|
+
console.log(`Extracting to: ${destDir}`);
|
|
165
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
166
|
+
|
|
167
|
+
await tar.extract({
|
|
168
|
+
file: archivePath,
|
|
169
|
+
cwd: destDir,
|
|
170
|
+
strip: 1, // Remove the top-level directory from archive
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
console.log('Extraction complete ✓');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Download and setup Python Build Standalone runtime
|
|
178
|
+
* Returns the path to the Python executable
|
|
179
|
+
*/
|
|
180
|
+
export async function ensurePythonRuntime(verbose = false) {
|
|
181
|
+
const platformKey = getPlatformKey();
|
|
182
|
+
const distribution = PBS_DISTRIBUTIONS[platformKey];
|
|
183
|
+
|
|
184
|
+
const cacheDir = getCacheDir();
|
|
185
|
+
const pythonDir = path.join(
|
|
186
|
+
cacheDir,
|
|
187
|
+
'python',
|
|
188
|
+
`${PYTHON_VERSION}-${platformKey}`
|
|
189
|
+
);
|
|
190
|
+
const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
|
|
191
|
+
|
|
192
|
+
// Determine Python executable path based on platform
|
|
193
|
+
let pythonExe;
|
|
194
|
+
if (process.platform === 'win32') {
|
|
195
|
+
pythonExe = path.join(pythonDir, 'python.exe');
|
|
196
|
+
} else {
|
|
197
|
+
pythonExe = path.join(pythonDir, 'bin', 'python3');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check if Python is already installed
|
|
201
|
+
try {
|
|
202
|
+
await fs.access(pythonExe);
|
|
203
|
+
if (verbose) {
|
|
204
|
+
console.log(`Using cached Python runtime: ${pythonExe}`);
|
|
205
|
+
}
|
|
206
|
+
return pythonExe;
|
|
207
|
+
} catch {
|
|
208
|
+
// Python not found, proceed with download and extraction
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(
|
|
212
|
+
`Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Download if not cached
|
|
216
|
+
try {
|
|
217
|
+
await fs.access(archivePath);
|
|
218
|
+
if (verbose) {
|
|
219
|
+
console.log('Using cached download');
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
const downloadUrl = `${PBS_BASE_URL}/${distribution.filename}`;
|
|
223
|
+
await downloadFile(downloadUrl, archivePath, distribution.sha256);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Extract
|
|
227
|
+
await extractTarGz(archivePath, pythonDir);
|
|
228
|
+
|
|
229
|
+
// Verify Python executable exists
|
|
230
|
+
await fs.access(pythonExe);
|
|
231
|
+
|
|
232
|
+
console.log(`Python runtime ready: ${pythonExe}`);
|
|
233
|
+
return pythonExe;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get Python executable path (assumes runtime is already set up)
|
|
238
|
+
*/
|
|
239
|
+
export async function getPythonExecutable() {
|
|
240
|
+
const platformKey = getPlatformKey();
|
|
241
|
+
const cacheDir = getCacheDir();
|
|
242
|
+
const pythonDir = path.join(
|
|
243
|
+
cacheDir,
|
|
244
|
+
'python',
|
|
245
|
+
`${PYTHON_VERSION}-${platformKey}`
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (process.platform === 'win32') {
|
|
249
|
+
return path.join(pythonDir, 'python.exe');
|
|
250
|
+
} else {
|
|
251
|
+
return path.join(pythonDir, 'bin', 'python3');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { getPythonExecutable, getCacheDir } from './python-runtime.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Execute a command and return stdout
|
|
9
|
+
*/
|
|
10
|
+
function execCommand(command, args, options = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const proc = spawn(command, args, {
|
|
13
|
+
...options,
|
|
14
|
+
shell: process.platform === 'win32',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
|
|
20
|
+
if (proc.stdout) {
|
|
21
|
+
proc.stdout.on('data', (data) => {
|
|
22
|
+
stdout += data.toString();
|
|
23
|
+
if (options.verbose) {
|
|
24
|
+
process.stdout.write(data);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (proc.stderr) {
|
|
30
|
+
proc.stderr.on('data', (data) => {
|
|
31
|
+
stderr += data.toString();
|
|
32
|
+
if (options.verbose) {
|
|
33
|
+
process.stderr.write(data);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
proc.on('error', reject);
|
|
39
|
+
|
|
40
|
+
proc.on('close', (code) => {
|
|
41
|
+
if (code === 0) {
|
|
42
|
+
resolve({ stdout, stderr, code });
|
|
43
|
+
} else {
|
|
44
|
+
reject(new Error(`Command failed with code ${code}\n${stderr}`));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calculate hash of requirements file for cache invalidation
|
|
52
|
+
*/
|
|
53
|
+
async function getRequirementsHash(requirementsPath) {
|
|
54
|
+
const content = await fs.readFile(requirementsPath, 'utf-8');
|
|
55
|
+
return crypto
|
|
56
|
+
.createHash('sha256')
|
|
57
|
+
.update(content)
|
|
58
|
+
.digest('hex')
|
|
59
|
+
.substring(0, 16);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the venv directory path
|
|
64
|
+
*/
|
|
65
|
+
export function getVenvDir() {
|
|
66
|
+
const cacheDir = getCacheDir();
|
|
67
|
+
return path.join(cacheDir, 'venv');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the venv Python executable path
|
|
72
|
+
*/
|
|
73
|
+
function getVenvPython(venvDir) {
|
|
74
|
+
if (process.platform === 'win32') {
|
|
75
|
+
return path.join(venvDir, 'Scripts', 'python.exe');
|
|
76
|
+
} else {
|
|
77
|
+
return path.join(venvDir, 'bin', 'python');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the venv pip executable path
|
|
83
|
+
*/
|
|
84
|
+
function getVenvPip(venvDir) {
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
return path.join(venvDir, 'Scripts', 'pip.exe');
|
|
87
|
+
} else {
|
|
88
|
+
return path.join(venvDir, 'bin', 'pip');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a virtual environment
|
|
94
|
+
*/
|
|
95
|
+
async function createVenv(pythonExe, venvDir, verbose = false) {
|
|
96
|
+
if (verbose) {
|
|
97
|
+
console.log(`Creating virtual environment at: ${venvDir}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await fs.mkdir(venvDir, { recursive: true });
|
|
101
|
+
|
|
102
|
+
await execCommand(pythonExe, ['-m', 'venv', venvDir], { verbose });
|
|
103
|
+
|
|
104
|
+
if (verbose) {
|
|
105
|
+
console.log('Virtual environment created ✓');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Install packages from requirements.txt into venv
|
|
111
|
+
*/
|
|
112
|
+
async function installRequirements(venvDir, requirementsPath, verbose = false) {
|
|
113
|
+
const pipExe = getVenvPip(venvDir);
|
|
114
|
+
|
|
115
|
+
console.log('Installing Python dependencies...');
|
|
116
|
+
if (verbose) {
|
|
117
|
+
console.log(`Using pip: ${pipExe}`);
|
|
118
|
+
console.log(`Requirements: ${requirementsPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Upgrade pip first
|
|
122
|
+
await execCommand(pipExe, ['install', '--upgrade', 'pip'], { verbose });
|
|
123
|
+
|
|
124
|
+
// Install requirements with hash checking if available
|
|
125
|
+
const args = ['install', '-r', requirementsPath];
|
|
126
|
+
|
|
127
|
+
// Support proxy and certificate configuration
|
|
128
|
+
if (process.env.PIP_CERT) {
|
|
129
|
+
args.push('--cert', process.env.PIP_CERT);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (process.env.PIP_TRUSTED_HOST) {
|
|
133
|
+
args.push('--trusted-host', process.env.PIP_TRUSTED_HOST);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await execCommand(pipExe, args, { verbose });
|
|
137
|
+
|
|
138
|
+
console.log('Dependencies installed ✓');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if venv exists and is valid
|
|
143
|
+
*/
|
|
144
|
+
async function isVenvValid(venvDir, requirementsHash) {
|
|
145
|
+
try {
|
|
146
|
+
// Check if venv Python exists
|
|
147
|
+
const venvPython = getVenvPython(venvDir);
|
|
148
|
+
await fs.access(venvPython);
|
|
149
|
+
|
|
150
|
+
// Check if requirements hash matches
|
|
151
|
+
const markerFile = path.join(venvDir, '.requirements-hash');
|
|
152
|
+
try {
|
|
153
|
+
const storedHash = await fs.readFile(markerFile, 'utf-8');
|
|
154
|
+
return storedHash.trim() === requirementsHash;
|
|
155
|
+
} catch {
|
|
156
|
+
return false; // Marker file doesn't exist
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
return false; // Venv doesn't exist
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write requirements hash marker
|
|
165
|
+
*/
|
|
166
|
+
async function writeRequirementsMarker(venvDir, requirementsHash) {
|
|
167
|
+
const markerFile = path.join(venvDir, '.requirements-hash');
|
|
168
|
+
await fs.writeFile(markerFile, requirementsHash, 'utf-8');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Ensure virtual environment is set up with all dependencies
|
|
173
|
+
* Returns the path to the venv Python executable
|
|
174
|
+
*/
|
|
175
|
+
export async function ensureVenv(requirementsPath, verbose = false) {
|
|
176
|
+
const pythonExe = await getPythonExecutable();
|
|
177
|
+
const venvDir = getVenvDir();
|
|
178
|
+
const requirementsHash = await getRequirementsHash(requirementsPath);
|
|
179
|
+
|
|
180
|
+
// Check if venv is already set up and valid
|
|
181
|
+
if (await isVenvValid(venvDir, requirementsHash)) {
|
|
182
|
+
if (verbose) {
|
|
183
|
+
console.log('Using existing virtual environment');
|
|
184
|
+
}
|
|
185
|
+
return getVenvPython(venvDir);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log('Setting up Python virtual environment...');
|
|
189
|
+
|
|
190
|
+
// Remove old venv if it exists but is invalid
|
|
191
|
+
try {
|
|
192
|
+
await fs.rm(venvDir, { recursive: true, force: true });
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore errors during cleanup
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Create new venv
|
|
198
|
+
await createVenv(pythonExe, venvDir, verbose);
|
|
199
|
+
|
|
200
|
+
// Install requirements
|
|
201
|
+
await installRequirements(venvDir, requirementsPath, verbose);
|
|
202
|
+
|
|
203
|
+
// Mark as complete
|
|
204
|
+
await writeRequirementsMarker(venvDir, requirementsHash);
|
|
205
|
+
|
|
206
|
+
console.log('Virtual environment ready ✓');
|
|
207
|
+
return getVenvPython(venvDir);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Execute Python CLI script with the venv Python
|
|
212
|
+
*/
|
|
213
|
+
export async function executePythonCli(scriptPath, args = [], options = {}) {
|
|
214
|
+
const venvPython = getVenvPython(getVenvDir());
|
|
215
|
+
|
|
216
|
+
if (!options.quiet) {
|
|
217
|
+
console.log(`Executing Python CLI: ${scriptPath} ${args.join(' ')}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const proc = spawn(venvPython, [scriptPath, ...args], {
|
|
222
|
+
stdio: 'inherit', // Pass through stdin/stdout/stderr for interactive auth
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
// Ensure venv is activated by setting PATH
|
|
226
|
+
PYTHONHOME: '', // Clear PYTHONHOME to avoid conflicts
|
|
227
|
+
},
|
|
228
|
+
cwd: options.cwd || process.cwd(), // Allow custom working directory
|
|
229
|
+
...options,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
proc.on('error', reject);
|
|
233
|
+
|
|
234
|
+
proc.on('close', (code) => {
|
|
235
|
+
if (code === 0) {
|
|
236
|
+
resolve(code);
|
|
237
|
+
} else {
|
|
238
|
+
reject(new Error(`Python CLI exited with code ${code}`));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|