ENG-2084: Install private wheels into the Python runtime's site-packages#3850
ENG-2084: Install private wheels into the Python runtime's site-packages#3850icanhasmath wants to merge 4 commits into
Conversation
The `state publish --build` private-ingredient consume path installed the decrypted wheel into a flat `site-packages` at the artifact deploy-tree root, which linked to `<installdir>/site-packages` -- the wrong location. The wheel must land in the Python runtime's own site-packages, whose layout varies by platform and Python version (e.g. `lib/pythonX.Y/site-packages` on Linux, `lib/site-packages` on macOS). The install previously ran during the parallel unpack phase, before the Python runtime was guaranteed on disk, so the correct path could not be discovered. Defer the placement until every artifact is unpacked, then locate the real site-packages by scanning the unpacked non-private artifacts (glob known layouts, full-walk fallback) and lay the wheel out at that same relative path so the normal deploy merges it into the runtime's site-packages. PYTHONPATH now points at the discovered path, matching the runtime's separator/inherit directives. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The Python runtime's site-packages layout is deterministic per OS, so the six-pattern glob list was overkill: Linux and macOS always use usr/lib/pythonX.Y/site-packages, and Windows always uses Lib/site-packages (case-insensitive filesystem). Collapse to a single OS-conditional pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
If the OS-specific glob does not find the runtime's site-packages, something is wrong with the runtime -- fail fast rather than fully walking every artifact tree looking for an unexpected layout. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR changes how decrypted private Python wheels are incorporated into a runtime so they are installed into the Python runtime’s actual site-packages directory (instead of a hard-coded flat ${INSTALLDIR}/site-packages), by deferring wheel installation until after all artifacts are unpacked and then locating site-packages on disk.
Changes:
- Defer private wheel installation during
unpackby recording wheel artifact dirs, then install them after the unpack worker pool completes. - Add
locateSitePackages+ glob-based discovery to find the runtime’ssite-packagesrelative path and update wheel install location +PYTHONPATHaccordingly. - Add unit tests for
globSitePackagesand cross-root deduplication.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| pkg/runtime/setup.go | Defers private wheel install until after unpack; adds site-packages discovery and installs wheels into the discovered path, updating PYTHONPATH. |
| pkg/runtime/sitepackages_test.go | Adds tests for the current globSitePackages behavior and deduplication across roots. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *setup) installPrivateWheels() error { | ||
| if len(s.privateWheels) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| relSitePackages, err := s.locateSitePackages() | ||
| if err != nil { | ||
| return errs.Wrap(err, "could not locate the Python runtime's site-packages directory") | ||
| } | ||
|
|
||
| for _, dir := range s.privateWheels { | ||
| if err := s.installPrivateWheel(dir, relSitePackages); err != nil { | ||
| return errs.Wrap(err, "could not install private wheel in %s", dir) | ||
| } | ||
| } | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Good catch on the cleanup — fixed in e8954f6. On failure installPrivateWheels now RemoveAlls each decrypted wheel directory, so no private plaintext lingers and the next run re-downloads and retries (the depot rebuilds its artifact set from a disk scan, so a leftover dir would otherwise be treated as already obtained). This restores the semantics the old inline path had via its os.RemoveAll(unpackPath).
On the mutex: the read here is safe without the lock. recordPrivateWheel writes s.privateWheels only from the unpack worker pool, and installPrivateWheels runs on the main goroutine after wp.Wait() has drained that pool — the Wait is a happens-before barrier, so the slice is fully published and no longer written concurrently. I added a comment noting this rather than taking a lock that would imply a concurrency that doesn't exist.
| // sitePackagesGlob is the relative location of the Python runtime's | ||
| // site-packages directory for the current OS. Windows uses a flat, unversioned | ||
| // Lib/site-packages (the filesystem is case-insensitive, so "lib" matches too); | ||
| // every other OS uses usr/lib/pythonX.Y/site-packages, with the version segment | ||
| // left as a wildcard to be resolved on disk. | ||
| func sitePackagesGlob() string { | ||
| if sysinfo.OS() == sysinfo.Windows { | ||
| return filepath.Join("Lib", "site-packages") | ||
| } | ||
| return filepath.Join("usr", "lib", "python*", "site-packages") | ||
| } |
There was a problem hiding this comment.
This is a deliberate scoping decision. prepare_mac.go is part of the camel (legacy) builder path, which is entirely separate from this feature — state publish --build private ingredients flow through the modern alternative builder (the setup.go deploy path), whose runtimes are relocated under a single install root with a deterministic layout: usr/lib/pythonX.Y/site-packages on Linux/macOS and Lib/site-packages on Windows. That per-OS layout was confirmed by the team, which is why this collapses to one pattern per OS rather than enumerating camel's framework layouts.
The behavior on an unmatched layout is intentional: locateSitePackages returns an error and the install fails fast rather than silently landing the wheel somewhere wrong. If a private ingredient ever targets a runtime with a different layout, that error is the signal to extend sitePackagesGlob — better than guessing.
| func TestGlobSitePackages(t *testing.T) { | ||
| // The expected layout is OS-specific (see sitePackagesGlob). | ||
| layout := filepath.Join("usr", "lib", "python3.10", "site-packages") | ||
| if sysinfo.OS() == sysinfo.Windows { | ||
| layout = filepath.Join("Lib", "site-packages") | ||
| } | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| layout string // relative dir to create under the root, "" for none | ||
| want string // expected relative site-packages path, "" for no match | ||
| }{ | ||
| {"matching layout", layout, layout}, | ||
| {"no site-packages", filepath.Join("bin"), ""}, | ||
| {"unexpected layout not globbed", filepath.Join("opt", "python", "site-packages"), ""}, | ||
| } |
There was a problem hiding this comment.
Intentional — the tests track the deterministic per-OS layout the alternative-builder runtimes actually use (see the reply on sitePackagesGlob). They're OS-aware (usr/lib/pythonX.Y/site-packages off-Windows, Lib/site-packages on Windows) rather than asserting a fixed string, so they follow sitePackagesGlob if it changes. If we ever need to support additional layouts, both the glob and these cases get extended together.
Deferring the private-wheel install lost the failure cleanup the inline path had. On failure, remove the decrypted wheel artifact directories so no private plaintext lingers and a later run re-downloads and retries (the depot rebuilds its artifact set from a disk scan, so a leftover directory would otherwise be treated as already obtained and never reinstalled). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Summary
state publish --buildprivate wheels were installed into a flatsite-packagesdirectory at the artifact deploy-tree root, which the deploy linked to<installdir>/site-packages. That is the wrong location — the wheel must land in the Python runtime's own site-packages, whose layout varies by platform and Python version:<installdir>/lib/pythonX.Y/site-packages(alsolib64,usr/libvariants)<installdir>/lib/site-packagesRoot cause
The install ran during the parallel unpack phase, before the Python runtime artifact was guaranteed to be on disk, so there was no reliable way to discover the real site-packages layout — the code hard-coded a flat
site-packages.What changed
The placement is now deferred until every artifact is unpacked, then the correct location is discovered on disk rather than guessed:
recordPrivateWheel) instead of installed inline.installPrivateWheelsruns.locateSitePackagesscans the unpacked non-private artifacts (Python runtime among them; private wheels skipped) for their site-packages: cheap globs first (lib/python*/site-packages,lib/site-packages,usr/lib/python*/site-packages,lib64/..., WindowsLib/site-packages), with a full-walk fallback for unexpected layouts. Returns the path relative to the install root, chosen deterministically (shortest, then lexicographic).installPrivateWheellays the wheel out at<artifactDir>/<InstallDir>/<relSitePackages>so the normal deploy merges it into<installdir>/<relSitePackages>alongside Python's own files, and pointsPYTHONPATHat the discovered path (keeping the:separator /inherit=falsedirectives from ENG-1793 to avoid the cross-artifact env-merge error).Because the wheel now lands via the standard deploy path, depot uninstall/eviction tracking works with no special-casing.
Testing
pkg/runtime/sitepackages_test.go) cover glob matching across Linux/macOS/Windows/usr/lib64layouts, the walk fallback, and cross-root dedup.pkg/runtimebuilds and tests pass.state installof a private-wheel project against a real ActiveState Python runtime, confirming files land inlib/pythonX.Y/site-packagesand import correctly — not yet run (no runtime available in the working environment).Jira: ENG-2084 (under epic ENG-1563)
🤖 Generated with Claude Code