Skip to content

Commit 2035cbb

Browse files
Fix Python crawler missing .egg-info installs (#447) (#452)
* Start fix for #447 Assisted-by: Claude Code:claude-opus-5-5 * Find Python packages installed as .egg-info pip before 23.1 installs an sdist without the wheel package via setup.py install, which records the install as <name>-<version>-pyX.Y.egg-info rather than .dist-info. That is the default state of a fresh venv on CPython 3.11 and older, and distro packages (Debian's python3-*) and distutils ship bare .egg-info files. The crawler only listed .dist-info, so these real, importable installs were reported "not installed" in agent mode, never got the hosted stale-install warning, and were missing from scan -g. The site-packages listing now also reads .egg-info directories (PKG-INFO, falling back to the directory name) and bare .egg-info files that carry metadata headers. Every consumer (scan, apply, rollback, the stale-install guards, -g) shares that listing. Fixes #447 Assisted-by: Claude Code:claude-opus-5-5 * Make the Hatch hosted pin-basis e2e hermetic The step asserts vex attests from the lock pin when nothing is installed, but it ran with no venv, so the Hatch project fell back to the global interpreters. On Ubuntu runners those carry apt's python3-six 1.16.0, the fixture's exact release, as an .egg-info install. Now that the crawler sees .egg-info, vex correctly refused to attest over that unpatched copy. Point VIRTUAL_ENV at an empty venv so the step tests what it says: a package that is not installed. Assisted-by: Claude Code:claude-opus-5-5 * Keep the pypi eject test off the system six The test vendors a hosted six 1.16.0 from a fresh checkout with nothing installed, but it ran with no venv, so the project fell back to the global interpreters. On Ubuntu those carry apt's python3-six 1.16.0 as an .egg-info install. Now that the crawler sees it, the variant probe compared it with the fixture's bytes and skipped it. Point VIRTUAL_ENV at an empty venv so nothing is installed. Assisted-by: Claude Code:claude-opus-5-5 * Use the Windows venv layout in the egg-info test The new agent-mode egg-info test planted its site-packages at lib/python3.11/site-packages, which is where a venv keeps them on Unix. On Windows a venv uses Lib\site-packages, so the crawler never found the package and the Windows test job failed. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d19bea9 commit 2035cbb

7 files changed

Lines changed: 300 additions & 43 deletions

File tree

‎crates/socket-patch-cli/src/commands/scan/hosted/python.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,28 @@ mod tests {
213213
assert!(out.stale_purls.is_empty());
214214
assert!(out.warnings.is_empty());
215215
}
216+
217+
/// A legacy `.egg-info` install (pip < 23.1 building an sdist without
218+
/// `wheel`) is a real copy pip keeps on `install -r`, so the hosted
219+
/// stale-install guard must judge it like a `.dist-info` one (#447).
220+
#[tokio::test]
221+
async fn egg_info_install_gets_the_stale_install_warning() {
222+
let tmp = tempfile::tempdir().unwrap();
223+
let site = tmp.path().join("site-packages");
224+
let egg = site.join("six-1.16.0-py3.11.egg-info");
225+
std::fs::create_dir_all(&egg).unwrap();
226+
std::fs::write(egg.join("PKG-INFO"), "Name: six\nVersion: 1.16.0\n").unwrap();
227+
std::fs::write(site.join("six.py"), b"upstream").unwrap();
228+
let common = crate::args::GlobalArgs {
229+
cwd: tmp.path().to_path_buf(),
230+
global_prefix: Some(site.clone()),
231+
..Default::default()
232+
};
233+
let purl = "pkg:pypi/six@1.16.0";
234+
let confirmed = vec![(purl.to_string(), "six-uuid".to_string())];
235+
let ledger = BTreeMap::from([("k".into(), record("six-uuid", "six.py", b"patched"))]);
236+
let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &ledger).await;
237+
assert_eq!(out.stale_purls, BTreeSet::from([purl.to_string()]));
238+
assert_eq!(out.warnings[0]["code"], "redirect_pypi_stale_install");
239+
}
216240
}

‎crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,15 +414,28 @@ fn flow(flavor: Flavor, mode: Mode) {
414414
);
415415

