Skip to content

Commit ade011e

Browse files
Preserve Python patches across uv lockfiles (#238)
* 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 * Record uv release-family compatibility checks Publish real CLI and uv installation evidence across fourteen releases, including frozen, locked, and ordinary installs in both patch modes. Record unavailable formats and legacy boundaries separately from passes. Assisted-by: Codex:gpt-6-astra
1 parent 90e1230 commit ade011e

22 files changed

Lines changed: 9510 additions & 461 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ 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).is_ok_and(|paths| !paths.is_empty())
570570
}
571571

572572
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)