Skip to content

Commit 3bfc6e6

Browse files
authored
Refactor Python installation logic and compatibility checks
1 parent 6deebb7 commit 3bfc6e6

File tree

1 file changed

+88
-183
lines changed

1 file changed

+88
-183
lines changed

src/installer/get-python.js

Lines changed: 88 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -14,90 +14,62 @@ import path from 'path';
1414
import { promisify } from 'util';
1515

1616
const execFile = promisify(require('child_process').execFile);
17+
const FALLBACK_PYTHON_VERSION = '3.13';
1718

1819
/**
1920
* Simple logger for minimal output with timestamp
20-
* @param {string} level - Log level ('info', 'warn', 'error')
21-
* @param {string} message - Log message to output
2221
*/
2322
function log(level, message) {
2423
const timestamp = new Date().toISOString();
25-
// eslint-disable-next-line no-console
2624
console[level](`[${timestamp}] [Python-Installer] ${message}`);
2725
}
2826

2927
/**
30-
* Check if Python version meets compatibility requirements
31-
* Supports different validation modes for finding existing vs installing new Python
32-
* @param {string} pythonVersion - Python version string (e.g., "3.13.1")
33-
* @param {boolean} forInstallation - If true, only allows 3.13.x; if false, allows 3.10-3.13
34-
* @returns {boolean} True if version is compatible with requirements
28+
* Check if Python version is compatible (3.10-3.13)
3529
*/
36-
function isPythonVersionCompatible(pythonVersion, forInstallation = false) {
30+
function isPythonCompatible(pythonVersion) {
3731
const versionParts = pythonVersion.split('.');
3832
const major = parseInt(versionParts[0], 10);
3933
const minor = parseInt(versionParts[1], 10);
4034

41-
if (major !== 3) {
42-
return false;
43-
}
44-
45-
if (forInstallation) {
46-
return minor === 13; // Only 3.13.x for new installations
47-
} else {
48-
return minor >= 10 && minor <= 13; // 3.10-3.13 for finding existing installations
49-
}
35+
return major === 3 && minor >= 10 && minor <= 13;
5036
}
5137

5238
/**
53-
* Search for existing Python executable in system PATH with version validation
54-
* Scans through PATH directories to find compatible Python installations.
55-
* Accepts Python versions 3.10 through 3.13. Returns first valid installation found.
56-
* @returns {Promise<string|null>} Path to first valid Python executable or null if not found
57-
* @throws {Error} If distutils module is missing in found Python installation
39+
* Search for existing compatible Python installation (3.10-3.13) in system PATH
5840
*/
5941
export async function findPythonExecutable() {
6042
const exenames = proc.IS_WINDOWS ? ['python.exe'] : ['python3', 'python'];
6143
const envPath = process.env.PLATFORMIO_PATH || process.env.PATH;
62-
const errors = [];
6344

6445
log('info', 'Searching for compatible Python installation (3.10-3.13)');
6546

66-
// Search through all PATH locations for Python executables with early exit on first match
6747
for (const location of envPath.split(path.delimiter)) {
6848
for (const exename of exenames) {
6949
const executable = path.normalize(path.join(location, exename)).replace(/"/g, '');
50+
7051
try {
71-
if (
72-
fs.existsSync(executable) &&
73-
(await isValidPythonVersion(executable)) &&
74-
(await callInstallerScript(executable, ['check', 'python']))
75-
) {
52+
if (fs.existsSync(executable) &&
53+
await isValidPythonVersion(executable) &&
54+
await callInstallerScript(executable, ['check', 'python'])) {
7655
log('info', `Found compatible Python: ${executable}`);
7756
return executable;
7857
}
7958
} catch (err) {
80-
errors.push(err);
59+
// Continue searching, but log significant errors
60+
if (err.message && !err.message.includes('timeout')) {
61+
log('warn', `Python check failed for ${executable}: ${err.message}`);
62+
}
8163
}
8264
}
8365
}
8466

85-
// Handle specific error conditions that should be propagated
86-
for (const err of errors) {
87-
if (err.toString().includes('Could not find distutils module')) {
88-
throw err;
89-
}
90-
}
91-
92-
log('info', 'No compatible system Python found, will install Python 3.13');
67+
log('info', 'No compatible Python found, will install Python 3.13 via UV');
9368
return null;
9469
}
9570

9671
/**
97-
* Validate Python executable version and basic functionality
98-
* This function is used for FINDING existing installations, not for installation validation
99-
* @param {string} executable - Full path to Python executable
100-
* @returns {Promise<boolean>} True if Python version is acceptable (3.10-3.13)
72+
* Validate Python executable version (3.10-3.13)
10173
*/
10274
async function isValidPythonVersion(executable) {
10375
try {
@@ -113,218 +85,151 @@ async function isValidPythonVersion(executable) {
11385
return false;
11486
}
11587

116-
return isPythonVersionCompatible(versionMatch[1], false); // Allow 3.10-3.13 for finding existing
88+
const version = versionMatch[1];
89+
const isCompatible = isPythonCompatible(version);
90+
91+
if (isCompatible) {
92+
log('info', `Found Python ${version}`);
93+
}
94+
95+
return isCompatible;
11796
} catch {
11897
return false;
11998
}
12099
}
121100

122101
/**
123-
* Check if UV (astral-sh/uv) package manager is available on the system
124-
* UV is a fast Python package installer and resolver written in Rust
125-
* @returns {Promise<boolean>} True if UV is installed and accessible via PATH
102+
* Check if UV package manager is available
126103
*/
127104
async function isUVAvailable() {
128105
try {
129106
await execFile('uv', ['--version'], { timeout: 5000 });
130-
log('info', 'UV is available on system');
107+
log('info', 'UV is available');
131108
return true;
132109
} catch {
133-
log('info', 'UV not found on system');
110+
log('info', 'UV not found, will install');
134111
return false;
135112
}
136113
}
137114

138115
/**
139-
* Install UV package manager using official installation scripts
140-
* Downloads and runs platform-specific installer from astral.sh
141-
* @returns {Promise<void>}
142-
* @throws {Error} If UV installation fails
116+
* Install UV package manager
143117
*/
144118
async function installUV() {
145119
log('info', 'Installing UV package manager');
146120

147121
try {
148122
if (proc.IS_WINDOWS) {
149-
// Windows: Use PowerShell with official installer script
150-
await execFile(
151-
'powershell',
152-
[
153-
'-NoProfile',
154-
'-ExecutionPolicy',
155-
'Bypass',
156-
'-Command',
157-
'irm https://astral.sh/uv/install.ps1 | iex',
158-
],
159-
{ timeout: 120000 },
160-
);
123+
await execFile('powershell', [
124+
'-NoProfile',
125+
'-ExecutionPolicy', 'Bypass',
126+
'-Command',
127+
'irm https://astral.sh/uv/install.ps1 | iex'
128+
], { timeout: 120000 });
161129
} else {
162-
// Unix/Linux/macOS: Use shell with curl installer
163130
await execFile('sh', ['-c', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], {
164-
timeout: 120000,
131+
timeout: 120000
165132
});
166133
}
167-
168134
log('info', 'UV installation completed');
169135
} catch (err) {
170136
throw new Error(`Failed to install UV: ${err.message}`);
171137
}
172138
}
173139

174140
/**
175-
* Install Python using UV package manager
176-
* Uses UV to download and install Python from astral-sh/python-build-standalone
177-
* Automatically handles platform detection, download, verification, and extraction
178-
* @param {string} destinationDir - Target installation directory
179-
* @param {string} pythonVersion - Python version to install (default: "3.13")
180-
* @returns {Promise<string>} Path to installed Python directory
181-
* @throws {Error} If UV installation or Python installation fails
141+
* Install Python 3.13 using UV
182142
*/
183-
async function installPythonWithUV(destinationDir, pythonVersion = '3.13') {
184-
log('info', `Installing Python ${pythonVersion} using UV`);
143+
async function installPython313WithUV(destinationDir) {
144+
log('info', `Installing Python ${FALLBACK_PYTHON_VERSION} using UV`);
185145

186-
// Ensure UV is available, install if necessary
146+
// Ensure UV is available
187147
if (!(await isUVAvailable())) {
188148
await installUV();
189149
}
190150

191-
// Clean up any existing installation to avoid conflicts
151+
// Clean and create destination directory
192152
try {
193153
await fs.promises.rm(destinationDir, { recursive: true, force: true });
194-
} catch (err) {
195-
// Ignore cleanup errors (directory might not exist)
154+
} catch {
155+
// Ignore if directory doesn't exist
196156
}
197-
198-
// Create destination directory structure
199157
await fs.promises.mkdir(destinationDir, { recursive: true });
200158

201-
try {
202-
// Configure environment for UV Python installation
203-
const env = {
204-
...process.env,
205-
UV_PYTHON_INSTALL_DIR: destinationDir,
206-
UV_CACHE_DIR: path.join(core.getTmpDir(), 'uv-cache'),
207-
};
159+
// Install Python via UV
160+
const env = {
161+
...process.env,
162+
UV_PYTHON_INSTALL_DIR: destinationDir,
163+
UV_CACHE_DIR: path.join(core.getTmpDir(), 'uv-cache'),
164+
};
208165

209-
// Execute UV Python installation command
210-
await execFile('uv', ['python', 'install', pythonVersion], {
166+
try {
167+
await execFile('uv', ['python', 'install', FALLBACK_PYTHON_VERSION], {
211168
env,
212-
timeout: 300000, // 5 minutes timeout for download and installation
169+
timeout: 300000, // 5 minutes
213170
cwd: destinationDir,
214171
});
215172

216-
// Verify that Python executable was successfully installed
217-
await ensurePythonExeExists(destinationDir, pythonVersion);
218-
219-
log('info', `Python ${pythonVersion} installation completed: ${destinationDir}`);
173+
log('info', `Python ${FALLBACK_PYTHON_VERSION} installation completed: ${destinationDir}`);
220174
return destinationDir;
221175
} catch (err) {
222176
throw new Error(`UV Python installation failed: ${err.message}`);
223177
}
224178
}
225179

226180
/**
227-
* Verify that Python executable exists in the installed directory
228-
* Searches through common installation paths where UV might place Python executables
229-
* @param {string} pythonDir - Directory containing Python installation
230-
* @param {string} pythonVersion - Python version for path construction (default: "3.13")
231-
* @returns {Promise<boolean>} True if executable exists and is accessible
232-
* @throws {Error} If no Python executable found in expected locations
181+
* Find Python executable in installed directory
233182
*/
234-
async function ensurePythonExeExists(pythonDir, pythonVersion = '3.13') {
235-
// UV typically installs to subdirectories organized by version
236-
const possiblePaths = [
237-
pythonDir, // Direct installation in target directory
238-
path.join(pythonDir, 'python'),
239-
path.join(pythonDir, `python-${pythonVersion}`),
240-
path.join(pythonDir, pythonVersion),
183+
function getPythonExecutablePath(pythonDir) {
184+
const exeName = proc.IS_WINDOWS ? 'python.exe' : 'python3';
185+
186+
// Common UV installation paths
187+
const searchPaths = [
188+
path.join(pythonDir, exeName),
189+
path.join(pythonDir, 'bin', exeName),
190+
path.join(pythonDir, FALLBACK_PYTHON_VERSION, exeName),
191+
path.join(pythonDir, FALLBACK_PYTHON_VERSION, 'bin', exeName),
241192
];
242193

243-
const executables = proc.IS_WINDOWS ? ['python.exe'] : ['python3', 'python'];
244-
245-
for (const basePath of possiblePaths) {
246-
// Check for executable in root of installation path
247-
for (const exeName of executables) {
248-
try {
249-
await fs.promises.access(path.join(basePath, exeName));
250-
return true;
251-
} catch (err) {
252-
// Continue trying other combinations
253-
}
254-
}
255-
256-
// Check for executable in bin subdirectory (Unix-style layout)
257-
const binDir = path.join(basePath, 'bin');
258-
for (const exeName of executables) {
259-
try {
260-
await fs.promises.access(path.join(binDir, exeName));
261-
return true;
262-
} catch (err) {
263-
// Continue trying other combinations
264-
}
194+
for (const execPath of searchPaths) {
195+
try {
196+
fs.accessSync(execPath, fs.constants.X_OK);
197+
log('info', `Found Python executable: ${execPath}`);
198+
return execPath;
199+
} catch {
200+
// Continue searching
265201
}
266202
}
267203

268-
throw new Error('Python executable does not exist after UV installation!');
204+
throw new Error(`Could not find Python executable in ${pythonDir}`);
269205
}
270206

271207
/**
272-
* Main entry point for installing Python distribution using UV
273-
* This replaces the legacy complex installation logic with a simple UV-based approach
274-
* @param {string} destinationDir - Target installation directory
275-
* @param {object} options - Optional configuration (kept for API compatibility)
276-
* @returns {Promise<string>} Path to installed Python directory
277-
* @throws {Error} If Python installation fails for any reason
208+
* Main entry point: find existing compatible Python (3.10-3.13) or install Python 3.13 via UV
278209
*/
279210
export async function installPortablePython(destinationDir) {
280-
log('info', 'Starting Python 3.13 installation');
211+
log('info', 'Starting Python setup (detecting 3.10-3.13, installing 3.13 if needed)');
281212

282-
// UV-based installation is now the only supported method
283213
try {
284-
return await installPythonWithUV(destinationDir, '3.13');
285-
} catch (uvError) {
286-
log('error', `UV installation failed: ${uvError.message}`);
214+
// Try to find existing compatible Python first
215+
const existingPython = await findPythonExecutable();
216+
if (existingPython) {
217+
log('info', `Using existing compatible Python: ${existingPython}`);
218+
return path.dirname(existingPython);
219+
}
220+
221+
// No compatible Python found, install Python 3.13 via UV
222+
log('info', 'No compatible Python installation found, installing Python 3.13');
223+
return await installPython313WithUV(destinationDir);
224+
225+
} catch (error) {
226+
log('error', `Python setup failed: ${error.message}`);
287227
throw new Error(
288-
`Python installation failed: ${uvError.message}. Please ensure UV can be installed and internet connection is available.`,
228+
`Python installation failed: ${error.message}. ` +
229+
`Please ensure internet connection is available and system supports UV installation.`
289230
);
290231
}
291232
}
292233

293-
/**
294-
* Locate Python executable in an installed Python directory
295-
* Searches through common locations where UV might install Python executables
296-
* @param {string} pythonDir - Python installation directory to search
297-
* @returns {Promise<string>} Full path to Python executable
298-
* @throws {Error} If no executable found in the directory
299-
*/
300-
function getPythonExecutablePath(pythonDir) {
301-
const executables = proc.IS_WINDOWS ? ['python.exe'] : ['python3', 'python'];
302-
303-
// Check common locations where UV might install Python
304-
const searchPaths = [
305-
pythonDir,
306-
path.join(pythonDir, 'bin'),
307-
path.join(pythonDir, 'python'),
308-
path.join(pythonDir, 'python-3.13'),
309-
path.join(pythonDir, '3.13'),
310-
path.join(pythonDir, '3.13', 'bin'),
311-
];
312-
313-
for (const searchPath of searchPaths) {
314-
for (const exeName of executables) {
315-
const fullPath = path.join(searchPath, exeName);
316-
try {
317-
fs.accessSync(fullPath, fs.constants.X_OK);
318-
log('info', `Found Python executable: ${fullPath}`);
319-
return fullPath;
320-
} catch (err) {
321-
// Continue searching through all combinations
322-
}
323-
}
324-
}
325-
326-
throw new Error(`Could not find Python executable in ${pythonDir}`);
327-
}
328-
329-
// Export utility functions for external use
330-
export { isPythonVersionCompatible, isUVAvailable, installUV, getPythonExecutablePath };
234+
// Export utility functions
235+
export { isPythonCompatible, isUVAvailable, installUV, getPythonExecutablePath };

0 commit comments

Comments
 (0)