416416
if mode == Mode::Hosted {
417-
// Not installed as far as the crawler knows (no VIRTUAL_ENV, no
418-
// in-project venv): the declaration's sha256 pin is the basis.
417+
// Not installed as far as the crawler knows: the declaration's
418+
// sha256 pin is the basis. The run points VIRTUAL_ENV at an EMPTY
419+
// virtualenv. With no venv at all, a Python project falls back to
420+
// the global interpreters, and on Ubuntu runners those carry apt's
421+
// python3-six 1.16.0 (`six-1.16.0.egg-info` in
422+
// /usr/lib/python3/dist-packages) — a real unpatched copy that vex
423+
// rightly refuses to attest over.
424+
let empty_venv = tmp.path().join("empty-venv");
425+
std::fs::create_dir_all(empty_venv.join(if cfg!(windows) {
426+
"Lib/site-packages"
427+
} else {
428+
"lib/python3.11/site-packages"
429+
}))
430+
.unwrap();
419431
let patch_api = vex_e2e_common::PatchApi::start(vec![(
420432
mode.uuid().to_string(),
421433
view(mode.uuid(), &pristine, &patched),
422434
)]);
423435
let run = vex_e2e_common::VexRun {
424436
patch_server_url: Some(api.uri()),
425437
product: Some(PRODUCT.into()),
438+
envs: vec![("VIRTUAL_ENV".into(), empty_venv.into_os_string())],
426439
..vex_e2e_common::VexRun::online(&patch_api)
427440
};
428441
let out = vex_e2e_common::run_vex(&vex_e2e_common::binary(), &fresh, &run);

‎crates/socket-patch-cli/tests/in_process_pypi_apply.rs‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,3 +613,90 @@ async fn pypi_crawler_finds_real_installed_six() {
613613
"batch request did not include the discovered six PURL {purl}; bodies: {batch_bodies:?}"
614614
);
615615
}
616+
617+
// ---------------------------------------------------------------------------
618+
// Legacy `.egg-info` installs (#447)
619+
// ---------------------------------------------------------------------------
620+
621+
/// pip < 23.1 installing an sdist without `wheel` (the default state of a
622+
/// fresh venv on CPython <= 3.11) records the install as
623+
/// `<name>-<version>-pyX.Y.egg-info` instead of `.dist-info`. Agent mode
624+
/// must find and patch that copy instead of skipping it as "not installed".
625+
/// The layouts are planted directly so the test needs no interpreter.
626+
#[tokio::test]
627+
#[serial]
628+
async fn pypi_scan_sync_patches_egg_info_install() {
629+
// (a) the pip/setuptools directory form with `PKG-INFO`;
630+
// (b) the bare distutils / apt `.egg-info` FILE form.
631+
for bare_file in [false, true] {
632+
let tmp = tempfile::tempdir().expect("tempdir");
633+
let venv = tmp.path().join(".venv");
634+
let site = if cfg!(windows) {
635+
venv.join("Lib").join("site-packages")
636+
} else {
637+
venv.join("lib").join("python3.11").join("site-packages")
638+
};
639+
std::fs::create_dir_all(&site).unwrap();
640+
let pkg_info =
641+
format!("Metadata-Version: 1.2\nName: {PYPI_PACKAGE}\nVersion: {PYPI_VERSION}\n");
642+
if bare_file {
643+
std::fs::write(
644+
site.join(format!("{PYPI_PACKAGE}-{PYPI_VERSION}.egg-info")),
645+
&pkg_info,
646+
)
647+
.unwrap();
648+
} else {
649+
let egg = site.join(format!("{PYPI_PACKAGE}-{PYPI_VERSION}-py3.11.egg-info"));
650+
std::fs::create_dir_all(&egg).unwrap();
651+
std::fs::write(egg.join("PKG-INFO"), &pkg_info).unwrap();
652+
}
653+
let six_path = site.join("six.py");
654+
let original = b"# six from an sdist\n".to_vec();
655+
std::fs::write(&six_path, &original).unwrap();
656+
let mut patched = original.clone();
657+
patched.extend_from_slice(b"# SOCKET-PATCH-E2E-MARKER\n");
658+
659+
let server = MockServer::start().await;
660+
setup_pypi_apply_mock(
661+
&server,
662+
&git_sha256(&original),
663+
&git_sha256(&patched),
664+
&patched,
665+
)
666+
.await;
667+
668+
std::env::remove_var("VIRTUAL_ENV");
669+
let code = scan_run(ScanArgs {
670+
socket_yml: Default::default(),
671+
paths: Vec::new(),
672+
packages: Vec::new(),
673+
common: socket_patch_cli::args::GlobalArgs {
674+
cwd: tmp.path().to_path_buf(),
675+
org: Some(ORG.to_string()),
676+
json: true,
677+
yes: true,
678+
api_url: Some(server.uri()),
679+
api_token: Some("fake".to_string()),
680+
ecosystems: Some(vec!["pypi".to_string()]),
681+
download_mode: "diff".to_string(),
682+
..socket_patch_cli::args::GlobalArgs::default()
683+
},
684+
batch_size: Some(100),
685+
apply: false,
686+
prune: false,
687+
sync: true,
688+
vendor: false,
689+
mode: None,
690+
all_releases: false,
691+
vex: Default::default(),
692+
rollout: Default::default(),
693+
})
694+
.await;
695+
assert_eq!(code, 0, "bare_file={bare_file}: scan --sync should succeed");
696+
assert_eq!(
697+
std::fs::read(&six_path).unwrap(),
698+
patched,
699+
"bare_file={bare_file}: the egg-info install must be patched"
700+
);
701+
}
702+
}

