Functions/GenXdev.AI/EnsurePython.ps1

<##############################################################################
Part of PowerShell module : GenXdev.AI
Original cmdlet filename : EnsurePython.ps1
Original author : René Vaessen / GenXdev
Version : 1.270.2025
################################################################################
MIT License
 
Copyright 2021-2025 GenXdev
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
################################################################################>

###############################################################################
<#
.SYNOPSIS
Ensures Python is installed and available in the system PATH.
 
.DESCRIPTION
Verifies that Python is installed and accessible via the system PATH. If Python
is not found, attempts to install it using winget (Windows Package Manager).
Supports specific version requirements and provides progress feedback during
installation. Returns the path to the Python executable if successful; throws
Write-Error on failure.
 
This function prioritizes existing Python installations but can force
reinstallation when needed. It validates Python functionality by checking
version output and ensures the installation is properly accessible.
 
.PARAMETER Version
The Python version to ensure is installed. Defaults to "3.12". Must follow
the format "major.minor" (e.g., "3.11", "3.12").
 
.PARAMETER Timeout
Timeout in seconds for Python installation process. Defaults to 600 seconds
(10 minutes) to accommodate download and installation time.
 
.PARAMETER Force
Forces reinstallation of Python even if it's already available. Useful for
updating corrupted installations or ensuring the latest version.
 
.EXAMPLE
EnsurePython
 
Ensures Python 3.12 is installed using default settings.
 
.EXAMPLE
$pythonPath = EnsurePython -Version "3.11" -Timeout 900
 
Installs Python 3.11 with extended timeout and returns the executable path.
 
.EXAMPLE
EnsurePython -Force -Verbose
 
Forces reinstallation of Python 3.12 with detailed progress information.
 
.EXAMPLE
EnsurePython -Version "3.10" -Force
 
Forces installation of Python 3.10 even if another version exists.
#>

