Remove Legacy Python (Package)
One Tanium package that removes Python 3.10, 3.11, and 3.12 from Windows endpoints. It covers all-users installs, per-user installs in every profile (signed in or not), and copies in custom folders such as C:\Python311.
Newer versions of Python are not touched, and neither is the all-users Python Launcher (C:\Windows\py.exe).
Why these three versions
python.org no longer publishes Windows installers for Python 3.10, 3.11, or 3.12. The last ones were 3.10.11, 3.11.9, and 3.12.10. Later releases of those versions are security fixes published as source code only, so a Windows copy of 3.10, 3.11, or 3.12 cannot be patched in place. The fix is to install a supported version (3.14 is the safest choice, since it still gets Windows installers) and remove the old one. Python 3.10 reached end of life in October 2026. Python 3.11 and 3.12 keep getting source-only security fixes until October 2027 and October 2028.
Vulnerability findings
Scanners, Tanium Comply included, check the per-user copy in C:\Users\<user>\AppData\Local\Programs\Python\ separately from the all-users copy. Installing a newer Python does not clear the finding, because the old copy is still there. Removing the old copy clears it. A finding can cover several vulnerabilities at once when they all point to the same old python.exe, so one removal often clears several findings on the same endpoint.
Test status
An earlier version of this script (all-users uninstall plus per-user folders, uninstall entries, Start menu, and PATH, for 3.10 and 3.11) was run through Tanium on one Windows endpoint with a per-user Python 3.11.2 install, and removed the install folder. The script on this page adds 3.12, custom folders, PythonCore registry keys, the per-user launcher, signed-out profiles, logging, and exit codes. It has only been checked for syntax so far. Test it on one endpoint before deploying widely. See Test results.
Where Python gets installed
| Install type | Location | Shows in Installed Applications as | Removed by this package |
|---|---|---|---|
| All users, 64-bit | C:\Program Files\Python311 |
Python 3.11.x (64-bit) |
Yes, with the installer's own uninstall |
| All users, 32-bit | C:\Program Files (x86)\Python311-32 |
Python 3.11.x (32-bit) |
Yes, with the installer's own uninstall |
| Per user (the installer's default) | C:\Users\<user>\AppData\Local\Programs\Python\Python311 |
Python 3.11.x (64-bit) |
Yes, cleaned directly |
| Per user, custom folder | Anywhere the user chose, for example C:\Python311 |
Python 3.11.x (64-bit) |
Yes, found through its registry entry |
| Copied by hand or embeddable zip | Anywhere, for example C:\python3.11 |
Not listed | Only at the root of the system drive |
pip --user packages |
C:\Users\<user>\AppData\Roaming\Python\Python311 |
Not listed | Yes |
| Per-user Python Launcher | C:\Users\<user>\AppData\Local\Programs\Python\Launcher |
Python Launcher |
Only if it is version 3.10, 3.11, or 3.12 |
The same table applies to 3.10 (Python310) and 3.12 (Python312).
Why per-user installs need special handling
The Python installer is a bundle (a small setup program) that installs several hidden MSI packages: the core interpreter, the standard library, pip, and so on. For a per-user install, the bundle registers everything in that user's own registry, under HKEY_CURRENT_USER.
A Tanium package runs as SYSTEM. SYSTEM cannot run another user's per-user uninstall: the uninstaller looks in SYSTEM's own registry, finds nothing, and the files and uninstall entry stay. In the Tanium console, this often shows as Python 3.11.x (64-bit) with Not Uninstallable in Installed Applications.
So the script does not try to uninstall per-user copies. It deletes the files and the registry entries directly, for every profile. For users who are signed out, it loads their registry file (NTUSER.DAT) offline, cleans it, and unloads it.
Before you remove anything
Give users a supported version first
If users need Python, install the replacement before running this package. Tanium Deploy has Python in its software gallery.
- In the Tanium Console, go to Modules > Deploy > Software.
- Search for
Python 3.14. If it is not listed, import it from the gallery first. - Deploy it to the same endpoints you plan to clean.
Menu labels can differ slightly between Tanium versions.
Save the user's packages
Removing the old version deletes the packages the user installed with pip. If the user needs them, have the user run this in PowerShell before the package runs. It saves the package names without version numbers:
py -3.11 -m pip freeze | ForEach-Object { $_ -replace '==.*', '' } | Set-Content "$env:USERPROFILE\python-requirements.txt"
Change -3.11 to the version they have. Version numbers are dropped on purpose: package versions from a few years ago often have no ready-made build for Python 3.14, and pinned installs then fail.
After the package runs, the user reinstalls them into a virtual environment in their own profile. This needs no administrator rights:
py -3.14 -m venv "$env:USERPROFILE\venvs\py314"
& "$env:USERPROFILE\venvs\py314\Scripts\python.exe" -m pip install -r "$env:USERPROFILE\python-requirements.txt"
Calling the virtual environment's python.exe directly avoids Activate.ps1, which is often blocked by execution policy on managed endpoints. If one package fails to build, the whole install stops. Remove that line from the file and run the install again.
Find the endpoints
In Interact, ask:
Get Computer Name and Installed Applications from all machines with ( Installed Applications contains "Python 3.10" or Installed Applications contains "Python 3.11" or Installed Applications contains "Python 3.12" )
To check whether Python is running on those endpoints (a running python.exe blocks the folder delete), ask:
Get Computer Name and Running Processes from all machines with Running Processes contains "python.exe"
Copies made by hand or from the embeddable zip do not show in Installed Applications. If Comply reports one, the test's Actual Values show its path.
What the script does
- Checks it is running as SYSTEM or an administrator. If not, it stops with exit code
5. - All-users installs:
- Reads the install folders registered for 3.10, 3.11, and 3.12 under
HKLM\SOFTWARE\Python\PythonCore. - For each
Python 3.1x.x (64-bit)or(32-bit)entry, runs the installer's own quiet uninstall fromC:\ProgramData\Package Cache\. - If the cached installer is gone, removes the hidden MSI components one by one with
msiexec /x.
- Reads the install folders registered for 3.10, 3.11, and 3.12 under
- Custom folders: deletes registered install folders that still exist, and
Python*folders at the root of the system drive, but only whenpython.exedirectly inside the folder is version 3.10, 3.11, or 3.12. A drive root and anything underC:\Windowsare never deleted. - Removes the
PythonCore\3.10,3.11, and3.12keys for all users, once their uninstall entries are gone. - Every user profile:
- Deletes
AppData\Local\Programs\Python\Python3xxandAppData\Roaming\Python\Python3xx. - Deletes the per-user Python Launcher, only if its
py.exeis version 3.10, 3.11, or 3.12. - Deletes
AppData\Local\Programs\Pythonif nothing is left in it. - Deletes Start menu folders named
Python 3.10,Python 3.11, orPython 3.12. - Opens the user's registry (the live one if the user is signed in, otherwise
NTUSER.DATloaded asHKEY_USERS\PY_CLEANUP) and deletes custom install folders registered there, the uninstall entries, thePythonCorekeys, and the PATH entries for those versions.
- Deletes
- Checks that nothing is left and prints a summary.
What it leaves behind
- Windows Installer records for per-user installs. The hidden MSI packages stay registered to the user. They do not run anything and do not affect inventory or scanners, but that user cannot repair or reinstall that exact old version per user afterward.
- pip's download cache in
C:\Users\<user>\AppData\Local\pip\cache. It is shared by all Python versions. - Virtual environments the user created with the old version. They stop working once the old version is gone and have to be recreated.
Changing the versions
The versions are set in one line near the top of the script:
Folder names, registry key names, and the version checks all follow this list. To include 3.9, change it to @('9', '10', '11', '12'). If you change it, rename the package to match.
Exit codes
When more than one applies, 6 is returned first, then 1.
| Exit code | Meaning |
|---|---|
0 |
Removed everything found, or nothing was found. |
1 |
Something could not be removed, or is still present afterward. Look for [ERROR] lines and the Still present line in the action log. |
5 |
The script is not running as SYSTEM or an administrator. Nothing changed. |
6 |
A user's registry file could not be unloaded. Restart the endpoint before that user signs in. |
Registry hive unload
The script loads each signed-out user's registry file while that user is away. If one does not unload (for example, another program opened it at the same moment), the script stops and returns exit code 6. If that user signs in before the endpoint restarts, Windows can give them a temporary profile. When you see exit code 6, restart the endpoint before the user signs in.
Create the package
- Save the script at the bottom of this page as
Remove-LegacyPython.ps1. - In the Tanium Console, go to Administration > Content > Packages.
- Click Create Package.
- Fill in the fields:
- Package Name and Display Name:
Remove Legacy Python 3.10-3.12 [Windows](add your team's prefix if you use one). - Description:
Removes Python 3.10, 3.11, and 3.12 from all-users installs, every user profile, and custom folders at the root of the system drive. Cleans related registry keys, Start menu folders, and user PATH entries. Does not touch newer Python or C:\Windows\py.exe. Exit 0 = removed. Exit 1 = something remains (see action log). - Content Set: the content set your team uses for custom content.
- Command:
- Command Timeout:
15minutes. Uninstalling all-users copies and their MSI components can take several minutes on a slow endpoint.
- Package Name and Display Name:
- Under Files, click Add File, choose Local File, and upload
Remove-LegacyPython.ps1. - This package has no parameters. Click Save.
Deploy the package
- Deploy the replacement Python first, if users need it. See Give users a supported version first.
- Ask users who need their packages to save the list. See Save the user's packages.
- In Interact, ask the question from Find the endpoints.
- Select the computers in the results grid.
- Click Deploy Action.
- In Deployment Package, search for and select
Remove Legacy Python 3.10-3.12 [Windows]. - Under the schedule, leave it as a one-time action.
- Click Show preview to continue, review the targets, then click Deploy Action.
For endpoints that return exit code 1 with a file may be in use, ask the user to close Python (including editors and terminals running it) and deploy again.
View the results
One endpoint
- Open the action from Administration > Actions > Action History.
- Select an endpoint.
- Open its action log. The script output appears between the
Command Lineline and theCompletedline. The last lines are the summary.
This example shows the format of the output for a signed-in user with a per-user Python 3.11 install. It is written to show the format, not captured from a real run:
2026-10-17 14:02:10 [INFO] Versions to remove: 3.10, 3.11, 3.12
2026-10-17 14:02:11 [INFO] Deleted C:\Users\jdoe\AppData\Local\Programs\Python\Python311
2026-10-17 14:02:11 [INFO] Deleted C:\Users\jdoe\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Python 3.11
2026-10-17 14:02:11 [INFO] Removed C:\Users\jdoe : 'Python 3.11.2 (64-bit)' uninstall entry
2026-10-17 14:02:11 [INFO] Removed C:\Users\jdoe : PythonCore\3.11 registry key
2026-10-17 14:02:11 [INFO] C:\Users\jdoe : removed 2 PATH entries
2026-10-17 14:02:12 [INFO] Summary:
2026-10-17 14:02:12 [INFO] Removed: C:\Users\jdoe\AppData\Local\Programs\Python\Python311, ...
2026-10-17 14:02:12 [INFO] Failed: none
2026-10-17 14:02:12 [INFO] Still present: none
Many endpoints
Ask a question with the built-in Tanium Action Log sensor, using the action ID from the top of the log:
Confirm Python is gone
Ask the question from Find the endpoints again. The endpoint should no longer return Python 3.10, 3.11, or 3.12. The Installed Applications sensor can return a cached result for a while after the change, so an endpoint that still shows it right after the action may clear on its own. If the removal was for a vulnerability finding, run the Comply assessment again afterward.
Requirements
- Windows 10, Windows 11, or Windows Server endpoints with Windows PowerShell 5.1.
- The action must run as SYSTEM, which is the Tanium default. Without administrator rights, the script stops with exit code
5. - A Tanium account with permission to create packages in a content set and deploy actions to the target computers.
Things to know
- Users can reinstall the old version. A per-user install needs no administrator rights, so a user can download Python 3.11 again and the finding comes back. If this keeps happening, the fix is a policy (application control or a standard Python version), not this package.
- Signed-in users are cleaned too. Their registry is edited live. A PATH change takes effect the next time the user signs in.
- Folder deletes are checked, not assumed. Each delete is checked afterward, and the final check looks for every
python.exethe script knows about. - It runs as 64-bit. If the Tanium client starts 32-bit PowerShell, the script restarts itself as 64-bit, so 64-bit registry entries are not missed.
- Copies outside the known locations are not found. A copy made by hand in a nested folder such as
C:\Tools\python3.11or on another drive has no registry entry to follow. Use the path from Comply's Actual Values and the Remove Folder package for those. - Deleting the folder by hand is not enough. It leaves the uninstall entry and PythonCore keys behind, and inventory keeps reporting the old version. Use this package instead.
Test results
| Case | Expected | Result |
|---|---|---|
| Earlier version, per-user Python 3.11.2, signed-in user, through Tanium | Install folder removed | Pass. Folder confirmed gone in the file browser. |
| Current script, syntax check with the PowerShell parser | No errors | Pass |
| Current script, per-user install, signed-in user, through Tanium | Folder, uninstall entry, PythonCore key, PATH removed | Not tested yet |
| Current script, per-user install, signed-out profile | Hive loaded, cleaned, unloaded | Not tested yet |
| Current script, all-users install with cached installer | Uninstalled by the bundle, exit 0 | Not tested yet |
| Current script, all-users install, cached installer missing | MSI components removed, exit 0 | Not tested yet |
Current script, C:\Python311 copy with no registry entry |
Folder removed | Not tested yet |
Current script, C:\Python314 present |
Left alone | Not tested yet |
| Current script, not elevated | Exit 5, nothing changed | Not tested yet |
Script
######################################################################
# Remove Legacy Python 3.10 / 3.11 / 3.12 (Tanium package)
#
# Tanium package command:
# cmd.exe /d /c powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -NoProfile -File Remove-LegacyPython.ps1
#
# Runs as SYSTEM. Removes the Python versions listed in $Minor from:
# 1. All-users installs: runs the installer's own uninstall, then removes
# any leftover component MSIs and PythonCore registry keys.
# 2. Every user profile, signed in or not: deletes the install folders
# (AppData\Local\Programs\Python\Python3xx), pip --user folders
# (AppData\Roaming\Python\Python3xx), Start menu folders, the user's
# Uninstall and PythonCore registry keys, and the user's PATH entries.
# Removes the per-user Python Launcher only if it is one of these
# versions.
# 3. Custom folders: any folder registered as an InstallPath for these
# versions, and Python* folders at the root of the system drive.
# A folder is only deleted if python.exe directly inside it is one of
# these versions.
#
# Not touched: newer Python versions and the all-users Python Launcher
# (C:\Windows\py.exe).
#
# Exit codes (6 wins over 1):
# 0 Removed everything found, or nothing was found
# 1 Something could not be removed, or is still present afterward
# 5 Not running as SYSTEM or an administrator. Nothing changed.
# 6 A user registry hive could not be unloaded. Restart the endpoint
# before that user signs in.
######################################################################
# Restart as 64-bit if the Tanium client started 32-bit PowerShell.
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
$ps64 = Join-Path $env:SystemRoot 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
& $ps64 -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $PSCommandPath
exit $LASTEXITCODE
}
$ErrorActionPreference = 'Stop'
# Minor versions to remove. Edit this list only, everything below follows it.
$Minor = @('10', '11', '12')
$VerRegex = '3\.(' + ($Minor -join '|') + ')'
$Folders = @($Minor | ForEach-Object { "Python3$_"; "Python3$_-32" })
$FolderRx = ($Folders | ForEach-Object { [regex]::Escape($_) }) -join '|'
$MountName = 'PY_CLEANUP'
$MountPs = "Registry::HKEY_USERS\$MountName"
$MachineUninstall = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$MachineCore = @(
'HKLM:\SOFTWARE\Python\PythonCore',
'HKLM:\SOFTWARE\WOW6432Node\Python\PythonCore'
)
$removed = [System.Collections.Generic.List[string]]::new()
$failed = [System.Collections.Generic.List[string]]::new()
$stillPresent = [System.Collections.Generic.List[string]]::new()
$checkDirs = [System.Collections.Generic.List[string]]::new()
$unloadFailed = $false
function Write-Log {
param ([string]$Message, [string]$Level = 'INFO')
Write-Output ("{0} [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message)
}
function Join-OrNone {
param ($Items)
$list = @($Items)
if ($list.Count -gt 0) { return ($list -join ', ') }
return 'none'
}
# Windows PowerShell 5.1 turns native stderr into a terminating error when
# ErrorActionPreference is Stop, so reg.exe runs with Continue.
function Invoke-Reg {
param ([string[]]$Arguments)
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$out = (& reg.exe @Arguments 2>&1 | Out-String).Trim()
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previous
}
return [pscustomobject]@{ ExitCode = $code; Output = $out }
}
# Open registry handles keep a hive from unloading, so release them and retry.
function Dismount-UserHive {
for ($i = 1; $i -le 3; $i++) {
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
Start-Sleep -Seconds 2
$r = Invoke-Reg @('unload', "HKU\$MountName")
if ($r.ExitCode -eq 0) { return $true }
}
return $false
}
# All-users uninstall entries for the versions being removed
function Get-LegacyMachineEntries {
@(Get-ItemProperty $MachineUninstall -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match "^Python $VerRegex\.\d+" })
}
function Remove-LoggedPath {
param ([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
try { Remove-Item -LiteralPath $Path -Recurse -Force } catch { }
if (Test-Path -LiteralPath $Path) {
Write-Log "Could not delete $Path (a file may be in use)" 'ERROR'
$failed.Add($Path)
} else {
Write-Log "Deleted $Path"
$removed.Add($Path)
}
}
function Remove-LoggedKey {
param ([string]$KeyPath, [string]$Label)
try { Remove-Item -LiteralPath $KeyPath -Recurse -Force } catch { }
if (Test-Path -LiteralPath $KeyPath) {
Write-Log "Could not remove $Label" 'ERROR'
$failed.Add($Label)
} else {
Write-Log "Removed $Label"
$removed.Add($Label)
}
}
# True only if python.exe sits directly in the folder and is one of the
# versions being removed. Never a drive root or anything under Windows.
function Test-LegacyPythonDir {
param ([string]$Dir)
if (-not $Dir) { return $false }
$Dir = $Dir.TrimEnd('\')
if ($Dir -match '^[A-Za-z]:$') { return $false }
if ($Dir.StartsWith($env:SystemRoot, [StringComparison]::OrdinalIgnoreCase)) { return $false }
try {
$exe = Join-Path $Dir 'python.exe'
if (-not (Test-Path -LiteralPath $exe)) { return $false }
return ((Get-Item -LiteralPath $exe).VersionInfo.FileVersion -match "^$VerRegex\.")
} catch {
return $false
}
}
function Remove-LegacyPythonDir {
param ([string]$Dir)
if (-not (Test-LegacyPythonDir $Dir)) { return }
$Dir = $Dir.TrimEnd('\')
$checkDirs.Add($Dir)
Remove-LoggedPath $Dir
}
# InstallPath values registered under PythonCore for the versions being removed
function Get-CoreInstallPaths {
param ([string]$CorePath)
@(Get-ChildItem -LiteralPath $CorePath -ErrorAction SilentlyContinue |
Where-Object { $_.PSChildName -match "^$VerRegex(-32)?$" } |
ForEach-Object { (Get-ItemProperty -LiteralPath "$($_.PSPath)\InstallPath" -ErrorAction SilentlyContinue).'(default)' } |
Where-Object { $_ })
}
function Remove-CoreKeys {
param ([string]$CorePath, [string]$Owner)
$keys = @(Get-ChildItem -LiteralPath $CorePath -ErrorAction SilentlyContinue |
Where-Object { $_.PSChildName -match "^$VerRegex(-32)?$" })
foreach ($k in $keys) { Remove-LoggedKey $k.PSPath "$Owner PythonCore\$($k.PSChildName) registry key" }
}
# --- Elevation check ----------------------------------------------------------
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Log "Not running as SYSTEM or an administrator. Nothing changed." 'ERROR'
exit 5
}
# A hive left mounted by an earlier run would block every load below.
if (Test-Path -LiteralPath $MountPs) {
Write-Log "A hive from an earlier run is still loaded as HKU\$MountName. Unloading it." 'WARN'
if (-not (Dismount-UserHive)) {
Write-Log "Could not unload HKU\$MountName. Restart the endpoint, then run again. Nothing changed." 'ERROR'
exit 6
}
}
Write-Log ("Versions to remove: {0}" -f (($Minor | ForEach-Object { "3.$_" }) -join ', '))
# --- 1. All-users installs ----------------------------------------------------
# Read the registered install folders first, because the uninstall removes them.
$machineDirs = @($MachineCore | ForEach-Object { Get-CoreInstallPaths $_ })
$bundles = @(Get-LegacyMachineEntries | Where-Object { $_.DisplayName -match "^Python $VerRegex\.\d+ \((64|32)-bit\)$" })
foreach ($b in $bundles) {
$cmd = if ($b.QuietUninstallString) { $b.QuietUninstallString } else { $b.UninstallString }
if ($cmd -match '^"([^"]+)"\s*(.*)$' -and (Test-Path -LiteralPath $Matches[1])) {
$exe = $Matches[1]
$argList = $Matches[2]
if ($argList -notmatch '/quiet') { $argList = "$argList /quiet" }
Write-Log "$($b.DisplayName) : running the installer's uninstall"
try {
$p = Start-Process -FilePath $exe -ArgumentList $argList -Wait -PassThru
Write-Log "$($b.DisplayName) : uninstall exit code $($p.ExitCode)"
} catch {
Write-Log "$($b.DisplayName) : uninstall could not start. $($_.Exception.Message)" 'ERROR'
}
} else {
Write-Log "$($b.DisplayName) : cached installer not found, removing its MSI components instead" 'WARN'
}
}
# Component MSIs left behind (or all of them, when the cached installer is gone)
$msis = @(Get-LegacyMachineEntries | Where-Object { $_.WindowsInstaller -eq 1 -and $_.PSChildName -match '^\{[0-9A-Fa-f-]+\}$' })
foreach ($m in $msis) {
try {
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/x $($m.PSChildName) /qn /norestart" -Wait -PassThru
Write-Log "$($m.DisplayName) : MSI uninstall exit code $($p.ExitCode)"
} catch {
Write-Log "$($m.DisplayName) : MSI uninstall could not start. $($_.Exception.Message)" 'ERROR'
}
}
# --- 2. Custom folders ----------------------------------------------------------
foreach ($d in $machineDirs) { Remove-LegacyPythonDir $d }
$rootDirs = @(Get-ChildItem -LiteralPath "$env:SystemDrive\" -Directory -Filter 'python*' -ErrorAction SilentlyContinue)
foreach ($d in $rootDirs) { Remove-LegacyPythonDir $d.FullName }
# PythonCore keys for all users, only once the uninstall entries are gone
if ((Get-LegacyMachineEntries).Count -eq 0) {
foreach ($c in $MachineCore) { Remove-CoreKeys $c 'All users:' }
}
# --- 3. Every user profile ------------------------------------------------------
$profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "Special=False" |
Where-Object { $_.LocalPath -and (Test-Path -LiteralPath $_.LocalPath) })
foreach ($prof in $profiles) {
$path = $prof.LocalPath
$pyRoot = Join-Path $path 'AppData\Local\Programs\Python'
# Install folders and pip --user folders
foreach ($f in $Folders) {
Remove-LoggedPath (Join-Path $pyRoot $f)
Remove-LoggedPath (Join-Path $path "AppData\Roaming\Python\$f")
}
# Per-user Python Launcher, only if it is one of the versions being removed
$launcherDir = Join-Path $pyRoot 'Launcher'
$launcherExe = Join-Path $launcherDir 'py.exe'
$oldLauncher = $false
if (Test-Path -LiteralPath $launcherExe) {
$lv = (Get-Item -LiteralPath $launcherExe).VersionInfo.FileVersion
if ($lv -match "^$VerRegex\.") {
$oldLauncher = $true
Write-Log "$path : per-user Python Launcher is version $lv"
Remove-LoggedPath $launcherDir
}
}
# The Python folder itself, if nothing is left in it
if ((Test-Path -LiteralPath $pyRoot) -and -not (Get-ChildItem -LiteralPath $pyRoot -Force -ErrorAction SilentlyContinue)) {
Remove-LoggedPath $pyRoot
}
# Start menu folders, for example "Python 3.11"
$startMenu = Join-Path $path 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs'
$menus = @(Get-ChildItem -LiteralPath $startMenu -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "^Python $VerRegex$" })
foreach ($menu in $menus) { Remove-LoggedPath $menu.FullName }
# Registry: use the live hive for a signed-in user, otherwise load NTUSER.DAT
$mounted = $false
if ($prof.Loaded) {
$hiveName = $prof.SID
} else {
$hive = Join-Path $path 'NTUSER.DAT'
if (-not (Test-Path -LiteralPath $hive)) { continue }
$load = Invoke-Reg @('load', "HKU\$MountName", $hive)
if ($load.ExitCode -ne 0) {
Write-Log "$path : could not load the registry hive. $($load.Output)" 'ERROR'
$failed.Add("$path (registry)")
continue
}
$hiveName = $MountName
$mounted = $true
}
$hivePs = "Registry::HKEY_USERS\$hiveName"
try {
# Per-user installs in custom folders, found through their InstallPath
foreach ($d in (Get-CoreInstallPaths "$hivePs\Software\Python\PythonCore")) { Remove-LegacyPythonDir $d }
# Uninstall entries
$entries = @(Get-ChildItem -LiteralPath "$hivePs\Software\Microsoft\Windows\CurrentVersion\Uninstall" -ErrorAction SilentlyContinue)
foreach ($e in $entries) {
$props = Get-ItemProperty -LiteralPath $e.PSPath -ErrorAction SilentlyContinue
if (-not $props) { continue }
$isOldPython = $props.DisplayName -match "^Python $VerRegex\.\d+"
$isOldLauncher = $oldLauncher -and $props.DisplayName -eq 'Python Launcher' -and $props.DisplayVersion -match "^$VerRegex\."
if ($isOldPython -or $isOldLauncher) {
Remove-LoggedKey $e.PSPath "$path : '$($props.DisplayName)' uninstall entry"
}
}
# PythonCore registration, which py.exe uses to find installs
Remove-CoreKeys "$hivePs\Software\Python\PythonCore" "$path :"
# PATH entries, keeping the original value type
$names = $FolderRx
if ($oldLauncher) { $names = "$FolderRx|Launcher" }
$pathRx = "\\(Programs|Roaming)\\Python\\($names)(\\|$)"
$envKey = [Microsoft.Win32.Registry]::Users.OpenSubKey("$hiveName\Environment", $true)
if ($envKey) {
try {
$raw = $envKey.GetValue('Path', $null, 'DoNotExpandEnvironmentNames')
if ($raw) {
$kind = $envKey.GetValueKind('Path')
$parts = @($raw -split ';' | Where-Object { $_ })
$kept = @($parts | Where-Object { $_ -notmatch $pathRx })
if ($kept.Count -lt $parts.Count) {
$envKey.SetValue('Path', ($kept -join ';'), $kind)
Write-Log ("{0} : removed {1} PATH entries" -f $path, ($parts.Count - $kept.Count))
}
}
} finally {
$envKey.Close()
}
}
} catch {
Write-Log "$path : registry cleanup error. $($_.Exception.Message)" 'ERROR'
$failed.Add("$path (registry)")
} finally {
if ($mounted -and -not (Dismount-UserHive)) {
Write-Log "$path : could not unload the registry hive. Restart the endpoint before this user signs in." 'ERROR'
$unloadFailed = $true
}
}
if ($unloadFailed) {
$failed.Add("$path (hive unload)")
Write-Log "Stopping, so no other hive is loaded while one is stuck." 'ERROR'
break
}
}
# --- 4. Verify ------------------------------------------------------------------
foreach ($e in (Get-LegacyMachineEntries)) { $stillPresent.Add($e.DisplayName) }
$exes = [System.Collections.Generic.List[string]]::new()
foreach ($m in $Minor) {
$exes.Add((Join-Path $env:ProgramFiles "Python3$m\python.exe"))
if (${env:ProgramFiles(x86)}) { $exes.Add((Join-Path ${env:ProgramFiles(x86)} "Python3$m-32\python.exe")) }
}
foreach ($prof in $profiles) {
foreach ($f in $Folders) { $exes.Add((Join-Path $prof.LocalPath "AppData\Local\Programs\Python\$f\python.exe")) }
$le = Join-Path $prof.LocalPath 'AppData\Local\Programs\Python\Launcher\py.exe'
if ((Test-Path -LiteralPath $le) -and ((Get-Item -LiteralPath $le).VersionInfo.FileVersion -match "^$VerRegex\.")) {
$stillPresent.Add($le)
}
}
foreach ($x in $exes) { if (Test-Path -LiteralPath $x) { $stillPresent.Add($x) } }
$rootDirs = @(Get-ChildItem -LiteralPath "$env:SystemDrive\" -Directory -Filter 'python*' -ErrorAction SilentlyContinue)
foreach ($d in $rootDirs) { $checkDirs.Add($d.FullName) }
foreach ($d in ($checkDirs | Select-Object -Unique)) {
if (Test-LegacyPythonDir $d) { $stillPresent.Add("$d\python.exe") }
}
# --- Summary --------------------------------------------------------------------
$stillPresentUnique = @($stillPresent | Select-Object -Unique)
Write-Log "Summary:"
Write-Log (" Removed: {0}" -f (Join-OrNone $removed))
Write-Log (" Failed: {0}" -f (Join-OrNone $failed))
Write-Log (" Still present: {0}" -f (Join-OrNone $stillPresentUnique))
if ($unloadFailed) { exit 6 }
if ($failed.Count -gt 0 -or $stillPresentUnique.Count -gt 0) { exit 1 }
exit 0