‎crates/socket-patch-cli/tests/in_process_python_envs.rs‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -456,38 +456,41 @@ async fn pypi_egg_info_layout_handled() {
456456
let tmp = tempfile::tempdir().unwrap();
457457
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
458458
std::fs::create_dir_all(&site).unwrap();
459-
// egg-info — older format. The crawler only recognizes `.dist-info`
460-
// dirs, so the egg-info package is NOT discovered. Pin that current
461-
// contract: scan exits cleanly (like the empty-site-packages case) and
462-
// ships no PURL for it. If egg-info support is added later this fails
463-
// loudly and the assertion should be flipped to `assert_discovered`.
464-
let egg = site.join("legacy_pkg-1.0.0.egg-info");
459+
// egg-info — the legacy layout pip < 23.1 writes for an sdist built
460+
// without `wheel` (and distutils / distro packages write as a bare
461+
// FILE). It is a real, importable install, so the crawler must report
462+
// it (#447). Three shapes: a `-pyX.Y`-suffixed directory with
463+
// `PKG-INFO`, a bare `.egg-info` file, and a directory whose PKG-INFO
464+
// is missing (the filename carries the identity).
465+
let egg = site.join("legacy_pkg-1.0.0-py3.11.egg-info");
465466
std::fs::create_dir_all(&egg).unwrap();
466467
std::fs::write(
467468
egg.join("PKG-INFO"),
468469
"Metadata-Version: 1.0\nName: legacy_pkg\nVersion: 1.0.0\n",
469470
)
470471
.unwrap();
472+
std::fs::write(
473+
site.join("distro_pkg-2.1.egg-info"),
474+
"Metadata-Version: 1.1\nName: distro-pkg\nVersion: 2.1\n",
475+
)
476+
.unwrap();
477+
std::fs::create_dir_all(site.join("bare_dir_pkg-0.3-py3.11.egg-info")).unwrap();
471478

472-
// Positive control in the SAME site-packages: a real `.dist-info`
473-
// package the crawler must discover. Without it, the negative
474-
// assertions below are vacuous — they pass even if the crawler never
475-
// walked this directory at all (e.g. a regression that stops probing
476-
// `.venv`). The control proves the dir WAS walked, so a missing
477-
// `legacy_pkg` means egg-info was specifically not recognized, not that
478-
// scanning silently no-op'd.
479+
// A `.dist-info` sibling in the SAME site-packages: both layouts are
480+
// listed side by side.
479481
write_dist_info(&site, "modern_sibling", "2.0.0");
480482

481483
let server = MockServer::start().await;
482484
mock_batch_empty(&server).await;
483485
let res = scan_scrubbed(default_args(tmp.path(), server.uri())).await;
484-
assert_eq!(res, 0, "egg-info layout must scan cleanly without crashing");
486+
assert_eq!(res, 0, "egg-info layout must scan cleanly");
485487
let bodies = batch_bodies(&server).await;
486-
// Control: proves the crawler genuinely walked this site-packages dir.
487488
assert_discovered(&bodies, "pkg:pypi/modern-sibling@2.0.0");
488-
// Not discovered today; neither the canonical nor raw name may appear.
489-
assert_not_discovered(&bodies, "pkg:pypi/legacy-pkg@1.0.0");
490-
assert_not_discovered(&bodies, "pkg:pypi/legacy_pkg@1.0.0");
489+
assert_discovered(&bodies, "pkg:pypi/legacy-pkg@1.0.0");
490+
assert_discovered(&bodies, "pkg:pypi/distro-pkg@2.1");
491+
assert_discovered(&bodies, "pkg:pypi/bare-dir-pkg@0.3");
492+
// The `-pyX.Y` suffix is not part of the version.
493+
assert_not_discovered(&bodies, "py3.11");
491494
}
492495

493496
// ---------------------------------------------------------------------------

‎crates/socket-patch-cli/tests/vendor_eject_fresh_checkout.rs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,11 +370,25 @@ async fn pypi_eject_needs_no_virtualenv() {
370370
)
371371
.unwrap();
372372

373+
// Nothing installed for the project: VIRTUAL_ENV names an EMPTY
374+
// virtualenv. With no venv at all, a Python project falls back to the
375+
// global interpreters, and on Ubuntu those carry apt's python3-six
376+
// 1.16.0 (`six-1.16.0.egg-info`), whose bytes are not this fixture's.
377+
let empty_venv = tmp.path().join("empty-venv");
378+
std::fs::create_dir_all(empty_venv.join(if cfg!(windows) {
379+
"Lib/site-packages"
380+
} else {
381+
"lib/python3.11/site-packages"
382+
}))
383+
.unwrap();
373384
let (code, env) = run_json_with(
374385
&root,
375386
&server,
376387
&["vendor"],
377-
&[("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri()))],
388+
&[
389+
("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri())),
390+
("VIRTUAL_ENV", empty_venv.display().to_string()),
391+
],
378392
);
379393
assert_eq!(code, 0, "a fresh hosted pypi checkout ejects: {env:#}");
380394
assert!(applied(&env, PURL), "{env:#}");

0 commit comments

Comments
 (0)