function EnsurePython {

    [CmdletBinding()]

    param(
        #######################################################################
        [Parameter(
            Mandatory = $false,
            Position = 0,
            HelpMessage = "Python version to ensure is installed"
        )]
        [ValidatePattern("^\d+\.\d+(\.\d+)?$")]
        [string] $Version = "3.12",
        #######################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Timeout in seconds for Python installation"
        )]
        [ValidateRange(60, 3600)]
        [int] $Timeout = 600,
        #######################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Forces reinstallation of Python"
        )]
        [switch] $Force
        #######################################################################
    )

    begin {
        # initialize variables
        $pythonPath = $null
        $pythonInstalled = $false
        $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

        # show initial progress
        Microsoft.PowerShell.Utility\Write-Progress `
            -Activity "Python Installation" `
            -Status "Checking Python availability..."
    }

    process {
        try {
            # check if python is already available (unless Force is specified)
            if (-not $Force) {
                Microsoft.PowerShell.Utility\Write-Progress `
                    -Activity "Python Installation" `
                    -Status "Checking existing Python installation..." `
                    -PercentComplete 20

                # Function to check if found version is compatible with requested version
                function Test-VersionCompatibility {
                    param(
                        [string]$FoundVersion,
                        [string]$RequestedVersion
                    )

                    if (-not $FoundVersion -or -not $RequestedVersion) { return $false }

                    $foundParts = $FoundVersion.Split('.')
                    $requestedParts = $RequestedVersion.Split('.')

                    # Must match major.minor at minimum
                    if ($foundParts.Length -ge 2 -and $requestedParts.Length -ge 2) {
                        $foundMajorMinor = "$($foundParts[0]).$($foundParts[1])"
                        $requestedMajorMinor = "$($requestedParts[0]).$($requestedParts[1])"

                        if ($foundMajorMinor -eq $requestedMajorMinor) {
                            # If only major.minor requested (e.g., "3.12"), accept any patch version
                            if ($requestedParts.Length -eq 2) {
                                return $true
                            }
                            # If patch version specified, found version should be >= requested
                            if ($requestedParts.Length -eq 3 -and $foundParts.Length -ge 3) {
                                try {
                                    $foundPatch = [int]$foundParts[2]
                                    $requestedPatch = [int]$requestedParts[2]
                                    return $foundPatch -ge $requestedPatch
                                } catch {
                                    # If patch version parsing fails, accept major.minor match
                                    return $true
                                }
                            }
                            return $true
                        }
                    }

                    return $false
                }

                # Function to check Python installation and version
                function Test-PythonInstallation {
                    param([string]$PythonExePath)

                    if (-not (Microsoft.PowerShell.Management\Test-Path -LiteralPath $PythonExePath)) { return $null }

                    try {
                        $versionOutput = & $PythonExePath --version 2>$null
                        if ($versionOutput -and $versionOutput -match "Python (\d+\.\d+(\.\d+)?)") {
                            return $Matches[1]
                        }
                    } catch {
                        return $null
                    }
                    return $null
                }

                # Function to add Python to PATH if needed
                function Add-PythonToPath {
                    param([string]$PythonExePath)

                    $pythonDir = Microsoft.PowerShell.Management\Split-Path $PythonExePath -Parent
                    $scriptsDir = Microsoft.PowerShell.Management\Join-Path $pythonDir "Scripts"

                    $currentPath = $env:PATH
                    $pathsToAdd = @()

                    if ($currentPath -notlike "*$pythonDir*") {
                        $pathsToAdd += $pythonDir
                    }

                    if ((Microsoft.PowerShell.Management\Test-Path -LiteralPath $scriptsDir) -and ($currentPath -notlike "*$scriptsDir*")) {
                        $pathsToAdd += $scriptsDir
                    }

                    if ($pathsToAdd.Count -gt 0) {
                        $env:PATH = ($pathsToAdd -join ";") + ";" + $env:PATH
                        Microsoft.PowerShell.Utility\Write-Verbose "Added to PATH: $($pathsToAdd -join ', ')"
                    }
                }

                # First, check if python is in PATH
                $pythonCmd = Microsoft.PowerShell.Core\Get-Command python -ErrorAction SilentlyContinue
                if ($pythonCmd) {
                    $foundVersion = Test-PythonInstallation -PythonExePath $pythonCmd.Source
                    if ($foundVersion -and (Test-VersionCompatibility -FoundVersion $foundVersion -RequestedVersion $Version)) {
                        Microsoft.PowerShell.Utility\Write-Verbose "Python ${foundVersion} already available in PATH at: $($pythonCmd.Source) (compatible with ${Version})"
                        return $pythonCmd.Source
                    }
                }

                # Search common Python installation locations
                $versionForPath = $Version -replace '\.', ''
                if ($Version.Split('.').Count -eq 3) {
                    # For x.y.z versions, use x.y for path (e.g., 3.12.11 -> 312)
                    $majorMinor = ($Version.Split('.')[0..1] -join '.')
                    $versionForPath = $majorMinor -replace '\.', ''
                }

                $commonPaths = @(
                    "$env:LOCALAPPDATA\Programs\Python\Python$versionForPath\python.exe",
                    "$env:LOCALAPPDATA\Programs\Python\Python$versionForPath-32\python.exe",
                    "$env:PROGRAMFILES\Python$versionForPath\python.exe",
                    "$env:PROGRAMFILES(X86)\Python$versionForPath\python.exe",
                    "$env:USERPROFILE\AppData\Local\Programs\Python\Python$versionForPath\python.exe",
                    "$env:USERPROFILE\AppData\Local\Programs\Python\Python$versionForPath-32\python.exe"
                )

                # Also search for any Python installations via registry or common paths
                $pythonVersions = Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath "$env:LOCALAPPDATA\Programs\Python" -ErrorAction SilentlyContinue |
                    Microsoft.PowerShell.Core\Where-Object { $_.Name -like "Python*" }

                foreach ($pythonDir in $pythonVersions) {
                    $pythonExe = Microsoft.PowerShell.Management\Join-Path $pythonDir.FullName "python.exe"
                    $commonPaths += $pythonExe
                }

                foreach ($pythonPath in $commonPaths) {
                    $foundVersion = Test-PythonInstallation -PythonExePath $pythonPath
                    if ($foundVersion -and (Test-VersionCompatibility -FoundVersion $foundVersion -RequestedVersion $Version)) {
                        Microsoft.PowerShell.Utility\Write-Verbose "Found Python ${foundVersion} at: ${pythonPath} (compatible with ${Version})"
                        Add-PythonToPath -PythonExePath $pythonPath
                        return $pythonPath
                    }
                }

                Microsoft.PowerShell.Utility\Write-Verbose "Python ${Version} not found in common locations"
            }

            # check for winget availability
            Microsoft.PowerShell.Utility\Write-Progress `
                -Activity "Python Installation" `
                -Status "Checking winget availability..." `
                -PercentComplete 30

            if (-not (Microsoft.PowerShell.Core\Get-Command winget -ErrorAction SilentlyContinue)) {
                Microsoft.PowerShell.Utility\Write-Error `
                    "winget (Windows Package Manager) is not installed. Please install winget and try again."
                return
            }

            # search for requested python version
            Microsoft.PowerShell.Utility\Write-Progress `
                -Activity "Python Installation" `
                -Status "Searching for Python ${Version}..." `
                -PercentComplete 40

            Microsoft.PowerShell.Utility\Write-Verbose `
                "Searching for Python ${Version} via winget"

            # For winget, use major.minor version for package ID (e.g., 3.12.11 -> 3.12)
            $packageVersion = $Version
            if ($Version.Split('.').Count -eq 3) {
                $packageVersion = ($Version.Split('.')[0..1] -join '.')
            }
            $packageId = "Python.Python.${packageVersion}"
            $pythonSearch = winget search python --id $packageId --exact 2>$null

            if (-not $pythonSearch -or $pythonSearch -match "No package found") {
                Microsoft.PowerShell.Utility\Write-Error `
                    "Python ${Version} not found via winget. Available versions may be different."
                return
            }

            # install python via winget
            Microsoft.PowerShell.Utility\Write-Progress `
                -Activity "Python Installation" `
                -Status "Installing Python ${Version}..." `
                -PercentComplete 60

            Microsoft.PowerShell.Utility\Write-Verbose `
                "Installing Python ${Version} via winget"

            try {
                $installArgs = @(
                    "install",
                    "--id", $packageId,
                    "--exact",
                    "--scope", "user",
                    "--accept-package-agreements",
                    "--accept-source-agreements",
                    "--silent"
                )

                $installResult = & winget $installArgs 2>&1

                Microsoft.PowerShell.Utility\Write-Verbose "winget install result: $installResult"

            } catch {
                Microsoft.PowerShell.Utility\Write-Error `
                    "Failed to install Python ${Version} via winget: $($_.Exception.Message)"
                return
            }

            # wait for python to be available and refresh PATH
            Microsoft.PowerShell.Utility\Write-Progress `
                -Activity "Python Installation" `
                -Status "Verifying Python installation..." `
                -PercentComplete 80

            # refresh environment variables from registry
            $machinePath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine")
            $userPath = [System.Environment]::GetEnvironmentVariable("PATH", "User")
            $env:PATH = $machinePath + ";" + $userPath

            # give some time for installation to complete
            Microsoft.PowerShell.Utility\Start-Sleep -Seconds 3

            # try to find python command again
            $attempts = 0
            $maxAttempts = 15

            while ($attempts -lt $maxAttempts) {
                # First check if python is now in PATH
                $pythonCmd = Microsoft.PowerShell.Core\Get-Command python -ErrorAction SilentlyContinue
                if ($pythonCmd) {
                    $foundVersion = Test-PythonInstallation -PythonExePath $pythonCmd.Source
                    if ($foundVersion -and (Test-VersionCompatibility -FoundVersion $foundVersion -RequestedVersion $Version)) {
                        Microsoft.PowerShell.Utility\Write-Verbose "Python ${foundVersion} successfully available in PATH at: $($pythonCmd.Source) (compatible with ${Version})"
                        $pythonPath = $pythonCmd.Source
                        $pythonInstalled = $true
                        break
                    }
                }

                # If not in PATH, search common installation locations again
                $versionForPath = $Version -replace '\.', ''
                if ($Version.Split('.').Count -eq 3) {
                    # For x.y.z versions, use x.y for path (e.g., 3.12.11 -> 312)
                    $majorMinor = ($Version.Split('.')[0..1] -join '.')
                    $versionForPath = $majorMinor -replace '\.', ''
                }

                $commonPaths = @(
                    "$env:LOCALAPPDATA\Programs\Python\Python$versionForPath\python.exe",
                    "$env:LOCALAPPDATA\Programs\Python\Python$versionForPath-32\python.exe",
                    "$env:PROGRAMFILES\Python$versionForPath\python.exe",
                    "$env:PROGRAMFILES(X86)\Python$versionForPath\python.exe",
                    "$env:USERPROFILE\AppData\Local\Programs\Python\Python$versionForPath\python.exe",
                    "$env:USERPROFILE\AppData\Local\Programs\Python\Python$versionForPath-32\python.exe"
                )

                foreach ($testPath in $commonPaths) {
                    $foundVersion = Test-PythonInstallation -PythonExePath $testPath
                    if ($foundVersion -and (Test-VersionCompatibility -FoundVersion $foundVersion -RequestedVersion $Version)) {
                        Microsoft.PowerShell.Utility\Write-Verbose "Found Python ${foundVersion} at: ${testPath} (compatible with ${Version})"
                        Add-PythonToPath -PythonExePath $testPath
                        $pythonPath = $testPath
                        $pythonInstalled = $true
                        break
                    }
                }

                if ($pythonInstalled) { break }

                $attempts++
                Microsoft.PowerShell.Utility\Start-Sleep -Seconds 2
            }

            if (-not $pythonInstalled) {
                Microsoft.PowerShell.Utility\Write-Error `
                    "Failed to verify Python ${Version} installation. Python may not be in PATH."
                return
            }

            # check timeout
            if ($stopwatch.Elapsed.TotalSeconds -gt $Timeout) {
                Microsoft.PowerShell.Utility\Write-Error `
                    "Python installation timed out after ${Timeout} seconds."
                return
            }

            # complete progress
            Microsoft.PowerShell.Utility\Write-Progress `
                -Activity "Python Installation" `
                -Status "Python installation completed" `
                -PercentComplete 100

            Microsoft.PowerShell.Utility\Write-Verbose "Python is ready at: ${pythonPath}"
            return $pythonPath

        } catch {
            Microsoft.PowerShell.Utility\Write-Error `
                "Failed to ensure Python installation: $($_.Exception.Message)"
            return
        }
    }

    end {
        Microsoft.PowerShell.Utility\Write-Progress `
            -Activity "Python Installation" `
            -Completed
    }
}
###############################################################################