Skip to content

Commit 6bf438b

Browse files
authored
fix(python): force-reinstall bundled pip wheel to create Scripts/*.exe (#271)
1 parent 9add90e commit 6bf438b

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
@@ -207,11 +207,15 @@ func (p *Provider) createShims(version string) error {
207207

208208
// installPip ensures pip is properly installed with working executables.
209209
// This handles two scenarios:
210-
// 1. python.org embeddable packages: pip is not included, needs ensurepip
211-
// 2. python-build-standalone: pip module exists but pip.exe has broken paths
210+
// 1. python.org embeddable packages: pip is not included, needs ensurepip
211+
// 2. python-build-standalone: pip module is pre-installed but no console
212+
// scripts exist in Scripts/, so we must force-reinstall the bundled
213+
// wheel to materialize pip.exe / pip3.exe / pip3.X.exe.
212214
//
213-
// Running "python -m ensurepip --default-pip --upgrade" handles both cases
214-
// by (re)installing pip and creating working pip/pip3/pipX.Y executables.
215+
// The two-step order matters: ensurepip first guarantees the pip module
216+
// is importable, then the force-reinstall guarantees the .exe scripts
217+
// exist. Either step alone is insufficient for python-build-standalone
218+
// on Windows.
215219
func (p *Provider) installPip(version string) error {
216220
pythonPath, err := p.ExecutablePath(version)
217221
if err != nil {
@@ -225,7 +229,12 @@ func (p *Provider) installPip(version string) error {
225229
pthFile := filepath.Join(installPath, fmt.Sprintf("python%s._pth", strings.Join(strings.Split(version, ".")[:2], "")))
226230
_ = p.enableSitePackages(pthFile) // Best effort - ignore errors
227231

228-
// Run ensurepip to install/reinstall pip with working executables.
232+
// Step 1: ensurepip bootstraps the pip module on distributions that
233+
// ship without one (python.org embeddable). It's a no-op on python-
234+
// build-standalone because pip is already in site-packages with a
235+
// matching version — pip's "already satisfied" short-circuit
236+
// silently skips the install AND the .exe entry-point generation,
237+
// which is why a separate force-reinstall step is needed below.
229238
cmd := exec.Command(pythonPath, "-m", "ensurepip", "--default-pip", "--upgrade")
230239
cmd.Dir = installPath
231240
output, err := cmd.CombinedOutput()
@@ -234,9 +243,78 @@ func (p *Provider) installPip(version string) error {
234243
return p.installPipWithGetPip(version, pythonPath, installPath)
235244
}
236245

246+
// Step 2: Force-reinstall pip from the bundled wheel so the .exe
247+
// entry-point scripts land in Scripts/. ensurepip doesn't expose a
248+
// --force flag, so we bypass it and feed the wheel directly to pip
249+
// with --force-reinstall. Offline (--no-index) and self-contained
250+
// (--no-deps), so no network access and no surprise upgrades.
251+
//
252+
// Best-effort: if the bundled wheel isn't where we expect (some
253+
// minimal distributions strip ensurepip's _bundled/ directory),
254+
// fall through with whatever ensurepip produced. The user-facing
255+
// installPipIfNeeded warns anyway when the eventual Scripts/ scan
256+
// turns up nothing.
257+
if err := p.materializePipScripts(pythonPath, installPath); err != nil {
258+
ui.Debug("force-reinstall of bundled pip wheel failed: %v", err)
259+
}
260+
237261
return nil
238262
}
239263

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