Skip to content

Commit e11bd41

Browse files
committed
Preserve patches across uv lockfile formats
Keep hosted and vendored Python patches installed across native uv, PEP 751, requirements, and script lockfiles. Preserve artifact hashes, paired metadata, and conditional versions; refuse ambiguous rewrites. Assisted-by: Codex:gpt-6-astra
1 parent a0b0480 commit e11bd41

17 files changed

Lines changed: 4727 additions & 453 deletions

File tree

‎crates/socket-patch-cli/src/commands/repair_vendor.rs‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,24 @@ const WIRING_FILES: &[&str] = &[
128128
pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> {
129129
let mut seen: HashSet<(String, String)> = HashSet::new();
130130
let mut out = Vec::new();
131-
for file in WIRING_FILES {
131+
let mut files: Vec<String> = WIRING_FILES
132+
.iter()
133+
.map(|file| (*file).to_string())
134+
.collect();
135+
if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) {
136+
for path in paths {
137+
if let Some(script) = path
138+
.strip_suffix(".py.lock")
139+
.map(|prefix| format!("{prefix}.py"))
140+
{
141+
files.push(script);
142+
}
143+
files.push(path);
144+
}
145+
}
146+
files.sort();
147+
files.dedup();
148+
for file in files {
132149
let Ok(text) = tokio::fs::read_to_string(project_root.join(file)).await else {
133150
continue;
134151
};
@@ -202,6 +219,26 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V
202219
/// routes to the package-lock backend, whose guard also fails closed on
203220
/// unwired entries.
204221
async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option<String> {
222+
if eco == "pypi" {
223+
let needle = format!(".socket/vendor/pypi/{uuid}/");
224+
for file in socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()? {
225+
if tokio::fs::read_to_string(project_root.join(&file))
226+
.await
227+
.ok()
228+
.is_some_and(|text| text.contains(&needle))
229+
{
230+
return Some(
231+
if file == "uv.lock" {
232+
"uv"
233+
} else {
234+
"python-lock"
235+
}
236+
.to_string(),
237+
);
238+
}
239+
}
240+
return None;
241+
}
205242
if eco != "npm" {
206243
return None;
207244
}
@@ -1409,6 +1446,32 @@ fn npm_coords(base_purl: &str) -> Option<(String, String)> {
14091446
mod tests {
14101447
use super::*;
14111448

1449+
#[tokio::test]
1450+
async fn scan_recovers_script_and_pep751_vendor_references() {
1451+
let tmp = tempfile::tempdir().unwrap();
1452+
let uuid = "11111111-1111-4111-8111-111111111111";
1453+
let path = format!(".socket/vendor/pypi/{uuid}/requests-2.28.1-py3-none-any.whl");
1454+
for file in ["example.py.lock", "pylock.dev.toml"] {
1455+
tokio::fs::write(
1456+
tmp.path().join(file),
1457+
format!("archive = {{ path = '{path}' }}"),
1458+
)
1459+
.await
1460+
.unwrap();
1461+
}
1462+
let references = scan_vendor_references(tmp.path()).await;
1463+
assert_eq!(
1464+
references,
1465+
vec![("pypi".to_string(), uuid.to_string(), path)]
1466+
);
1467+
assert_eq!(
1468+
detect_reference_flavor(tmp.path(), "pypi", uuid)
1469+
.await
1470+
.as_deref(),
1471+
Some("python-lock")
1472+
);
1473+
}
1474+
14121475
/// pnpm writes vendored paths in THREE spellings — override values,
14131476
/// `tarball:` fields, and snapshot KEYS with a trailing colon. The
14141477
/// scanner must yield the clean relpath whichever form it meets first.

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

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[
3232
"bun.lock",
3333
"requirements.txt",
3434
"uv.lock",
35+
"pyproject.toml",
3536
"Cargo.toml",
3637
"Cargo.lock",
3738
".cargo/config.toml",
@@ -785,7 +786,7 @@ pub(crate) async fn run_redirect_selected(
785786
) -> i32 {
786787
use socket_patch_core::manifest::schema::PatchRecord;
787788
use socket_patch_core::patch::redirect::{
788-
rewrite_registry_redirect, DepOverride, RedirectState,
789+
rewrite_registry_redirect_with_python_metadata, DepOverride, RedirectState,
789790
};
790791

791792
let mut skipped: Vec<serde_json::Value> = Vec::new();
@@ -1260,6 +1261,22 @@ pub(crate) async fn run_redirect_selected(
12601261
}
12611262
}
12621263

1264+
if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) {
1265+
for path in paths {
1266+
if let Some(script_path) = path
1267+
.strip_suffix(".py.lock")
1268+
.map(|prefix| format!("{prefix}.py"))
1269+
{
1270+
if let Ok(content) = std::fs::read_to_string(common.cwd.join(&script_path)) {
1271+
files.insert(script_path, content);
1272+
}
1273+
}
1274+
if let Ok(content) = std::fs::read_to_string(common.cwd.join(&path)) {
1275+
files.insert(path, content);
1276+
}
1277+
}
1278+
}
1279+
12631280
// Rush monorepos have no root package.json/lock pair: the single pnpm
12641281
// source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and
12651282
// (when subspaces are enabled) one lock per subspace under
@@ -1299,7 +1316,63 @@ pub(crate) async fn run_redirect_selected(
12991316
// `mut`: the pnpm trustLockfile auto-config below may fold a
13001317
// pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so
13011318
// it rides the same atomic-write / ledger-first machinery as the locks.
1302-
let mut rewrite = rewrite_registry_redirect(&files, &overrides);
1319+
let mut python_metadata = std::collections::BTreeMap::new();
1320+
let mut unavailable_python_artifacts = std::collections::BTreeSet::new();
1321+
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
1322+
let Some(sha256) = dep.integrity.sha256.as_deref() else {
1323+
continue;
1324+
};
1325+
if !dep
1326+
.artifact_url
1327+
.split(['?', '#'])
1328+
.next()
1329+
.is_some_and(|path| path.ends_with(".whl"))
1330+
{
1331+
continue;
1332+
}
1333+
let native_target = files
1334+
.iter()
1335+
.filter(|(path, _)| *path == "uv.lock" || path.ends_with(".py.lock"))
1336+
.any(|(_, text)| {
1337+
socket_patch_core::utils::python_lock::rewrite_python_lock(
1338+
text,
1339+
&dep.name,
1340+
&dep.version,
1341+
socket_patch_core::utils::python_lock::ArtifactSource::Url(&dep.artifact_url),
1342+
sha256,
1343+
)
1344+
.ok()
1345+
.flatten()
1346+
.is_some()
1347+
});
1348+
if !native_target {
1349+
continue;
1350+
}
1351+
match socket_patch_core::vendor::pypi::fetch_hosted_wheel_metadata(
1352+
api_client,
1353+
&dep.artifact_url,
1354+
sha256,
1355+
)
1356+
.await
1357+
{
1358+
Ok(Some(metadata)) => {
1359+
python_metadata.insert(dep.artifact_url.clone(), metadata);
1360+
}
1361+
Ok(None) => {}
1362+
Err(detail) => {
1363+
unavailable_python_artifacts.insert(dep.artifact_url.clone());
1364+
skipped.push(serde_json::json!({
1365+
"purl": format!("pkg:pypi/{}@{}", dep.name, dep.version),
1366+
"uuid": dep.patch_uuid,
1367+
"reason": "python_metadata_unavailable",
1368+
"detail": detail.replace(&dep.artifact_url, "<hosted artifact>"),
1369+
}));
1370+
}
1371+
}
1372+
}
1373+
overrides.retain(|dep| !unavailable_python_artifacts.contains(&dep.artifact_url));
1374+
let mut rewrite =
1375+
rewrite_registry_redirect_with_python_metadata(&files, &overrides, &python_metadata);
13031376

13041377
// The lockb→text migration is only KEPT when the rewrite actually landed
13051378
// in the migrated bun.lock. Otherwise nothing was redirected there and the

‎crates/socket-patch-core/src/crawlers/python_crawler.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,8 @@ pub async fn is_python_project(cwd: &Path) -> bool {
566566
return true;
567567
}
568568
}
569-
false
569+
crate::utils::python_lock::python_lock_paths(cwd)
570+
.is_ok_and(|paths| !paths.is_empty())
570571
}
571572

572573
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)