From b01083448ce31e3d24d8420641f22ced1417e7a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:52:24 +0000 Subject: [PATCH 1/3] Initial plan for issue From dcf8d6198cb9e85506b95d652a6c5f96a5f23c5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:08:38 +0000 Subject: [PATCH 2/3] Add validation and alternative installation method for Python installations Co-authored-by: sanjuyadav24 <185911972+sanjuyadav24@users.noreply.github.com> --- .../L0ValidatesIncompleteInstallation.ts | 139 ++++++++ .../installpythonversion.ts | 298 +++++++++++++++++- 2 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 Tasks/UsePythonVersionV0/Tests/L0ValidatesIncompleteInstallation.ts diff --git a/Tasks/UsePythonVersionV0/Tests/L0ValidatesIncompleteInstallation.ts b/Tasks/UsePythonVersionV0/Tests/L0ValidatesIncompleteInstallation.ts new file mode 100644 index 000000000000..6d6882af958a --- /dev/null +++ b/Tasks/UsePythonVersionV0/Tests/L0ValidatesIncompleteInstallation.ts @@ -0,0 +1,139 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +import { TaskMockRunner } from 'azure-pipelines-task-lib/mock-run'; + +const taskPath = path.join(__dirname, '..', 'main.js'); +const taskRunner = new TaskMockRunner(taskPath); + +const TEST_GITHUB_TOKEN = 'testtoken'; + +taskRunner.setInput('versionSpec', '3.12.x'); +taskRunner.setInput('disableDownloadFromRegistry', 'false'); +taskRunner.setInput('addToPath', 'true'); +taskRunner.setInput('architecture', 'x64'); +taskRunner.setInput('githubToken', TEST_GITHUB_TOKEN); + +// `getVariable` is not supported by `TaskLibAnswers` +process.env['AGENT_TOOLSDIRECTORY'] = '$(Agent.ToolsDirectory)'; +process.env['APPDATA'] = 'testappdata'; + +let pythonWasInstalled = false; +let setupExecuted = false; + +// Mock azure-pipelines-tool-lib +taskRunner.registerMock('azure-pipelines-tool-lib/tool', { + findLocalTool() { + if (!pythonWasInstalled) { + return null; + } + + return path.join('C', 'tools', 'Python', '3.12.10', 'x64'); + }, + findLocalToolVersions: () => pythonWasInstalled ? ['3.12.10'] : [], + downloadTool: () => Promise.resolve('C:/downloaded/python.zip'), + extractZip: () => Promise.resolve('C:/extracted/python'), + extractTar() { + throw new Error('This should never be called'); + }, +}); + +taskRunner.registerMock('os', { + platform() { + return 'win32'; + }, + arch() { + return 'x64'; + }, + EOL: '\r\n' +}); + +// Can't mock process, so have to mock taskutil instead +enum Platform { + Windows, + MacOS, + Linux +} + +taskRunner.registerMock('./taskutil', { + Platform, + getPlatform() { + return Platform.Windows; + } +}); + +const tl = require('azure-pipelines-task-lib/mock-task'); +const tlClone = Object.assign({}, tl); +tlClone.exec = function(command, args, options) { + if (command !== 'powershell' || args !== './setup.ps1') { + throw new Error(`Invalid command and arguments: ${command} ${args}`); + } + + if (options.cwd !== 'C:/extracted/python') { + throw new Error(`Invalid python installer dir path: ${options.cwd}`); + } + + setupExecuted = true; + pythonWasInstalled = true; +}; + +// Mock fs.promises to simulate incomplete installation +const fsMock = { + ...fs, + promises: { + stat: async (filePath: string) => { + // Simulate python.exe exists + if (filePath.endsWith('python.exe')) { + return { isFile: () => true, isDirectory: () => false }; + } + // Simulate missing Lib directory (incomplete installation) + if (filePath.endsWith('Lib')) { + throw new Error('ENOENT: no such file or directory'); + } + // Simulate missing libs directory (incomplete installation) + if (filePath.endsWith('libs')) { + throw new Error('ENOENT: no such file or directory'); + } + // Simulate missing include directory (incomplete installation) + if (filePath.endsWith('include')) { + throw new Error('ENOENT: no such file or directory'); + } + // Default to file not existing + throw new Error('ENOENT: no such file or directory'); + }, + readdir: async (dirPath: string) => { + // This shouldn't be called in our incomplete installation scenario + return []; + } + } +}; + +taskRunner.registerMock('fs', fsMock); +taskRunner.registerMock('azure-pipelines-task-lib/mock-task', tlClone); + +// Test manifest contains stable python 3.12.10, so the task should find it +taskRunner.registerMock('typed-rest-client', { + RestClient: class { + get(_url: string) { + return Promise.resolve({ + result: [ + { + "version": "3.12.10", + "stable": true, + "release_url": "https://github.com/actions/python-versions/releases/tag/3.12.10-14343898437", + "files": [ + { + "filename": "python-3.12.10-win32-x64.zip", + "arch": "x64", + "platform": "win32", + "download_url": "https://github.com/actions/python-versions/releases/download/3.12.10-14343898437/python-3.12.10-win32-x64.zip" + } + ] + } + ] + }); + } + } +}); + +taskRunner.run(); \ No newline at end of file diff --git a/Tasks/UsePythonVersionV0/installpythonversion.ts b/Tasks/UsePythonVersionV0/installpythonversion.ts index 1f758e67b8e5..44dee51eba7b 100644 --- a/Tasks/UsePythonVersionV0/installpythonversion.ts +++ b/Tasks/UsePythonVersionV0/installpythonversion.ts @@ -28,7 +28,19 @@ export async function installPythonVersion(versionSpec: string, parameters: Task }; if (os.platform() === 'win32') { - return task.exec('powershell', './setup.ps1', installerScriptOptions); + try { + await task.exec('powershell', './setup.ps1', installerScriptOptions); + + // Validate the installation completed successfully + await validatePythonInstallation(versionSpec, parameters); + + } catch (error) { + // If setup.ps1 fails or produces incomplete installation, try alternative approach + task.warning(`Standard installation failed or produced incomplete installation: ${error.message}`); + task.warning('Attempting alternative installation method...'); + + await installPythonAlternative(pythonInstallerDir, versionSpec, parameters); + } } else { return task.exec('bash', './setup.sh', installerScriptOptions); } @@ -130,3 +142,287 @@ function matchesOs(file: PythonFileInfo, architecture: string): boolean { return file.platform_version === OS_VERSION; } + +/** + * Alternative Python installation method for Windows when the standard setup.ps1 fails. + * This method runs the Python installer from the extraction directory rather than the target directory. + * @param pythonInstallerDir directory containing the extracted Python installer. + * @param versionSpec version specification. + * @param parameters task parameters. + */ +async function installPythonAlternative(pythonInstallerDir: string, versionSpec: string, parameters: TaskParameters): Promise { + task.debug('Using alternative Python installation method'); + + const fs = require('fs'); + + // Find the Python installer executable in the extracted directory + const files = fs.readdirSync(pythonInstallerDir); + const installerFile = files.find((file: string) => file.endsWith('.exe') && file.includes('python')); + + if (!installerFile) { + throw new Error('Python installer executable not found in extracted archive'); + } + + const installerPath = path.join(pythonInstallerDir, installerFile); + task.debug(`Found Python installer: ${installerPath}`); + + // Extract version from installer filename (e.g., python-3.12.10-amd64.exe) + const versionMatch = installerFile.match(/python-(\d+\.\d+\.\d+)/); + if (!versionMatch) { + throw new Error(`Could not extract version from installer filename: ${installerFile}`); + } + const exactVersion = versionMatch[1]; + + // Determine installation paths using the same logic as setup.ps1 + const toolcacheRoot = process.env.AGENT_TOOLSDIRECTORY || process.env.RUNNER_TOOL_CACHE; + if (!toolcacheRoot) { + throw new Error('Tool cache directory not found. AGENT_TOOLSDIRECTORY or RUNNER_TOOL_CACHE must be set.'); + } + + const pythonToolcachePath = path.join(toolcacheRoot, 'Python'); + const pythonVersionPath = path.join(pythonToolcachePath, exactVersion); + const pythonArchPath = path.join(pythonVersionPath, parameters.architecture); + + // Clean up any existing incomplete installation + if (fs.existsSync(pythonArchPath)) { + task.debug(`Removing existing installation at ${pythonArchPath}`); + fs.rmSync(pythonArchPath, { recursive: true, force: true }); + } + + // Ensure the target directory exists + if (!fs.existsSync(pythonToolcachePath)) { + fs.mkdirSync(pythonToolcachePath, { recursive: true }); + } + if (!fs.existsSync(pythonVersionPath)) { + fs.mkdirSync(pythonVersionPath, { recursive: true }); + } + if (!fs.existsSync(pythonArchPath)) { + fs.mkdirSync(pythonArchPath, { recursive: true }); + } + + // Prepare installation parameters (same logic as setup.ps1) + const isMSI = installerFile.includes('msi'); + const isFreeThreaded = parameters.architecture.includes('-freethreaded'); + + let execParams: string; + if (isMSI) { + execParams = `TARGETDIR="${pythonArchPath}" ALLUSERS=1`; + } else { + const includeFreethreaded = isFreeThreaded ? 'Include_freethreaded=1' : ''; + execParams = `DefaultAllUsersTargetDir="${pythonArchPath}" InstallAllUsers=1 ${includeFreethreaded}`; + } + + // Run the installer from the extraction directory (not the target directory) + const installCommand = `"${installerPath}" ${execParams} /quiet`; + task.debug(`Running installer: ${installCommand}`); + + const installOptions = { + cwd: pythonInstallerDir, // Run from extraction directory, not target + windowsHide: true + }; + + await task.exec('cmd', ['/c', installCommand], installOptions); + + // Create symlinks and install pip (same as setup.ps1) + await finalizeInstallation(pythonArchPath, exactVersion); + + // Validate the installation + await validatePythonInstallation(versionSpec, parameters); + + task.debug('Alternative Python installation completed successfully'); +} + +/** + * Finalize Python installation by creating symlinks and installing pip. + * @param pythonArchPath path to the Python installation. + * @param exactVersion exact version string (e.g., "3.12.10"). + */ +async function finalizeInstallation(pythonArchPath: string, exactVersion: string): Promise { + const fs = require('fs'); + const versionParts = exactVersion.match(/^(\d+)\.(\d+)/); + if (!versionParts) { + return; + } + + const majorVersion = versionParts[1]; + const minorVersion = versionParts[2]; + + // Create python3 symlink if it's Python 3.x + if (majorVersion !== '2') { + const python3Path = path.join(pythonArchPath, 'python3.exe'); + const pythonPath = path.join(pythonArchPath, 'python.exe'); + + if (fs.existsSync(pythonPath) && !fs.existsSync(python3Path)) { + try { + fs.symlinkSync(pythonPath, python3Path); + } catch (error) { + task.warning(`Could not create python3.exe symlink: ${error.message}`); + } + } + } + + // Install and upgrade pip + const pythonExePath = path.join(pythonArchPath, 'python.exe'); + if (fs.existsSync(pythonExePath)) { + try { + process.env.PIP_ROOT_USER_ACTION = 'ignore'; + + const pipInstallOptions = { + cwd: pythonArchPath, + windowsHide: true + }; + + // Install pip + await task.exec(pythonExePath, ['-m', 'ensurepip'], pipInstallOptions); + + // Upgrade pip + await task.exec(pythonExePath, ['-m', 'pip', 'install', '--upgrade', '--force-reinstall', 'pip', '--no-warn-script-location'], pipInstallOptions); + + } catch (error) { + task.warning(`Could not install/upgrade pip: ${error.message}`); + } + } + + // Create completion marker + const versionPath = path.dirname(pythonArchPath); + const architecture = path.basename(pythonArchPath); + const completionFile = path.join(versionPath, `${architecture}.complete`); + + try { + fs.writeFileSync(completionFile, ''); + } catch (error) { + task.warning(`Could not create completion file: ${error.message}`); + } +} + * @param versionSpec version specification. + * @param parameters task parameters. + */ +async function validatePythonInstallation(versionSpec: string, parameters: TaskParameters): Promise { + task.debug('Validating Python installation completeness'); + + // Try to find the installed Python directory + let installDir = tool.findLocalTool('Python', versionSpec, parameters.architecture); + + // If not found with the exact spec, try to find any recent installation + if (!installDir) { + const allVersions = tool.findLocalToolVersions('Python', parameters.architecture); + if (allVersions.length > 0) { + // Use the most recent version that was just installed + const latestVersion = allVersions[allVersions.length - 1]; + installDir = tool.findLocalTool('Python', latestVersion, parameters.architecture); + } + } + + if (!installDir) { + throw new Error('Python installation validation failed: installation directory not found'); + } + + task.debug(`Validating Python installation in: ${installDir}`); + + // Check for essential files and directories that should exist in a complete Python installation + const essentialFiles = [ + 'python.exe' + ]; + + const essentialDirectories = [ + 'Lib', // Standard library - most critical for "platform independent libraries" + 'libs', // Contains .lib files for linking + 'include' // Header files + ]; + + let hasErrors = false; + + // Check essential files + for (const file of essentialFiles) { + const filePath = path.join(installDir, file); + if (!await fileExists(filePath)) { + task.error(`Python installation is incomplete: missing essential file ${file}`); + hasErrors = true; + } + } + + // Check essential directories + for (const dir of essentialDirectories) { + const dirPath = path.join(installDir, dir); + if (!await directoryExists(dirPath)) { + task.error(`Python installation is incomplete: missing essential directory ${dir}`); + hasErrors = true; + } + } + + // Specifically check for python*.lib files in the libs directory + const libsDir = path.join(installDir, 'libs'); + if (await directoryExists(libsDir)) { + // Extract major.minor version from versionSpec (e.g., "3.12.x" -> "312") + const versionMatch = versionSpec.match(/^(\d+)\.(\d+)/); + if (versionMatch) { + const majorMinor = versionMatch[1] + versionMatch[2]; + const expectedLibFile = path.join(libsDir, `python${majorMinor}.lib`); + + if (!await fileExists(expectedLibFile)) { + task.error(`Python installation is incomplete: missing python${majorMinor}.lib file`); + hasErrors = true; + } + } + } + + // Check if Lib directory has content (standard library) + const stdLibDir = path.join(installDir, 'Lib'); + if (await directoryExists(stdLibDir)) { + if (!await directoryHasContent(stdLibDir)) { + task.error('Python installation is incomplete: Lib directory is empty (missing standard library)'); + hasErrors = true; + } + } + + if (hasErrors) { + throw new Error('Python installation validation failed: installation is incomplete. This is a known issue with certain Python archive versions. Please try using a different Python version or report this issue.'); + } + + task.debug('Python installation validation completed successfully'); +} + +/** + * Helper function to check if a file exists. + * @param filePath path to the file. + * @returns true if file exists. + */ +async function fileExists(filePath: string): Promise { + try { + const fs = require('fs').promises; + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +} + +/** + * Helper function to check if a directory exists. + * @param dirPath path to the directory. + * @returns true if directory exists. + */ +async function directoryExists(dirPath: string): Promise { + try { + const fs = require('fs').promises; + const stats = await fs.stat(dirPath); + return stats.isDirectory(); + } catch { + return false; + } +} + +/** + * Helper function to check if a directory has content. + * @param dirPath path to the directory. + * @returns true if directory exists and has files. + */ +async function directoryHasContent(dirPath: string): Promise { + try { + const fs = require('fs').promises; + const files = await fs.readdir(dirPath); + return files.length > 0; + } catch { + return false; + } +} From c35f7a15e139b68ebeb1cb9bcadc1d800744ca88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:10:27 +0000 Subject: [PATCH 3/3] Improve error handling and compatibility for Python installation fix Co-authored-by: sanjuyadav24 <185911972+sanjuyadav24@users.noreply.github.com> --- .../installpythonversion.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Tasks/UsePythonVersionV0/installpythonversion.ts b/Tasks/UsePythonVersionV0/installpythonversion.ts index 44dee51eba7b..a4a524c8c02c 100644 --- a/Tasks/UsePythonVersionV0/installpythonversion.ts +++ b/Tasks/UsePythonVersionV0/installpythonversion.ts @@ -39,7 +39,14 @@ export async function installPythonVersion(versionSpec: string, parameters: Task task.warning(`Standard installation failed or produced incomplete installation: ${error.message}`); task.warning('Attempting alternative installation method...'); - await installPythonAlternative(pythonInstallerDir, versionSpec, parameters); + try { + await installPythonAlternative(pythonInstallerDir, versionSpec, parameters); + task.warning('Alternative installation method succeeded'); + } catch (altError) { + task.error(`Alternative installation method also failed: ${altError.message}`); + throw new Error(`Both standard and alternative Python installation methods failed. ` + + `Standard error: ${error.message}. Alternative error: ${altError.message}`); + } } } else { return task.exec('bash', './setup.sh', installerScriptOptions); @@ -186,7 +193,12 @@ async function installPythonAlternative(pythonInstallerDir: string, versionSpec: // Clean up any existing incomplete installation if (fs.existsSync(pythonArchPath)) { task.debug(`Removing existing installation at ${pythonArchPath}`); - fs.rmSync(pythonArchPath, { recursive: true, force: true }); + try { + // Simple recursive removal using task.exec + await task.exec('cmd', ['/c', `rmdir /s /q "${pythonArchPath}"`], { windowsHide: true }); + } catch (error) { + task.warning(`Could not remove existing installation: ${error.message}`); + } } // Ensure the target directory exists @@ -376,7 +388,10 @@ async function validatePythonInstallation(versionSpec: string, parameters: TaskP } if (hasErrors) { - throw new Error('Python installation validation failed: installation is incomplete. This is a known issue with certain Python archive versions. Please try using a different Python version or report this issue.'); + const message = 'Python installation validation failed: installation is incomplete. ' + + 'This may be due to a known issue with the Python installer when run from certain directories. ' + + 'The task will now attempt an alternative installation method.'; + throw new Error(message); } task.debug('Python installation validation completed successfully');