From bffe56bf96ae19654653f3c853db2756a3997e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Wed, 14 Oct 2026 11:18:22 -0600 Subject: [PATCH 1/8] Install Windows Python 3.16+ from release manifest packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.16 no longer ships legacy Windows installers (PEP 773), so downloading `python--amd64.exe` fails with a 404. For 3.16+, read `windows-.json` from python.org, download the matching pythoncore package, and verify its SHA-256 before archiving. `setup.ps1` now extracts the `.zip` into the toolcache instead of running an installer and cleaning up registry entries. Links: - https://discuss.python.org/t/python-3-16-0-alpha-1-is-now-available/109466/2 - https://peps.python.org/pep-0773/ Signed-off-by: Edgar Ramírez Mondragón --- builders/win-python-builder.psm1 | 82 +++++++++++++++++++++++++++++-- installers/win-setup-template.ps1 | 37 ++++++++------ 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/builders/win-python-builder.psm1 b/builders/win-python-builder.psm1 index 33148d0bf..d6e0b63ea 100644 --- a/builders/win-python-builder.psm1 +++ b/builders/win-python-builder.psm1 @@ -25,6 +25,7 @@ class WinPythonBuilder : PythonBuilder { [string] $InstallationTemplateName [string] $InstallationScriptName [string] $OutputArtifactName + [string] $PackageLocation WinPythonBuilder( [semver] $version, @@ -36,6 +37,73 @@ class WinPythonBuilder : PythonBuilder { $this.OutputArtifactName = "python-$Version-$Platform-$Architecture.zip" } + [bool] UsesPackageManifest() { + <# + .SYNOPSIS + Python 3.16+ no longer ships legacy Windows installers (PEP 773), only packages listed in a release manifest. + #> + + return $this.Version.Major -eq 3 -and $this.Version.Minor -ge 16 + } + + [string] GetManifestPackageId() { + <# + .SYNOPSIS + Return the id of the "pythoncore" package in the Windows release manifest for this architecture. + #> + + $hardware = switch ($this.GetHardwareArchitecture()) { + "x86" { "32" } + "x64" { "64" } + "arm64" { "arm64" } + default { throw "Unsupported architecture: $($this.Architecture)" } + } + $freethreaded = if ($this.IsFreeThreaded()) { "t" } else { "" } + + return "pythoncore-$($this.Version.Major).$($this.Version.Minor)${freethreaded}-${hardware}" + } + + [uri] GetManifestUri() { + <# + .SYNOPSIS + Return URI of the Windows release manifest, e.g. windows-3.16.0a1.json + #> + + $base = $this.GetBaseUri() + $versionName = $this.GetBaseVersion() + $nativeVersion = Convert-Version -version $this.Version + + return "${base}/${versionName}/windows-${nativeVersion}.json" + } + + [string] DownloadPackage() { + <# + .SYNOPSIS + Download the Python package listed in the Windows release manifest and validate its SHA-256 hash. + #> + + $manifestUri = $this.GetManifestUri() + Write-Host "Manifest URI: $manifestUri" + $manifest = Invoke-RestMethod -Uri $manifestUri + + $packageId = $this.GetManifestPackageId() + $package = $manifest.versions | Where-Object { $_.id -eq $packageId } + if ($null -eq $package) { + throw "Package '$packageId' not found in $manifestUri" + } + + Write-Host "Sources URI: $($package.url)" + $packagePath = Download-File -Uri $package.url -OutputFolder $this.WorkFolderLocation + Write-Debug "Done; Sources location: $packagePath" + + $actualHash = (Get-FileHash -Path $packagePath -Algorithm SHA256).Hash + if ($actualHash -ne $package.hash.sha256) { + throw "SHA-256 mismatch for $($package.url): expected $($package.hash.sha256), got $actualHash" + } + + return $packagePath + } + [string] GetPythonExtension() { <# .SYNOPSIS @@ -84,6 +152,10 @@ class WinPythonBuilder : PythonBuilder { Download Python installation executable into artifact location. #> + if ($this.UsesPackageManifest()) { + return $this.DownloadPackage() + } + $sourceUri = $this.GetSourceUri() Write-Host "Sources URI: $sourceUri" @@ -99,8 +171,12 @@ class WinPythonBuilder : PythonBuilder { Create Python artifact installation script based on specified template. #> - $sourceUri = $this.GetSourceUri() - $pythonExecName = [IO.path]::GetFileName($sourceUri.AbsoluteUri) + if ($this.UsesPackageManifest()) { + $pythonExecName = [IO.path]::GetFileName($this.PackageLocation) + } else { + $sourceUri = $this.GetSourceUri() + $pythonExecName = [IO.path]::GetFileName($sourceUri.AbsoluteUri) + } $installationTemplateLocation = Join-Path -Path $this.InstallationTemplatesLocation -ChildPath $this.InstallationTemplateName $installationTemplateContent = Get-Content -Path $installationTemplateLocation -Raw $installationScriptLocation = New-Item -Path $this.WorkFolderLocation -Name $this.InstallationScriptName -ItemType File @@ -129,7 +205,7 @@ class WinPythonBuilder : PythonBuilder { #> Write-Host "Download Python $($this.Version) [$($this.Architecture)] executable..." - $this.Download() + $this.PackageLocation = $this.Download() Write-Host "Create installation script..." $this.CreateInstallationScript() diff --git a/installers/win-setup-template.ps1 b/installers/win-setup-template.ps1 index ad93a2dd3..1b54f395c 100644 --- a/installers/win-setup-template.ps1 +++ b/installers/win-setup-template.ps1 @@ -83,6 +83,7 @@ $PythonToolcachePath = Join-Path -Path $ToolcacheRoot -ChildPath "Python" $PythonVersionPath = Join-Path -Path $PythonToolcachePath -ChildPath $Version $PythonArchPath = Join-Path -Path $PythonVersionPath -ChildPath $Architecture +$IsZip = $PythonExecName -match "\.zip$" $IsMSI = $PythonExecName -match "msi" $IsFreeThreaded = $Architecture -match "-freethreaded" @@ -114,27 +115,35 @@ if ($null -ne $InstalledVersions) { Write-Host "No Python$MajorVersion.$MinorVersion.* found" } -Write-Host "Remove registry entries for Python ${MajorVersion}.${MinorVersion}(${Architecture})..." -Remove-RegistryEntries -Architecture $Architecture -MajorVersion $MajorVersion -MinorVersion $MinorVersion +if (-not $IsZip) { + Write-Host "Remove registry entries for Python ${MajorVersion}.${MinorVersion}(${Architecture})..." + Remove-RegistryEntries -Architecture $Architecture -MajorVersion $MajorVersion -MinorVersion $MinorVersion +} Write-Host "Create Python $Version folder in $PythonToolcachePath" New-Item -ItemType Directory -Path $PythonArchPath -Force | Out-Null -Write-Host "Copy Python binaries to $PythonArchPath" -Copy-Item -Path ./$PythonExecName -Destination $PythonArchPath | Out-Null +if ($IsZip) { + # Python 3.16+ is distributed as a package (PEP 773) that can be extracted and run as-is + Write-Host "Extract Python $Version in $PythonToolcachePath..." + Expand-Archive -Path ./$PythonExecName -DestinationPath $PythonArchPath -Force +} else { + Write-Host "Copy Python binaries to $PythonArchPath" + Copy-Item -Path ./$PythonExecName -Destination $PythonArchPath | Out-Null -Write-Host "Install Python $Version in $PythonToolcachePath..." -$ExecParams = Get-ExecParams -IsMSI $IsMSI -IsFreeThreaded $IsFreeThreaded -PythonArchPath $PythonArchPath + Write-Host "Install Python $Version in $PythonToolcachePath..." + $ExecParams = Get-ExecParams -IsMSI $IsMSI -IsFreeThreaded $IsFreeThreaded -PythonArchPath $PythonArchPath -cmd.exe /c "cd $PythonArchPath && call $PythonExecName $ExecParams /quiet" -if ($LASTEXITCODE -ne 0) { - Throw "Error happened during Python installation" -} + cmd.exe /c "cd $PythonArchPath && call $PythonExecName $ExecParams /quiet" + if ($LASTEXITCODE -ne 0) { + Throw "Error happened during Python installation" + } -if ($IsFreeThreaded) { - # Delete python.exe and create a symlink to free-threaded exe - Remove-Item -Path "$PythonArchPath\python.exe" -Force - New-Item -Path "$PythonArchPath\python.exe" -ItemType SymbolicLink -Value "$PythonArchPath\python${MajorVersion}.${MinorVersion}t.exe" + if ($IsFreeThreaded) { + # Delete python.exe and create a symlink to free-threaded exe + Remove-Item -Path "$PythonArchPath\python.exe" -Force + New-Item -Path "$PythonArchPath\python.exe" -ItemType SymbolicLink -Value "$PythonArchPath\python${MajorVersion}.${MinorVersion}t.exe" + } } Write-Host "Create `python3` symlink" From a638a87e77e0ed5d7767a9b766be4eed0705d4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Wed, 14 Oct 2026 12:20:45 -0600 Subject: [PATCH 2/8] Add a test to confirm that pip.exe is in the toolcache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/python-tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index e1dc7a74a..e5d9f9322 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -66,6 +66,15 @@ Describe "Tests" { } } + if ($Platform -eq "win32") { + It "pip.exe is from the toolcache" { + $pipLocation = (Get-Command "pip").Path + $pipLocation | Should -Not -BeNullOrEmpty + $expectedPath = Join-Path -Path $env:RUNNER_TOOL_CACHE -ChildPath "Python" + $pipLocation.startsWith($expectedPath) | Should -BeTrue + } + } + It "Run pip" { "pip install requests" | Should -ReturnZeroExitCode "pip uninstall requests -y" | Should -ReturnZeroExitCode From cab5080e4e1ade39ba680531ecdb94e9b995f5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 13:06:43 -0600 Subject: [PATCH 3/8] Test presence of Lib/test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/python-tests.ps1 | 4 ++++ tests/sources/stdlib-test-suite.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/sources/stdlib-test-suite.py diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index e5d9f9322..2cb7e2c72 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -66,6 +66,10 @@ Describe "Tests" { } } + It "Check if the standard library test suite (Lib/test) is installed" { + "python ./sources/stdlib-test-suite.py" | Should -ReturnZeroExitCode + } + if ($Platform -eq "win32") { It "pip.exe is from the toolcache" { $pipLocation = (Get-Command "pip").Path diff --git a/tests/sources/stdlib-test-suite.py b/tests/sources/stdlib-test-suite.py new file mode 100644 index 000000000..0b1b9a137 --- /dev/null +++ b/tests/sources/stdlib-test-suite.py @@ -0,0 +1,19 @@ +import importlib.util +import os.path +import sys + + +def check_test_suite(): + spec = importlib.util.find_spec("test") + if spec is None or not spec.submodule_search_locations: + print("The 'test' package (Lib/test) is not installed") + sys.exit(1) + + test_dir = list(spec.submodule_search_locations)[0] + for name in ("support", "libregrtest"): + if not os.path.isdir(os.path.join(test_dir, name)): + print("Missing test.{} in {}".format(name, test_dir)) + sys.exit(1) + + +check_test_suite() From 891b598c95342870f0e8f14498557097ffbd42f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 15:29:17 -0600 Subject: [PATCH 4/8] Download Lib/test in the standard build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- builders/win-python-builder.psm1 | 32 +++++++++++++++++++++---------- installers/win-setup-template.ps1 | 3 +++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/builders/win-python-builder.psm1 b/builders/win-python-builder.psm1 index d6e0b63ea..0155ea7f3 100644 --- a/builders/win-python-builder.psm1 +++ b/builders/win-python-builder.psm1 @@ -46,10 +46,10 @@ class WinPythonBuilder : PythonBuilder { return $this.Version.Major -eq 3 -and $this.Version.Minor -ge 16 } - [string] GetManifestPackageId() { + [string] GetManifestPackageId([string] $package, [bool] $freethreaded) { <# .SYNOPSIS - Return the id of the "pythoncore" package in the Windows release manifest for this architecture. + Return the id of a package ("pythoncore" or "pythontest") in the Windows release manifest for this architecture. #> $hardware = switch ($this.GetHardwareArchitecture()) { @@ -58,9 +58,9 @@ class WinPythonBuilder : PythonBuilder { "arm64" { "arm64" } default { throw "Unsupported architecture: $($this.Architecture)" } } - $freethreaded = if ($this.IsFreeThreaded()) { "t" } else { "" } + $suffix = if ($freethreaded) { "t" } else { "" } - return "pythoncore-$($this.Version.Major).$($this.Version.Minor)${freethreaded}-${hardware}" + return "${package}-$($this.Version.Major).$($this.Version.Minor)${suffix}-${hardware}" } [uri] GetManifestUri() { @@ -76,20 +76,27 @@ class WinPythonBuilder : PythonBuilder { return "${base}/${versionName}/windows-${nativeVersion}.json" } - [string] DownloadPackage() { + [object] GetManifest() { <# .SYNOPSIS - Download the Python package listed in the Windows release manifest and validate its SHA-256 hash. + Download and parse the Windows release manifest. #> $manifestUri = $this.GetManifestUri() Write-Host "Manifest URI: $manifestUri" - $manifest = Invoke-RestMethod -Uri $manifestUri - $packageId = $this.GetManifestPackageId() + return Invoke-RestMethod -Uri $manifestUri + } + + [string] DownloadPackage([object] $manifest, [string] $packageId) { + <# + .SYNOPSIS + Download a package listed in the Windows release manifest and validate its SHA-256 hash. + #> + $package = $manifest.versions | Where-Object { $_.id -eq $packageId } if ($null -eq $package) { - throw "Package '$packageId' not found in $manifestUri" + throw "Package '$packageId' not found in the Windows release manifest" } Write-Host "Sources URI: $($package.url)" @@ -153,7 +160,12 @@ class WinPythonBuilder : PythonBuilder { #> if ($this.UsesPackageManifest()) { - return $this.DownloadPackage() + $manifest = $this.GetManifest() + + # The "pythontest" package is "pythoncore" plus Lib/test, for consistency with the legacy installers. + $package = if ($this.IsFreeThreaded()) { "pythoncore" } else { "pythontest" } + + return $this.DownloadPackage($manifest, $this.GetManifestPackageId($package, $this.IsFreeThreaded())) } $sourceUri = $this.GetSourceUri() diff --git a/installers/win-setup-template.ps1 b/installers/win-setup-template.ps1 index 1b54f395c..5ad780894 100644 --- a/installers/win-setup-template.ps1 +++ b/installers/win-setup-template.ps1 @@ -127,6 +127,9 @@ if ($IsZip) { # Python 3.16+ is distributed as a package (PEP 773) that can be extracted and run as-is Write-Host "Extract Python $Version in $PythonToolcachePath..." Expand-Archive -Path ./$PythonExecName -DestinationPath $PythonArchPath -Force + + # Debug symbols are only in the "pythontest" package and were not installed by the legacy installers + Get-ChildItem -Path $PythonArchPath -Recurse -Filter "*.pdb" | Remove-Item -Force } else { Write-Host "Copy Python binaries to $PythonArchPath" Copy-Item -Path ./$PythonExecName -Destination $PythonArchPath | Out-Null From f6d1b56473f37e88fde0fa2a165a011310e10c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 16:32:33 -0600 Subject: [PATCH 5/8] Skip Lib/test for 3.16t and comment we are waiting for a release that includes `pythontest-3.16t-* ` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- builders/win-python-builder.psm1 | 10 ++++++++-- tests/python-tests.ps1 | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/builders/win-python-builder.psm1 b/builders/win-python-builder.psm1 index 0155ea7f3..0044cf4de 100644 --- a/builders/win-python-builder.psm1 +++ b/builders/win-python-builder.psm1 @@ -163,9 +163,15 @@ class WinPythonBuilder : PythonBuilder { $manifest = $this.GetManifest() # The "pythontest" package is "pythoncore" plus Lib/test, for consistency with the legacy installers. - $package = if ($this.IsFreeThreaded()) { "pythoncore" } else { "pythontest" } + $packageId = $this.GetManifestPackageId("pythontest", $this.IsFreeThreaded()) - return $this.DownloadPackage($manifest, $this.GetManifestPackageId($package, $this.IsFreeThreaded())) + # 3.16.0a1 has no free-threaded "pythontest" package, so fall back to "pythoncore" (no Lib/test) for those. + if (-not ($manifest.versions | Where-Object { $_.id -eq $packageId })) { + Write-Host "Package '$packageId' not found, using 'pythoncore' instead" + $packageId = $this.GetManifestPackageId("pythoncore", $this.IsFreeThreaded()) + } + + return $this.DownloadPackage($manifest, $packageId) } $sourceUri = $this.GetSourceUri() diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index 2cb7e2c72..5f9e7cb8c 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -66,7 +66,9 @@ Describe "Tests" { } } - It "Check if the standard library test suite (Lib/test) is installed" { + # TODO: There is no free-threaded "PythonTest" package in the 3.16.0a1 Windows manifest (expected to be fixed in 3.16.0a2) + $SkipTestSuite = ($Platform -eq "win32") -and ($Architecture -match "-freethreaded") -and ($Version -le [semver]"3.16.0-alpha.1") + It "Check if the standard library test suite (Lib/test) is installed" -Skip:$SkipTestSuite { "python ./sources/stdlib-test-suite.py" | Should -ReturnZeroExitCode } From f75dc39c9d13d082f3ef675fbdad0c25a8d6fdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 17:19:32 -0600 Subject: [PATCH 6/8] Test `py -3.16` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/python-tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index 5f9e7cb8c..5a7ee1b9d 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -79,6 +79,15 @@ Describe "Tests" { $expectedPath = Join-Path -Path $env:RUNNER_TOOL_CACHE -ChildPath "Python" $pipLocation.startsWith($expectedPath) | Should -BeTrue } + + It "py.exe finds Python" { + $suffix = if ($Architecture -match "-freethreaded") { "t" } else { "" } + $major, $minor = "$Version".Split(".")[0..1] + $pyLocation = py "-$major.$minor$suffix" -c "import sys; print(sys.executable)" + $LASTEXITCODE | Should -Be 0 + $expectedPath = Join-Path -Path $env:RUNNER_TOOL_CACHE -ChildPath "Python" + $pyLocation.startsWith($expectedPath) | Should -BeTrue + } } It "Run pip" { From 14b4b271d0b806a410d1239acc6cf02487fb7259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 17:55:32 -0600 Subject: [PATCH 7/8] Do not expect a fix in 3.16.0a2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/python-tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index 5a7ee1b9d..cf512890c 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -66,8 +66,8 @@ Describe "Tests" { } } - # TODO: There is no free-threaded "PythonTest" package in the 3.16.0a1 Windows manifest (expected to be fixed in 3.16.0a2) - $SkipTestSuite = ($Platform -eq "win32") -and ($Architecture -match "-freethreaded") -and ($Version -le [semver]"3.16.0-alpha.1") + # Windows free-threaded Python 3.16+ is installed from a "PythonCore" package, as there is no free-threaded "PythonTest" package with Lib/test + $SkipTestSuite = ($Platform -eq "win32") -and ($Architecture -match "-freethreaded") -and ([semver]$Version -ge [semver]"3.16.0-alpha.1") It "Check if the standard library test suite (Lib/test) is installed" -Skip:$SkipTestSuite { "python ./sources/stdlib-test-suite.py" | Should -ReturnZeroExitCode } From 6edb380f6937af81fa6a560cea5b291ab394cbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Thu, 15 Oct 2026 18:03:50 -0600 Subject: [PATCH 8/8] Skip pylauncher test on 3.16.0a1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/python-tests.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/python-tests.ps1 b/tests/python-tests.ps1 index cf512890c..addd3b6eb 100644 --- a/tests/python-tests.ps1 +++ b/tests/python-tests.ps1 @@ -80,7 +80,9 @@ Describe "Tests" { $pipLocation.startsWith($expectedPath) | Should -BeTrue } - It "py.exe finds Python" { + # TODO: Python 3.16+ is extracted from a package, so it is not registered for the legacy py.exe launcher + $SkipPyLauncher = [semver]$Version -ge [semver]"3.16.0-alpha.1" + It "py.exe finds Python" -Skip:$SkipPyLauncher { $suffix = if ($Architecture -match "-freethreaded") { "t" } else { "" } $major, $minor = "$Version".Split(".")[0..1] $pyLocation = py "-$major.$minor$suffix" -c "import sys; print(sys.executable)"