Skip to content

Commit dab2ce1

Browse files
committed
fix(python): force-reinstall bundled pip wheel to create Scripts/*.exe
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 <tmpdir>` 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 <wheel> 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
1 parent c095a8b commit dab2ce1

2 files changed

Lines changed: 137 additions & 5 deletions

File tree

src/runtimes/python/provider_full.go

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,15 @@ func (p *Provider) createShims(version string) error {
194194

195195
// installPip ensures pip is properly installed with working executables.
196196
// This handles two scenarios:
197-
// 1. python.org embeddable packages: pip is not included, needs ensurepip
198-
// 2. python-build-standalone: pip module exists but pip.exe has broken paths
197+
// 1. python.org embeddable packages: pip is not included, needs ensurepip
198+
// 2. python-build-standalone: pip module is pre-installed but no console
199+
// scripts exist in Scripts/, so we must force-reinstall the bundled
200+
// wheel to materialize pip.exe / pip3.exe / pip3.X.exe.
199201
//
200-
// Running "python -m ensurepip --default-pip --upgrade" handles both cases
201-
// by (re)installing pip and creating working pip/pip3/pipX.Y executables.
202+
// The two-step order matters: ensurepip first guarantees the pip module
203+
// is importable, then the force-reinstall guarantees the .exe scripts
204+
// exist. Either step alone is insufficient for python-build-standalone
205+
// on Windows.
202206
func (p *Provider) installPip(version string) error {
203207
pythonPath, err := p.ExecutablePath(version)
204208
if err != nil {
@@ -212,7 +216,12 @@ func (p *Provider) installPip(version string) error {
212216
pthFile := filepath.Join(installPath, fmt.Sprintf("python%s._pth", strings.Join(strings.Split(version, ".")[:2], "")))
213217
_ = p.enableSitePackages(pthFile) // Best effort - ignore errors
214218

215-
// Run ensurepip to install/reinstall pip with working executables.
219+
// Step 1: ensurepip bootstraps the pip module on distributions that
220+
// ship without one (python.org embeddable). It's a no-op on python-
221+
// build-standalone because pip is already in site-packages with a
222+
// matching version — pip's "already satisfied" short-circuit
223+
// silently skips the install AND the .exe entry-point generation,
224+
// which is why a separate force-reinstall step is needed below.
216225
cmd := exec.Command(pythonPath, "-m", "ensurepip", "--default-pip", "--upgrade")
217226
cmd.Dir = installPath
218227
output, err := cmd.CombinedOutput()
@@ -221,9 +230,78 @@ func (p *Provider) installPip(version string) error {
221230
return p.installPipWithGetPip(version, pythonPath, installPath)
222231
}
223232

233+
// Step 2: Force-reinstall pip from the bundled wheel so the .exe
234+
// entry-point scripts land in Scripts/. ensurepip doesn't expose a
235+
// --force flag, so we bypass it and feed the wheel directly to pip
236+
// with --force-reinstall. Offline (--no-index) and self-contained
237+
// (--no-deps), so no network access and no surprise upgrades.
238+
//
239+
// Best-effort: if the bundled wheel isn't where we expect (some
240+
// minimal distributions strip ensurepip's _bundled/ directory),
241+
// fall through with whatever ensurepip produced. The user-facing
242+
// installPipIfNeeded warns anyway when the eventual Scripts/ scan
243+
// turns up nothing.
244+
if err := p.materializePipScripts(pythonPath, installPath); err != nil {
245+
ui.Debug("force-reinstall of bundled pip wheel failed: %v", err)
246+
}
247+
224248
return nil
225249
}
226250

251+
// materializePipScripts force-reinstalls pip from the bundled wheel that
252+
// ships under Lib/ensurepip/_bundled. This is the step that actually
253+
// creates Scripts/pip.exe, Scripts/pip3.exe, and Scripts/pip3.X.exe.
254+
//
255+
// Why this is necessary: ensurepip --upgrade internally runs
256+
// `pip install --upgrade --no-index --find-links <tmpdir>` against the
257+
// bundled wheel. When the installed pip version equals the bundled
258+
// version (the default state of every python-build-standalone Windows
259+
// install), pip prints "Requirement already satisfied" and skips —
260+
// including skipping the entry-point .exe script generation, even
261+
// though that's what the caller actually wanted. ensurepip exposes no
262+
// --force flag, so we have to feed the wheel directly to pip with
263+
// --force-reinstall to bypass the short-circuit.
264+
//
265+
// Returns an error only if the bundled wheel can't be found or the pip
266+
// invocation itself failed; the caller treats this as best-effort.
267+
func (p *Provider) materializePipScripts(pythonPath, installPath string) error {
268+
bundledDir := filepath.Join(installPath, "Lib", "ensurepip", "_bundled")
269+
wheel, err := findBundledPipWheel(bundledDir)
270+
if err != nil {
271+
return err
272+
}
273+
274+
cmd := exec.Command(pythonPath, "-m", "pip", "install",
275+
"--no-index", "--no-deps", "--force-reinstall", wheel)
276+
cmd.Dir = installPath
277+
output, err := cmd.CombinedOutput()
278+
if err != nil {
279+
return fmt.Errorf("pip install --force-reinstall %s: %w\nOutput: %s",
280+
wheel, err, string(output))
281+
}
282+
return nil
283+
}
284+
285+
// findBundledPipWheel returns the path to the pip wheel that ships
286+
// alongside ensurepip (Lib/ensurepip/_bundled/pip-*.whl). The directory
287+
// is part of the CPython stdlib distribution and is present on every
288+
// build the upstream project produces, including python-build-standalone
289+
// and the official Windows installers, but minimal redistributions
290+
// (e.g., python.org embeddable) may strip it.
291+
//
292+
// Returns an error when the directory is missing or contains no
293+
// pip-*.whl entry so callers can fall back cleanly.
294+
func findBundledPipWheel(bundledDir string) (string, error) {
295+
matches, err := filepath.Glob(filepath.Join(bundledDir, "pip-*.whl"))
296+
if err != nil {
297+
return "", fmt.Errorf("glob bundled pip wheel: %w", err)
298+
}
299+
if len(matches) == 0 {
300+
return "", fmt.Errorf("no bundled pip wheel found in %s", bundledDir)
301+
}
302+
return matches[0], nil
303+
}
304+
227305
// installPipWithGetPip is a fallback method that downloads and runs get-pip.py.
228306
// Used when ensurepip fails (e.g., ensurepip module missing or corrupted).
229307
func (p *Provider) installPipWithGetPip(version, pythonPath, installPath string) error {

src/runtimes/python/provider_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,57 @@ func TestPythonProvider_Shims(t *testing.T) {
283283
}
284284
})
285285
}
286+
287+
// TestFindBundledPipWheel exercises the glob-based discovery used by
288+
// materializePipScripts. The wheel filename varies across Python versions
289+
// (pip-25.3-py3-none-any.whl on 3.14, pip-24.x on older), so the test
290+
// confirms the helper finds whatever pip-*.whl is present and surfaces a
291+
// clear error when the bundled directory is empty or absent.
292+
func TestFindBundledPipWheel(t *testing.T) {
293+
t.Run("finds wheel when present", func(t *testing.T) {
294+
bundledDir := t.TempDir()
295+
wheelPath := filepath.Join(bundledDir, "pip-25.3-py3-none-any.whl")
296+
if err := os.WriteFile(wheelPath, []byte("PK\x03\x04"), 0644); err != nil {
297+
t.Fatalf("write wheel: %v", err)
298+
}
299+
300+
got, err := findBundledPipWheel(bundledDir)
301+
if err != nil {
302+
t.Fatalf("unexpected error: %v", err)
303+
}
304+
if got != wheelPath {
305+
t.Errorf("got %q, want %q", got, wheelPath)
306+
}
307+
})
308+
309+
t.Run("errors when bundled dir empty", func(t *testing.T) {
310+
bundledDir := t.TempDir()
311+
_, err := findBundledPipWheel(bundledDir)
312+
if err == nil {
313+
t.Error("expected error for empty bundled dir, got nil")
314+
}
315+
})
316+
317+
t.Run("errors when bundled dir missing", func(t *testing.T) {
318+
_, err := findBundledPipWheel(filepath.Join(t.TempDir(), "does-not-exist"))
319+
if err == nil {
320+
t.Error("expected error for missing bundled dir, got nil")
321+
}
322+
})
323+
324+
t.Run("ignores non-pip wheels", func(t *testing.T) {
325+
bundledDir := t.TempDir()
326+
// ensurepip historically also bundled setuptools alongside pip
327+
// (removed in Python 3.12). The glob targets pip specifically
328+
// so a stray non-pip wheel must not be selected.
329+
other := filepath.Join(bundledDir, "setuptools-65.5.0-py3-none-any.whl")
330+
if err := os.WriteFile(other, []byte("PK\x03\x04"), 0644); err != nil {
331+
t.Fatalf("write decoy: %v", err)
332+
}
333+
334+
_, err := findBundledPipWheel(bundledDir)
335+
if err == nil {
336+
t.Error("expected error when only non-pip wheel is present")
337+
}
338+
})
339+
}

0 commit comments

Comments
 (0)