From b82f3cb60ffe4f623c168b9d3784928bfed87131 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 14 May 2026 10:57:22 -0400 Subject: [PATCH] fix(python): force-reinstall bundled pip wheel to create Scripts/*.exe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows install of Python 3.14.2 (and other python-build-standalone releases) produced no pip.exe, pip3.exe, or pip3.X.exe even though "Configuring pip..." reported success. ensurepip --upgrade exited 0 and printed "Requirement already satisfied: pip" — but did not create any of the console scripts users expect. Root cause: python-build-standalone Windows tarballs ship pip's module tree already present in Lib/site-packages/pip-25.3.dist-info/ with the bundled wheel sitting at Lib/ensurepip/_bundled/pip-25.3-py3-none-any.whl. ensurepip --upgrade internally runs `pip install --upgrade --no-index --find-links ` against that bundled wheel. Because the wheel version matches the version already installed, pip short-circuits with "Requirement already satisfied" and skips the install — including skipping the .exe entry-point script generation. ensurepip exposes no --force flag, so the only way to materialize the scripts is to bypass ensurepip and call pip directly with --force-reinstall. This change adds a materializePipScripts step that runs after ensurepip: glob Lib/ensurepip/_bundled/pip-*.whl, then invoke python -m pip install --no-index --no-deps --force-reinstall against it. --no-index keeps it offline (uses the wheel shipped with the distribution, no PyPI access). --no-deps avoids surprise upgrades. --force-reinstall is the part that defeats the "already satisfied" short-circuit and creates Scripts/pip.exe / pip3.exe / pip3.X.exe. ensurepip is kept as the first step because it still handles the case where pip isn't already in site-packages (e.g., python.org embeddable distributions). The force-reinstall is best-effort: minimal distributions may strip ensurepip's _bundled/ directory, and in that case we fall through with whatever ensurepip produced. Resolves #269 --- src/runtimes/python/provider_full.go | 88 ++++++++++++++++++++++++++-- src/runtimes/python/provider_test.go | 54 +++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/runtimes/python/provider_full.go b/src/runtimes/python/provider_full.go index 835edae..5b0bb36 100644 --- a/src/runtimes/python/provider_full.go +++ b/src/runtimes/python/provider_full.go @@ -207,11 +207,15 @@ func (p *Provider) createShims(version string) error { // installPip ensures pip is properly installed with working executables. // This handles two scenarios: -// 1. python.org embeddable packages: pip is not included, needs ensurepip -// 2. python-build-standalone: pip module exists but pip.exe has broken paths +// 1. python.org embeddable packages: pip is not included, needs ensurepip +// 2. python-build-standalone: pip module is pre-installed but no console +// scripts exist in Scripts/, so we must force-reinstall the bundled +// wheel to materialize pip.exe / pip3.exe / pip3.X.exe. // -// Running "python -m ensurepip --default-pip --upgrade" handles both cases -// by (re)installing pip and creating working pip/pip3/pipX.Y executables. +// The two-step order matters: ensurepip first guarantees the pip module +// is importable, then the force-reinstall guarantees the .exe scripts +// exist. Either step alone is insufficient for python-build-standalone +// on Windows. func (p *Provider) installPip(version string) error { pythonPath, err := p.ExecutablePath(version) if err != nil { @@ -225,7 +229,12 @@ func (p *Provider) installPip(version string) error { pthFile := filepath.Join(installPath, fmt.Sprintf("python%s._pth", strings.Join(strings.Split(version, ".")[:2], ""))) _ = p.enableSitePackages(pthFile) // Best effort - ignore errors - // Run ensurepip to install/reinstall pip with working executables. + // Step 1: ensurepip bootstraps the pip module on distributions that + // ship without one (python.org embeddable). It's a no-op on python- + // build-standalone because pip is already in site-packages with a + // matching version — pip's "already satisfied" short-circuit + // silently skips the install AND the .exe entry-point generation, + // which is why a separate force-reinstall step is needed below. cmd := exec.Command(pythonPath, "-m", "ensurepip", "--default-pip", "--upgrade") cmd.Dir = installPath output, err := cmd.CombinedOutput() @@ -234,9 +243,78 @@ func (p *Provider) installPip(version string) error { return p.installPipWithGetPip(version, pythonPath, installPath) } + // Step 2: Force-reinstall pip from the bundled wheel so the .exe + // entry-point scripts land in Scripts/. ensurepip doesn't expose a + // --force flag, so we bypass it and feed the wheel directly to pip + // with --force-reinstall. Offline (--no-index) and self-contained + // (--no-deps), so no network access and no surprise upgrades. + // + // Best-effort: if the bundled wheel isn't where we expect (some + // minimal distributions strip ensurepip's _bundled/ directory), + // fall through with whatever ensurepip produced. The user-facing + // installPipIfNeeded warns anyway when the eventual Scripts/ scan + // turns up nothing. + if err := p.materializePipScripts(pythonPath, installPath); err != nil { + ui.Debug("force-reinstall of bundled pip wheel failed: %v", err) + } + return nil } +// materializePipScripts force-reinstalls pip from the bundled wheel that +// ships under Lib/ensurepip/_bundled. This is the step that actually +// creates Scripts/pip.exe, Scripts/pip3.exe, and Scripts/pip3.X.exe. +// +// Why this is necessary: ensurepip --upgrade internally runs +// `pip install --upgrade --no-index --find-links ` against the +// bundled wheel. When the installed pip version equals the bundled +// version (the default state of every python-build-standalone Windows +// install), pip prints "Requirement already satisfied" and skips — +// including skipping the entry-point .exe script generation, even +// though that's what the caller actually wanted. ensurepip exposes no +// --force flag, so we have to feed the wheel directly to pip with +// --force-reinstall to bypass the short-circuit. +// +// Returns an error only if the bundled wheel can't be found or the pip +// invocation itself failed; the caller treats this as best-effort. +func (p *Provider) materializePipScripts(pythonPath, installPath string) error { + bundledDir := filepath.Join(installPath, "Lib", "ensurepip", "_bundled") + wheel, err := findBundledPipWheel(bundledDir) + if err != nil { + return err + } + + cmd := exec.Command(pythonPath, "-m", "pip", "install", + "--no-index", "--no-deps", "--force-reinstall", wheel) + cmd.Dir = installPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("pip install --force-reinstall %s: %w\nOutput: %s", + wheel, err, string(output)) + } + return nil +} + +// findBundledPipWheel returns the path to the pip wheel that ships +// alongside ensurepip (Lib/ensurepip/_bundled/pip-*.whl). The directory +// is part of the CPython stdlib distribution and is present on every +// build the upstream project produces, including python-build-standalone +// and the official Windows installers, but minimal redistributions +// (e.g., python.org embeddable) may strip it. +// +// Returns an error when the directory is missing or contains no +// pip-*.whl entry so callers can fall back cleanly. +func findBundledPipWheel(bundledDir string) (string, error) { + matches, err := filepath.Glob(filepath.Join(bundledDir, "pip-*.whl")) + if err != nil { + return "", fmt.Errorf("glob bundled pip wheel: %w", err) + } + if len(matches) == 0 { + return "", fmt.Errorf("no bundled pip wheel found in %s", bundledDir) + } + return matches[0], nil +} + // installPipWithGetPip is a fallback method that downloads and runs get-pip.py. // Used when ensurepip fails (e.g., ensurepip module missing or corrupted). func (p *Provider) installPipWithGetPip(version, pythonPath, installPath string) error { diff --git a/src/runtimes/python/provider_test.go b/src/runtimes/python/provider_test.go index 7bada4a..6ed63bd 100644 --- a/src/runtimes/python/provider_test.go +++ b/src/runtimes/python/provider_test.go @@ -283,3 +283,57 @@ func TestPythonProvider_Shims(t *testing.T) { } }) } + +// TestFindBundledPipWheel exercises the glob-based discovery used by +// materializePipScripts. The wheel filename varies across Python versions +// (pip-25.3-py3-none-any.whl on 3.14, pip-24.x on older), so the test +// confirms the helper finds whatever pip-*.whl is present and surfaces a +// clear error when the bundled directory is empty or absent. +func TestFindBundledPipWheel(t *testing.T) { + t.Run("finds wheel when present", func(t *testing.T) { + bundledDir := t.TempDir() + wheelPath := filepath.Join(bundledDir, "pip-25.3-py3-none-any.whl") + if err := os.WriteFile(wheelPath, []byte("PK\x03\x04"), 0644); err != nil { + t.Fatalf("write wheel: %v", err) + } + + got, err := findBundledPipWheel(bundledDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != wheelPath { + t.Errorf("got %q, want %q", got, wheelPath) + } + }) + + t.Run("errors when bundled dir empty", func(t *testing.T) { + bundledDir := t.TempDir() + _, err := findBundledPipWheel(bundledDir) + if err == nil { + t.Error("expected error for empty bundled dir, got nil") + } + }) + + t.Run("errors when bundled dir missing", func(t *testing.T) { + _, err := findBundledPipWheel(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Error("expected error for missing bundled dir, got nil") + } + }) + + t.Run("ignores non-pip wheels", func(t *testing.T) { + bundledDir := t.TempDir() + // ensurepip historically also bundled setuptools alongside pip + // (removed in Python 3.12). The glob targets pip specifically + // so a stray non-pip wheel must not be selected. + other := filepath.Join(bundledDir, "setuptools-65.5.0-py3-none-any.whl") + if err := os.WriteFile(other, []byte("PK\x03\x04"), 0644); err != nil { + t.Fatalf("write decoy: %v", err) + } + + _, err := findBundledPipWheel(bundledDir) + if err == nil { + t.Error("expected error when only non-pip wheel is present") + } + }) +}