@@ -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.
215219func (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\n Output: %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).
242320func (p * Provider ) installPipWithGetPip (version , pythonPath , installPath string ) error {
0 commit comments