Skip to content

Commit dac7189

Browse files
Pick pure pypi lock wheels through one shared portability rule (#1150) (#1151)
* Start refactor for #1150 Assisted-by: Claude Code:claude-opus-5-5 * Pick pure lock wheels through one shared rule Lock inventory picked a pypi package's "pure" wheel with its own `-none-any.whl` suffix check, so a uv.lock or poetry.lock whose only pinned wheel is cp311-, pp310- or py2-none-any was resolved as fetchable while vendored mode, the hosted redirect and ledger recovery call the same wheel platform-locked (#1048). The uv/PEP 751 inventory and ledger recovery now share one `portable_wheel_artifact` pick, and the poetry reader classifies its file names through `is_portable_wheel_url`. Both suffix checks and recovery's copy of the pick are gone. A table test runs every former caller over the wheel shapes where the rules differed. Fixes #1150 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fed701 commit dac7189

3 files changed

Lines changed: 131 additions & 32 deletions

File tree

‎crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs‎

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::utils::python_lock::{lock_package_collection, package_artifacts, UvSo
1414
use crate::utils::requirements::archive_filename_coords;
1515

1616
use crate::utils::digest::{sha256_hex, sha256_prefixed};
17+
use crate::vendor::pypi_distribution::is_portable_wheel_url;
1718

1819
use super::view::ProjectView;
1920
use super::{dedup_prefer_integrity, http_url, LockIntegrity, LockfileEntry, SourceKind};
@@ -205,7 +206,7 @@ pub(crate) fn replaceable_hosted_pin(
205206
}
206207

207208
/// Inventory the pypi lock the project carries. Fetchable resolution
208-
/// (URL + sha256 of a pure `-none-any` wheel) comes from `uv.lock` and
209+
/// (URL + sha256 of a portable wheel) comes from `uv.lock` and
209210
/// PEP 751 / PEP 723 script locks; `poetry.lock` entries carry the pure
210211
/// wheel's sha256 when the lock lists one (resolved through PyPI's JSON API
211212
/// at fetch time), else stay discovery-only; exact `==` `requirements.txt`
@@ -301,16 +302,21 @@ pub(super) async fn inventory_pypi_locks_raw_in(
301302
found.then_some(out)
302303
}
303304

304-
/// The first fetchable pure-Python wheel of a lock package — `archive`,
305-
/// then `wheels[]` / `wheel` (read with the shared lock model,
306-
/// [`crate::utils::python_lock::package_artifacts`]): an http(s) url ending
307-
/// `-none-any.whl` with a sha256 pin, as `(url, sha256)`.
308-
fn python_package_archive(package: &dyn TableLike) -> Option<(String, String)> {
309-
package_artifacts(package, &["archive", "wheels", "wheel"])
305+
/// The first hash-pinned, portable, http(s) wheel among a lock package's
306+
/// `keys` artifacts (read with the shared lock model, [`package_artifacts`]),
307+
/// as `(url, sha256)`. Each url is paired with **that artifact's** hash, and
308+
/// portability is the shared [`is_portable_wheel_url`] rule vendored and
309+
/// hosted mode use. The one pure-wheel pick of the uv / PEP 751 inventory
310+
/// and of ledger recovery.
311+
pub(super) fn portable_wheel_artifact(
312+
package: &dyn TableLike,
313+
keys: &[&str],
314+
) -> Option<(String, String)> {
315+
package_artifacts(package, keys)
310316
.into_iter()
311317
.find_map(|artifact| {
312318
let url = artifact.url?;
313-
if !url.split(['?', '#']).next()?.ends_with("-none-any.whl") {
319+
if !is_portable_wheel_url(url) {
314320
return None;
315321
}
316322
Some((http_url(url)?, artifact.sha256?))
@@ -358,10 +364,11 @@ pub(super) fn python_lock_inventory(text: &str) -> Option<Vec<LockfileEntry>> {
358364
if !remote {
359365
continue;
360366
}
361-
let (resolved, integrity) = match python_package_archive(package) {
362-
Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)),
363-
None => (None, LockIntegrity::None),
364-
};
367+
let (resolved, integrity) =
368+
match portable_wheel_artifact(package, &["archive", "wheels", "wheel"]) {
369+
Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)),
370+
None => (None, LockIntegrity::None),
371+
};
365372
out.push(LockfileEntry {
366373
ecosystem: "pypi",
367374
source_kind: SourceKind::Unspecified,
@@ -377,7 +384,7 @@ pub(super) fn python_lock_inventory(text: &str) -> Option<Vec<LockfileEntry>> {
377384

378385
/// poetry.lock: `[[package]]` tables with `name`/`version`. The lock records
379386
/// file hashes but no URLs and no platform choice, so an entry carries the
380-
/// sha256 of the package's pure-Python (`-none-any.whl`) wheel when the lock
387+
/// sha256 of the package's portable wheel ([`is_portable_wheel_url`]) when the lock
381388
/// lists one — its own `files = [...]` (lock 2.x) or its `[metadata.files]`
382389
/// entry (lock 1.0/1.1), read through the shared poetry lock helpers — and
383390
/// the pypi fetcher then resolves the matching file through PyPI's JSON
@@ -390,7 +397,7 @@ async fn inventory_poetry_lock(view: &ProjectView<'_>) -> Option<Vec<LockfileEnt
390397
let pure_wheel_sha = |files: Vec<&dyn TableLike>| {
391398
files.into_iter().find_map(|entry| {
392399
let file = entry.get("file")?.as_str()?;
393-
if !file.ends_with("-none-any.whl") {
400+
if !is_portable_wheel_url(file) {
394401
return None;
395402
}
396403
sha256_prefixed(entry.get("hash")?.as_str()?)
@@ -753,3 +760,7 @@ async fn requirements_tree(view: &ProjectView<'_>) -> Option<Vec<String>> {
753760
}
754761
Some(files)
755762
}
763+
764+
#[cfg(test)]
765+
#[path = "pypi_wheel_tests.rs"]
766+
mod wheel_tests;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//! Every lock reader that picks "the pure wheel" of a pypi package (uv /
2+
//! PEP 751 inventory, poetry inventory, ledger recovery) goes through the
3+
//! one shared portability rule, so they agree with vendored and hosted
4+
//! mode on the wheels where the old `-none-any.whl` suffix check did not
5+
//! (#1048: interpreter-bound and Python-2-only tags).
6+
7+
use super::*;
8+
use crate::vendor::lock_inventory::recover::pure_wheel_from_uv_unit;
9+
use crate::vendor::pypi_distribution::wheel_platform_from_filename;
10+
11+
const SHA: &str = "abababababababababababababababababababababababababababababababab";
12+
13+
/// `(wheel filename, portable)`: the cases where a suffix check and the
14+
/// shared tag classifier used to differ, plus the ones they always agreed on.
15+
const WHEELS: &[(&str, bool)] = &[
16+
("six-1.16.0-py2.py3-none-any.whl", true),
17+
("six-1.16.0-py3-none-any.whl", true),
18+
("six-1.16.0-py38-none-any.whl", true),
19+
("six-1.16.0-cp311-none-any.whl", false),
20+
("six-1.16.0-pp310-none-any.whl", false),
21+
("six-1.16.0-py2-none-any.whl", false),
22+
("six-1.16.0-cp312-abi3-any.whl", false),
23+
("six-1.16.0-cp312-cp312-manylinux_2_17_x86_64.whl", false),
24+
];
25+
26+
fn uv_unit(file: &str) -> String {
27+
format!(
28+
"[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\
29+
source = {{ registry = \"https://pypi.org/simple\" }}\n\
30+
wheels = [{{ url = \"https://files.pythonhosted.org/packages/aa/{file}\", hash = \"sha256:{SHA}\" }}]\n"
31+
)
32+
}
33+
34+
#[test]
35+
fn the_table_matches_the_shared_classifier() {
36+
for &(file, portable) in WHEELS {
37+
assert_eq!(!wheel_platform_from_filename(file).0, portable, "{file}");
38+
}
39+
}
40+
41+
#[test]
42+
fn uv_inventory_and_ledger_recovery_pick_the_same_wheels() {
43+
for &(file, portable) in WHEELS {
44+
let unit = uv_unit(file);
45+
let entries = python_lock_inventory(&format!("version = 1\n\n{unit}")).unwrap();
46+
let [six] = entries.as_slice() else {
47+
panic!("{file}: {entries:?}")
48+
};
49+
let expected =
50+
portable.then(|| format!("https://files.pythonhosted.org/packages/aa/{file}"));
51+
assert_eq!(six.resolved, expected, "inventory, {file}");
52+
assert_eq!(
53+
six.integrity,
54+
if portable {
55+
LockIntegrity::Sha256Hex(SHA.into())
56+
} else {
57+
LockIntegrity::None
58+
},
59+
"inventory, {file}"
60+
);
61+
assert_eq!(
62+
pure_wheel_from_uv_unit(&unit).map(|(url, _)| url),
63+
expected,
64+
"recovery, {file}"
65+
);
66+
}
67+
}
68+
69+
#[test]
70+
fn uv_inventory_reads_the_wheel_name_before_a_query_or_fragment() {
71+
for suffix in ["?download=1", "#sha256=00"] {
72+
let unit = uv_unit(&format!("six-1.16.0-py3-none-any.whl{suffix}"));
73+
let entries = python_lock_inventory(&format!("version = 1\n\n{unit}")).unwrap();
74+
assert!(entries[0].resolved.is_some(), "{suffix}: {entries:?}");
75+
assert!(pure_wheel_from_uv_unit(&unit).is_some(), "{suffix}");
76+
}
77+
}
78+
79+
#[tokio::test]
80+
async fn poetry_inventory_pins_only_a_portable_wheel() {
81+
for &(file, portable) in WHEELS {
82+
let lock = format!(
83+
"[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{file = \"{file}\", hash = \"sha256:{SHA}\"}},\n]\n\n[metadata]\nlock-version = \"2.1\"\n"
84+
);
85+
let tmp = tempfile::tempdir().unwrap();
86+
tokio::fs::write(tmp.path().join("poetry.lock"), lock)
87+
.await
88+
.unwrap();
89+
let entries = inventory_pypi_locks(tmp.path()).await.unwrap();
90+
let six = entries.iter().find(|e| e.name == "six").unwrap();
91+
assert_eq!(
92+
six.integrity,
93+
if portable {
94+
LockIntegrity::Sha256Hex(SHA.into())
95+
} else {
96+
LockIntegrity::None
97+
},
98+
"poetry, {file}"
99+
);
100+
}
101+
}

‎crates/socket-patch-core/src/vendor/lock_inventory/recover.rs‎

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name;
1010
use crate::formats::composer::ComposerLockPackage;
1111
use crate::utils::digest::{is_hex, is_sri_pin, sha256_hex};
1212
use crate::utils::purl::percent_decode_purl_component;
13-
use crate::utils::python_lock::package_artifacts;
14-
use crate::vendor::pypi_distribution::is_portable_wheel_url;
1513

1614
use super::gem::{gem_download_url, gem_remotes};
17-
use super::pypi::python_lock_inventory;
15+
use super::pypi::{portable_wheel_artifact, python_lock_inventory};
1816
use super::{http_url, LockIntegrity, LockfileEntry, SourceKind};
1917

2018
// ──────────────── registry-fragment recovery from the ledger ────────────────
@@ -456,26 +454,15 @@ pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option<String> {
456454

457455
/// The first hash-pinned, portable, http(s) wheel of a recorded uv / pdm
458456
/// `[[package]]` unit (or a bare artifact-array fragment), as
459-
/// `(url, sha256)`. The unit is read as TOML through the shared lock model
460-
/// ([`package_artifacts`]), so each artifact's url is paired with **that
461-
/// artifact's** hash (#1079), and portability is the shared
462-
/// [`is_portable_wheel_url`] rule vendored and hosted mode use. Anything
463-
/// unparseable, unpinned or platform-bound yields `None`: fail-closed,
464-
/// never a guessed pairing.
457+
/// `(url, sha256)`, through the inventory's own pick
458+
/// ([`portable_wheel_artifact`], #1079). Anything unparseable, unpinned or
459+
/// platform-bound yields `None`: fail-closed, never a guessed pairing.
465460
pub(super) fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> {
466461
let document: DocumentMut = unit.parse().ok()?;
467462
let root = document.as_table();
468463
let package: &dyn TableLike = match root.get("package").and_then(Item::as_array_of_tables) {
469464
Some(units) => units.get(0)?,
470465
None => root,
471466
};
472-
package_artifacts(package, &["archive", "wheels", "wheel", "files"])
473-
.into_iter()
474-
.find_map(|artifact| {
475-
let url = artifact.url?;
476-
if !is_portable_wheel_url(url) {
477-
return None;
478-
}
479-
Some((http_url(url)?, artifact.sha256?))
480-
})
467+
portable_wheel_artifact(package, &["archive", "wheels", "wheel", "files"])
481468
}

0 commit comments

Comments
 (0)