diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 2301c85f..3445d40b 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -128,7 +128,24 @@ const WIRING_FILES: &[&str] = &[ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); - for file in WIRING_FILES { + let mut files: Vec = WIRING_FILES + .iter() + .map(|file| (*file).to_string()) + .collect(); + if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) { + for path in paths { + if let Some(script) = path + .strip_suffix(".py.lock") + .map(|prefix| format!("{prefix}.py")) + { + files.push(script); + } + files.push(path); + } + } + files.sort(); + files.dedup(); + for file in files { let Ok(text) = tokio::fs::read_to_string(project_root.join(file)).await else { continue; }; @@ -202,6 +219,26 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V /// routes to the package-lock backend, whose guard also fails closed on /// unwired entries. async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option { + if eco == "pypi" { + let needle = format!(".socket/vendor/pypi/{uuid}/"); + for file in socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()? { + if tokio::fs::read_to_string(project_root.join(&file)) + .await + .ok() + .is_some_and(|text| text.contains(&needle)) + { + return Some( + if file == "uv.lock" { + "uv" + } else { + "python-lock" + } + .to_string(), + ); + } + } + return None; + } if eco != "npm" { return None; } @@ -1409,6 +1446,32 @@ fn npm_coords(base_purl: &str) -> Option<(String, String)> { mod tests { use super::*; + #[tokio::test] + async fn scan_recovers_script_and_pep751_vendor_references() { + let tmp = tempfile::tempdir().unwrap(); + let uuid = "11111111-1111-4111-8111-111111111111"; + let path = format!(".socket/vendor/pypi/{uuid}/requests-2.28.1-py3-none-any.whl"); + for file in ["example.py.lock", "pylock.dev.toml"] { + tokio::fs::write( + tmp.path().join(file), + format!("archive = {{ path = '{path}' }}"), + ) + .await + .unwrap(); + } + let references = scan_vendor_references(tmp.path()).await; + assert_eq!( + references, + vec![("pypi".to_string(), uuid.to_string(), path)] + ); + assert_eq!( + detect_reference_flavor(tmp.path(), "pypi", uuid) + .await + .as_deref(), + Some("python-lock") + ); + } + /// pnpm writes vendored paths in THREE spellings — override values, /// `tarball:` fields, and snapshot KEYS with a trailing colon. The /// scanner must yield the clean relpath whichever form it meets first. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 55bd8950..3a0028ef 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -32,6 +32,7 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "bun.lock", "requirements.txt", "uv.lock", + "pyproject.toml", "Cargo.toml", "Cargo.lock", ".cargo/config.toml", @@ -785,7 +786,7 @@ pub(crate) async fn run_redirect_selected( ) -> i32 { use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect, DepOverride, RedirectState, + rewrite_registry_redirect_with_python_metadata, DepOverride, RedirectState, }; let mut skipped: Vec = Vec::new(); @@ -1260,6 +1261,22 @@ pub(crate) async fn run_redirect_selected( } } + if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { + for path in paths { + if let Some(script_path) = path + .strip_suffix(".py.lock") + .map(|prefix| format!("{prefix}.py")) + { + if let Ok(content) = std::fs::read_to_string(common.cwd.join(&script_path)) { + files.insert(script_path, content); + } + } + if let Ok(content) = std::fs::read_to_string(common.cwd.join(&path)) { + files.insert(path, content); + } + } + } + // Rush monorepos have no root package.json/lock pair: the single pnpm // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and // (when subspaces are enabled) one lock per subspace under @@ -1299,7 +1316,63 @@ pub(crate) async fn run_redirect_selected( // `mut`: the pnpm trustLockfile auto-config below may fold a // pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so // it rides the same atomic-write / ledger-first machinery as the locks. - let mut rewrite = rewrite_registry_redirect(&files, &overrides); + let mut python_metadata = std::collections::BTreeMap::new(); + let mut unavailable_python_artifacts = std::collections::BTreeSet::new(); + for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + let Some(sha256) = dep.integrity.sha256.as_deref() else { + continue; + }; + if !dep + .artifact_url + .split(['?', '#']) + .next() + .is_some_and(|path| path.ends_with(".whl")) + { + continue; + } + let native_target = files + .iter() + .filter(|(path, _)| *path == "uv.lock" || path.ends_with(".py.lock")) + .any(|(_, text)| { + socket_patch_core::utils::python_lock::rewrite_python_lock( + text, + &dep.name, + &dep.version, + socket_patch_core::utils::python_lock::ArtifactSource::Url(&dep.artifact_url), + sha256, + ) + .ok() + .flatten() + .is_some() + }); + if !native_target { + continue; + } + match socket_patch_core::vendor::pypi::fetch_hosted_wheel_metadata( + api_client, + &dep.artifact_url, + sha256, + ) + .await + { + Ok(Some(metadata)) => { + python_metadata.insert(dep.artifact_url.clone(), metadata); + } + Ok(None) => {} + Err(detail) => { + unavailable_python_artifacts.insert(dep.artifact_url.clone()); + skipped.push(serde_json::json!({ + "purl": format!("pkg:pypi/{}@{}", dep.name, dep.version), + "uuid": dep.patch_uuid, + "reason": "python_metadata_unavailable", + "detail": detail.replace(&dep.artifact_url, ""), + })); + } + } + } + overrides.retain(|dep| !unavailable_python_artifacts.contains(&dep.artifact_url)); + let mut rewrite = + rewrite_registry_redirect_with_python_metadata(&files, &overrides, &python_metadata); // The lockb→text migration is only KEPT when the rewrite actually landed // in the migrated bun.lock. Otherwise nothing was redirected there and the diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index c787f31c..88384486 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -566,7 +566,7 @@ pub async fn is_python_project(cwd: &Path) -> bool { return true; } } - false + crate::utils::python_lock::python_lock_paths(cwd).is_ok_and(|paths| !paths.is_empty()) } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d0389daa..d4c09aea 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -9,8 +9,8 @@ //! PR flow) and by this CLI, so a customer gets the same result whether Socket //! opens the PR or they run `socket-patch scan --redirect` locally. //! -//! Non-JSON formats are edited SURGICALLY (regex/string) to stay byte-stable -//! and reproducible across languages; JSON uses `serde_json` with +//! Python lockfiles use TOML-aware edits to keep source identities consistent. +//! Other non-JSON formats use targeted text edits; JSON uses `serde_json` with //! `preserve_order` (2-space pretty + trailing newline) to match the TS //! `JSON.stringify(v, null, 2) + '\n'`. @@ -21,11 +21,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::crawlers::composer_crawler::normalize_version; -use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; mod replay; +mod requirements; mod state; mod takeover; pub use replay::{revert_remaining_redirect_edits, GroupRefusal, ReplayOutcome}; @@ -194,6 +194,14 @@ fn serialize_json(value: &Value) -> String { pub fn rewrite_registry_redirect( files: &BTreeMap, overrides: &[DepOverride], +) -> RewriteResult { + rewrite_registry_redirect_with_python_metadata(files, overrides, &BTreeMap::new()) +} + +pub fn rewrite_registry_redirect_with_python_metadata( + files: &BTreeMap, + overrides: &[DepOverride], + python_metadata: &BTreeMap, ) -> RewriteResult { let mut result = RewriteResult::default(); rewrite_npm_lock(files, overrides, &mut result); @@ -202,7 +210,7 @@ pub fn rewrite_registry_redirect( rewrite_yarn_berry(files, overrides, &mut result); rewrite_bun_lock(files, overrides, &mut result); rewrite_pypi_requirements(files, overrides, &mut result); - rewrite_uv_lock(files, overrides, &mut result); + rewrite_uv_lock(files, overrides, python_metadata, &mut result); rewrite_cargo(files, overrides, &mut result); rewrite_composer_lock(files, overrides, &mut result); rewrite_nuget(files, overrides, &mut result); @@ -498,110 +506,7 @@ fn rewrite_pypi_requirements( overrides: &[DepOverride], result: &mut RewriteResult, ) { - let pypi: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "pypi").collect(); - if pypi.is_empty() || !files.contains_key("requirements.txt") { - return; - } - let name_re = Regex::new(r"^([A-Za-z0-9._-]+)\s*(?:[=<>~!]=?|@|;|\s|$)") - .expect("static requirements-name regex is valid"); - let comment_re = Regex::new(r"\s+#.*$").expect("static requirements-comment regex is valid"); - let mut lines: Vec = files["requirements.txt"] - .split('\n') - .map(|s| s.to_string()) - .collect(); - let mut changed = false; - for dep in &pypi { - let Some(sha256) = dep.integrity.sha256.clone() else { - result.warnings.push(RewriteWarning { - code: "redirect_requirements_missing_sha256".into(), - detail: format!("{} has no sha256 integrity", dep.name), - }); - continue; - }; - let target = canonicalize_pypi_name(&dep.name); - let mut matched_any = false; - for raw in lines.iter_mut() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with('-') { - continue; - } - let Some(caps) = name_re.captures(line) else { - continue; - }; - if canonicalize_pypi_name(&caps[1]) != target { - continue; - } - matched_any = true; - // pip-compile --generate-hashes emits backslash continuations - // (`foo==1.2 \` + indented `--hash=…` lines). Rewriting only the - // first physical line would orphan the old hash lines and — with - // an environment marker — leave a mid-line `\` that makes pip - // fail with InvalidMarker. Refuse rather than corrupt. - if line.ends_with('\\') { - result.warnings.push(RewriteWarning { - code: "redirect_requirements_continuation".into(), - detail: format!( - "{}@{} uses backslash continuations; not rewritten", - dep.name, dep.version - ), - }); - continue; - } - // Take the marker from the requirement portion only — everything - // BEFORE any per-requirement ` --` option. Grabbing to end-of-line - // would swallow a previously appended `--hash=…` and duplicate it - // on every re-run. - let uncommented = comment_re.replace(line, ""); - let req_part = uncommented - .split(" --") - .next() - .unwrap_or(&uncommented) - .trim_end(); - let marker = match req_part.find(';') { - Some(idx) => req_part[idx..].trim_end(), - None => "", - }; - let rewritten = if marker.is_empty() { - format!("{} @ {} --hash=sha256:{sha256}", dep.name, dep.artifact_url) - } else { - format!( - "{} @ {} {marker} --hash=sha256:{sha256}", - dep.name, dep.artifact_url - ) - }; - if rewritten != *raw { - result.edits.push(FileEdit { - path: "requirements.txt".into(), - kind: "redirect_requirements_line".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(Value::String(raw.clone())), - new: Some(Value::String(rewritten.clone())), - }); - *raw = rewritten; - changed = true; - } - } - // Parity with the npm/pnpm/berry/uv rewriters: a granted dep no line - // accounted for — omitted (a transitive dep the file never pins), or - // spelled in a form the name matcher cannot parse (a PEP 508 extras - // bracket) — must be SAID, not silently dropped from the redirected - // count. A found-but-refused line (continuation) already warned above. - if !matched_any { - result.warnings.push(RewriteWarning { - code: "redirect_requirements_entry_not_found".into(), - detail: format!( - "no requirements.txt entry for {}@{}", - dep.name, dep.version - ), - }); - } - } - if changed { - result - .files - .insert("requirements.txt".into(), lines.join("\n")); - } + requirements::rewrite(files, overrides, result); } // ── cargo (Cargo.toml + .cargo/config.toml + Cargo.lock) ───────────────────── @@ -2628,123 +2533,239 @@ fn is_prior_hosted_bun_spec(spec: &str, fname: &str, current_url: &str) -> bool } // ── uv.lock ────────────────────────────────────────────────────────────────── +fn python_lock_blocks(text: &str) -> Vec<&str> { + let mut starts = vec![0]; + let mut offset = 0; + for line in text.split_inclusive('\n') { + if offset != 0 + && matches!( + line.trim(), + "[[package]]" | "[[packages]]" | "[[distribution]]" + ) + { + starts.push(offset); + } + offset += line.len(); + } + starts.push(text.len()); + starts + .windows(2) + .map(|bounds| &text[bounds[0]..bounds[1]]) + .collect() +} + +fn record_python_lock_edits( + path: &str, + dep: &DepOverride, + original: &str, + rewritten: &str, + result: &mut RewriteResult, +) { + let original_blocks = python_lock_blocks(original); + let rewritten_blocks = python_lock_blocks(rewritten); + let blocks = if original_blocks.len() == rewritten_blocks.len() { + original_blocks.into_iter().zip(rewritten_blocks).collect() + } else { + vec![(original, rewritten)] + }; + for (original, rewritten) in blocks { + if original != rewritten { + result.edits.push(FileEdit { + path: path.to_string(), + kind: "redirect_uv_lock_wheel".into(), + action: "rewritten".into(), + key: Some(format!("{}@{}", dep.name, dep.version)), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(rewritten.to_string())), + }); + } + } +} + +struct PythonMetadataEdit { + path: String, + original: String, + rewritten: String, + script: bool, +} + +fn plan_python_metadata( + path: &str, + lock: &str, + files: &BTreeMap, + dep: &DepOverride, + result: &RewriteResult, +) -> Result<(Option, Option), RewriteWarning> { + use crate::utils::python_lock::{check_python_lock_source_scope, ArtifactSource}; + use crate::utils::python_script::{rewrite_project_metadata, rewrite_script_metadata}; + + let script = path.ends_with(".py.lock"); + let metadata_path = if script { + path.strip_suffix(".lock") + .expect("script lock suffix") + .to_string() + } else if path == "uv.lock" && files.contains_key("pyproject.toml") { + "pyproject.toml".to_string() + } else { + return Ok((None, None)); + }; + let Some(original) = result + .files + .get(&metadata_path) + .or_else(|| files.get(&metadata_path)) + .cloned() + else { + return Err(RewriteWarning { + code: "redirect_uv_script_missing".into(), + detail: format!("{path} requires its paired {metadata_path}"), + }); + }; + let unsupported = |detail| RewriteWarning { + code: if script { + "redirect_uv_script_unsupported" + } else { + "redirect_uv_project_unsupported" + } + .into(), + detail: format!("{metadata_path}: {detail}"), + }; + check_python_lock_source_scope(lock, &dep.name, &dep.version).map_err(unsupported)?; + let rewritten = if script { + rewrite_script_metadata( + &original, + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + ) + } else { + rewrite_project_metadata( + &original, + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + ) + } + .map_err(unsupported)?; + let project = (!script).then(|| rewritten.as_ref().unwrap_or(&original).clone()); + let edit = rewritten.map(|rewritten| PythonMetadataEdit { + path: metadata_path, + original, + rewritten, + script, + }); + Ok((edit, project)) +} + +fn record_python_metadata_edit( + edit: PythonMetadataEdit, + dep: &DepOverride, + result: &mut RewriteResult, +) { + let (original, rewritten) = if edit.script { + let original_span = crate::utils::python_script::script_metadata(&edit.original) + .expect("validated script metadata") + .0; + let rewritten_span = crate::utils::python_script::script_metadata(&edit.rewritten) + .expect("validated script metadata") + .0; + ( + edit.original[original_span].to_string(), + edit.rewritten[rewritten_span].to_string(), + ) + } else { + (edit.original, edit.rewritten.clone()) + }; + result.edits.push(FileEdit { + path: edit.path.clone(), + kind: "redirect_uv_lock_wheel".into(), + action: "rewritten".into(), + key: Some(format!("{}@{}", dep.name, dep.version)), + original: Some(Value::String(original)), + new: Some(Value::String(rewritten)), + }); + result.files.insert(edit.path, edit.rewritten); +} + fn rewrite_uv_lock( files: &BTreeMap, overrides: &[DepOverride], + python_metadata: &BTreeMap, result: &mut RewriteResult, ) { - let pypi: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "pypi").collect(); - if pypi.is_empty() || !files.contains_key("uv.lock") { - return; - } - let mut content = files["uv.lock"].clone(); - let wheel_re = Regex::new(r#"\{ url = "[^"]*", hash = "sha256:[^"]*"([^}]*) \}"#) - .expect("static uv wheel-entry regex is valid"); - let name_re = Regex::new(r#"name = "([^"]+)""#).expect("static name-field regex is valid"); - let mut changed = false; - for dep in &pypi { - let Some(sha256) = dep.integrity.sha256.clone() else { - result.warnings.push(RewriteWarning { - code: "redirect_uv_missing_sha256".into(), - detail: format!("{} has no sha256 integrity", dep.name), - }); - continue; - }; - // Find the [[package]] block for this name+version by string bounds - // (no lookahead in Rust regex). Iterate over [[package]] starts. - let target = canonicalize_pypi_name(&dep.name); - let mut matched = false; - let marker = "[[package]]\n"; - let mut search = 0usize; - while let Some(rel) = content[search..].find(marker) { - let block_start = search + rel; - let body_start = block_start + marker.len(); - let block_end = match content[body_start..].find("\n[[package]]") { - Some(r) => body_start + r + 1, - None => content.len(), - }; - let block = content[block_start..block_end].to_string(); - search = block_end; - let name_ok = name_re - .captures(&block) - .map(|c| canonicalize_pypi_name(&c[1]) == target) - .unwrap_or(false); - let version_ok = block.contains(&format!("version = \"{}\"\n", dep.version)) - || block.contains(&format!("version = \"{}\"", dep.version)); - if !name_ok || !version_ok { + use crate::utils::python_lock::{ + complete_python_lock_metadata, is_python_lock_name, rewrite_python_lock, ArtifactSource, + }; + + for (path, original) in files.iter().filter(|(path, _)| is_python_lock_name(path)) { + let mut content = original.clone(); + for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + let Some(sha256) = dep.integrity.sha256.as_deref() else { + result.warnings.push(RewriteWarning { + code: "redirect_uv_missing_sha256".into(), + detail: format!("{} has no sha256 integrity", dep.name), + }); continue; - } - // Split the head (`[[package]]\nname\nversion\n` — 3 lines) from the - // body, so the recorded edit is the BODY (matches the TS rewriter, - // whose regex captured head + body separately). - let head_end = { - let mut nl = 0; - let mut idx = block.len(); - for (i, ch) in block.char_indices() { - if ch == '\n' { - nl += 1; - if nl == 3 { - idx = i + 1; - break; - } + }; + let rewritten = match rewrite_python_lock( + &content, + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + sha256, + ) { + Ok(Some(rewritten)) => rewritten, + Ok(None) => { + result.warnings.push(RewriteWarning { + code: "redirect_uv_entry_not_found".into(), + detail: format!("no {path} archive entry for {}@{}", dep.name, dep.version), + }); + continue; + } + Err(detail) => { + result.warnings.push(RewriteWarning { + code: "redirect_uv_lock_unsupported".into(), + detail: format!("{path}: {detail}"), + }); + continue; + } + }; + let (metadata_edit, project) = + match plan_python_metadata(path, &content, files, dep, result) { + Ok(plan) => plan, + Err(warning) => { + result.warnings.push(warning); + continue; } + }; + let rewritten = match complete_python_lock_metadata( + &rewritten, + project.as_deref(), + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + python_metadata.get(&dep.artifact_url).map(String::as_str), + ) { + Ok(rewritten) => rewritten, + Err(detail) => { + result.warnings.push(RewriteWarning { + code: "redirect_uv_metadata_unsupported".into(), + detail: format!("{path}: {detail}"), + }); + continue; } - idx }; - let head = block[..head_end].to_string(); - let body = block[head_end..].to_string(); - if !wheel_re.is_match(&body) { - continue; + if let Some(edit) = metadata_edit { + record_python_metadata_edit(edit, dep, result); } - // Repoint EVERY url/hash entry in the block — sdist AND all - // wheels. uv prefers a wheel, so an upstream `wheels` entry left - // behind installs the unpatched artifact while the redirect is - // reported (and attested) as landed. - let new_body = wheel_re - .replace_all( - &body, - format!( - "{{ url = \"{}\", hash = \"sha256:{sha256}\"${{1}} }}", - dep.artifact_url - ) - .as_str(), - ) - .to_string(); - if new_body == body { - // Already redirected (re-run): the entry exists at the target - // values — not "entry not found". - matched = true; - continue; + if rewritten != content { + record_python_lock_edits(path, dep, &content, &rewritten, result); + content = rewritten; } - content = format!( - "{}{}{}{}", - &content[..block_start], - head, - new_body, - &content[block_end..] - ); - matched = true; - changed = true; - result.edits.push(FileEdit { - path: "uv.lock".into(), - kind: "redirect_uv_lock_wheel".into(), - action: "rewritten".into(), - key: Some(format!("{}@{}", dep.name, dep.version)), - original: Some(Value::String(body)), - new: Some(Value::String(new_body)), - }); - break; } - if !matched { - result.warnings.push(RewriteWarning { - code: "redirect_uv_entry_not_found".into(), - detail: format!("no uv.lock wheel entry for {}@{}", dep.name, dep.version), - }); + if content != *original { + result.files.insert(path.clone(), content); } } - if changed { - result.files.insert("uv.lock".into(), content); - } } // ── composer.lock ──────────────────────────────────────────────────────────── @@ -5253,6 +5274,10 @@ mod tests { ); } + /// An inline comment after the marker must not swallow the appended + /// `--hash=…` (pip would then treat the hash as comment text and skip + /// enforcement). The comment is split off and re-appended AFTER the hash + /// so the pin stays active and the user's note survives. #[test] fn requirements_marker_comment_keeps_hash_active() { let original = "requests==2.28.1 ; python_version >= \"3.7\" # explanation\n"; @@ -5264,7 +5289,9 @@ mod tests { let output = first.files.get("requirements.txt").expect("rewritten"); assert_eq!( output, - &format!("requests @ {url} ; python_version >= \"3.7\" --hash=sha256:{sha256}\n") + &format!( + "requests @ {url} ; python_version >= \"3.7\" --hash=sha256:{sha256} # explanation\n" + ) ); let again = BTreeMap::from([("requirements.txt".to_string(), output.clone())]); let second = rewrite_registry_redirect(&again, &overrides); @@ -5942,116 +5969,6 @@ mod tests { ); } - /// pip-compile --generate-hashes continuation lines are refused (warning) - /// rather than corrupted: rewriting only the first physical line would - /// orphan the old `--hash` lines, and with a marker pip hard-fails on the - /// mid-line backslash (InvalidMarker). - #[test] - fn requirements_continuation_lines_are_refused() { - let mut files = BTreeMap::new(); - files.insert( - "requirements.txt".to_string(), - "requests==2.28.1 ; python_version >= \"3.7\" \\\n --hash=sha256:OLDOLDOLD\n" - .to_string(), - ); - let overrides = vec![pypi_override( - "requests", - "2.28.1", - "http://patch.test/requests-2.28.1-py3-none-any.whl", - &"c".repeat(64), - )]; - let result = rewrite_registry_redirect(&files, &overrides); - assert!( - result.files.is_empty() && result.edits.is_empty(), - "continuation input must not be rewritten: {:?}", - result.files - ); - assert!( - result - .warnings - .iter() - .any(|w| w.code == "redirect_requirements_continuation"), - "must surface the continuation refusal: {:?}", - result.warnings - ); - } - - /// A granted pypi dep whose requirements.txt line the name matcher cannot - /// parse (a PEP 508 extras bracket terminates the name run before any - /// terminator alternative) — or that the file omits entirely — must be - /// SAID with an entry-not-found warning, matching npm/pnpm/yarn/berry/ - /// bun/uv/cargo/composer, not silently dropped from the redirected count. - #[test] - fn requirements_unmatched_dep_warns_entry_not_found() { - let mut files = BTreeMap::new(); - files.insert( - "requirements.txt".to_string(), - "requests[security]==2.28.1\n".to_string(), - ); - let overrides = vec![pypi_override( - "requests", - "2.28.1", - "http://patch.test/requests-2.28.1-py3-none-any.whl", - &"c".repeat(64), - )]; - let r = rewrite_registry_redirect(&files, &overrides); - // The extras spelling itself is a recorded TS-parity residual (the - // line matching needs a coordinated TS+Rust fix) — the line stays. - assert!( - r.files.is_empty() && r.edits.is_empty(), - "extras line must not be rewritten: {:?}", - r.files - ); - assert!( - warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), - "the un-wired dep must be SAID, not silent: {:?}", - r.warnings - ); - } - - /// The not-found warning fires ONLY for a dep no line accounted for: a - /// rewritten line and a continuation-refused line (which carries its own - /// warning) both count as found. - #[test] - fn requirements_matched_or_refused_dep_gets_no_not_found_warning() { - let overrides = vec![pypi_override( - "requests", - "2.28.1", - "http://patch.test/requests-2.28.1-py3-none-any.whl", - &"c".repeat(64), - )]; - // Plain match: rewritten, no not-found. - let mut files = BTreeMap::new(); - files.insert( - "requirements.txt".to_string(), - "requests==2.28.1\n".to_string(), - ); - let r = rewrite_registry_redirect(&files, &overrides); - assert!(!r.edits.is_empty(), "plain pin rewritten"); - assert!( - !warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), - "a rewritten dep is not not-found: {:?}", - r.warnings - ); - // Continuation refusal: found-but-refused must not ALSO say not-found. - let mut files = BTreeMap::new(); - files.insert( - "requirements.txt".to_string(), - "requests==2.28.1 \\\n --hash=sha256:OLDOLDOLD\n".to_string(), - ); - let r = rewrite_registry_redirect(&files, &overrides); - assert!( - warning_codes(&r).contains(&"redirect_requirements_continuation"), - "{:?}", - r.warnings - ); - assert!( - !warning_codes(&r).contains(&"redirect_requirements_entry_not_found"), - "a refused-with-cause dep is not not-found: {:?}", - r.warnings - ); - } - fn berry_override(name: &str, version: &str, url: &str, checksum: &str) -> DepOverride { DepOverride { integrity: Integrity { @@ -6848,14 +6765,8 @@ mod tests { } } - /// A realistic uv.lock block carries BOTH an `sdist` entry and a `wheels` - /// entry. Every `{ url, hash }` in the block must be repointed at the - /// hosted patch: uv PREFERS a wheel, so leaving `wheels` at the upstream - /// URL/hash makes the install silently use the UNPATCHED artifact while - /// the scan confirms the dep as redirected (the artifact URL landed in - /// the sdist slot). #[test] - fn uv_lock_sdist_and_wheels_all_repointed() { + fn uv_lock_uses_direct_source_and_matching_archive() { let lock = "version = 1\nrequires-python = \">=3.8\"\n\n[[package]]\nname = \"requests\"\nversion = \"2.28.1\"\nsource = { registry = \"https://pypi.org/simple\" }\nsdist = { url = \"https://files.pythonhosted.org/packages/aa/requests-2.28.1.tar.gz\", hash = \"sha256:aaaa\" }\nwheels = [\n { url = \"https://files.pythonhosted.org/packages/bb/requests-2.28.1-py3-none-any.whl\", hash = \"sha256:bbbb\" },\n]\n"; let mut files = BTreeMap::new(); files.insert("uv.lock".to_string(), lock.to_string()); @@ -6870,13 +6781,13 @@ mod tests { assert_eq!( out.matches(url).count(), 2, - "sdist AND wheel repointed: {out}" + "direct source and wheel URL agree: {out}" ); assert_eq!( out.matches(&format!("hash = \"sha256:{}\"", "c".repeat(64))) .count(), - 2, - "both hashes pinned: {out}" + 1, + "one patched wheel hash is pinned: {out}" ); // Re-run over the rewritten output: a no-op, and NOT reported as @@ -11441,8 +11352,7 @@ packages: ); files.insert( "yarn.lock".to_string(), - "left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"https://x/lp.tgz\"\n" - .to_string(), + "left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"https://x/lp.tgz\"\n".to_string(), ); files.insert( "bun.lock".to_string(), @@ -11545,8 +11455,7 @@ packages: files.insert("composer.lock".to_string(), "{}\n".to_string()); files.insert( "go.mod".to_string(), - "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n" - .to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n".to_string(), ); let overrides = vec![ cargo_dep, @@ -11668,7 +11577,11 @@ packages: ); files.insert( ".cargo/config.toml".to_string(), - format!("[registries.{}]\nindex = \"{}\"\n", cargo_reg(), cargo_index_url()), + format!( + "[registries.{}]\nindex = \"{}\"\n", + cargo_reg(), + cargo_index_url() + ), ); let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!( @@ -11732,9 +11645,7 @@ packages: "the alias table gains the registry line: {toml}" ); assert!( - toml.contains( - "[dependencies.serde]\npackage = \"leftpad\"\nversion = \"1.0.0\"" - ), + toml.contains("[dependencies.serde]\npackage = \"leftpad\"\nversion = \"1.0.0\""), "the key-colliding rename of another crate is untouched: {toml}" ); assert!(r.warnings.is_empty(), "{:?}", r.warnings); @@ -12250,7 +12161,11 @@ packages: let out = r.files.get("uv.lock").expect("uv.lock rewritten"); assert!(out.contains(alpha), "alpha block byte-identical: {out}"); assert!(out.contains(zulu), "zulu block byte-identical: {out}"); - assert_eq!(out.matches(url).count(), 2, "sdist + wheel repointed: {out}"); + assert_eq!( + out.matches(url).count(), + 2, + "source and wheel repointed: {out}" + ); assert_eq!(r.edits.len(), 1, "{:?}", r.edits); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } @@ -12353,7 +12268,10 @@ packages: .to_string(), ); let r = rewrite_registry_redirect(&files, &[nuget_override()]); - let config = r.files.get("nuget.config").expect("default config authored"); + let config = r + .files + .get("nuget.config") + .expect("default config authored"); assert!( config.contains( "" diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs new file mode 100644 index 00000000..79769584 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -0,0 +1,572 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; +use serde_json::Value; + +use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::purl::percent_decode_purl_component; + +struct LogicalRequirement { + original: String, + text: String, + ending: String, + unterminated: bool, +} + +fn logical_requirements(content: &str) -> Vec { + let physical: Vec<&str> = content.split_inclusive('\n').collect(); + let mut requirements = Vec::new(); + let mut index = 0; + while index < physical.len() { + let start = index; + let mut original = String::new(); + let mut text = String::new(); + loop { + let physical_line = physical[index]; + let (body, ending) = if let Some(body) = physical_line.strip_suffix("\r\n") { + (body, "\r\n") + } else if let Some(body) = physical_line.strip_suffix('\n') { + (body, "\n") + } else { + (physical_line, "") + }; + let parsed_body = if index == 0 { + body.strip_prefix('\u{feff}').unwrap_or(body) + } else { + body + }; + let continued = + !parsed_body.trim_start().starts_with('#') && body.trim_end().ends_with('\\'); + if continued && index + 1 < physical.len() { + original.push_str(physical_line); + text.push_str(body.trim_end().strip_suffix('\\').unwrap_or(body)); + index += 1; + continue; + } + original.push_str(body); + text.push_str(body); + if start == 0 { + text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned(); + } + requirements.push(LogicalRequirement { + original, + text, + ending: ending.to_owned(), + unterminated: continued, + }); + index += 1; + break; + } + } + requirements +} + +fn unquoted_index(text: &str, target: char, after_whitespace: bool) -> Option { + let mut quote = None; + let mut escaped = false; + let mut previous = None; + for (index, character) in text.char_indices() { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if quote == Some(character) { + quote = None; + } else if quote.is_none() { + if character == '\'' || character == '"' { + quote = Some(character); + } else if character == target + && (!after_whitespace || previous.is_none_or(char::is_whitespace)) + { + return Some(index); + } + } + previous = Some(character); + } + None +} + +fn requirement_tokens(text: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + let mut start = None; + let mut quote = None; + let mut escaped = false; + for (index, character) in text.char_indices() { + if quote.is_none() && character.is_whitespace() { + if let Some(start) = start.take() { + tokens.push(&text[start..index]); + } + continue; + } + start.get_or_insert(index); + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if quote == Some(character) { + quote = None; + } else if quote.is_none() && (character == '\'' || character == '"') { + quote = Some(character); + } + } + if let Some(start) = start { + tokens.push(&text[start..]); + } + tokens +} + +fn without_hashes(text: &str) -> String { + let mut kept = Vec::new(); + let mut tokens = requirement_tokens(text).into_iter(); + while let Some(token) = tokens.next() { + if token == "--hash" { + tokens.next(); + } else if !token.starts_with("--hash=") { + kept.push(token); + } + } + kept.join(" ") +} + +enum RequirementVersion { + Exact(String), + Unpinned, + Ambiguous, +} + +fn archive_version(location: &str, name: &str) -> Option { + let url = reqwest::Url::parse(location).ok()?; + let filename = percent_decode_purl_component(url.path().rsplit('/').next()?); + let (distribution, version) = if let Some(stem) = filename.strip_suffix(".whl") { + let mut parts = stem.splitn(3, '-'); + let distribution = parts.next()?; + let version = parts.next()?; + parts.next()?; + (distribution, version) + } else { + let stem = [".tar.gz", ".zip", ".tar.bz2", ".tar.xz"] + .into_iter() + .find_map(|suffix| filename.strip_suffix(suffix))?; + stem.rsplit_once('-')? + }; + (canonicalize_pypi_name(distribution) == name && !version.is_empty()) + .then(|| version.to_string()) +} + +fn requirement_version(specifier: &str, name_re: &Regex, name: &str) -> RequirementVersion { + let Some(captures) = name_re.captures(specifier.trim()) else { + return RequirementVersion::Ambiguous; + }; + let end = captures.get(2).or_else(|| captures.get(1)).unwrap().end(); + let tail = &specifier.trim()[end..]; + let tail = requirement_tokens(tail) + .into_iter() + .take_while(|token| !token.starts_with("--")) + .collect::>() + .join(" "); + let tail = tail.trim(); + let tail = tail + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(tail) + .trim(); + if tail.is_empty() { + return RequirementVersion::Unpinned; + } + if let Some(location) = tail.strip_prefix('@') { + return archive_version(location.trim(), name) + .map_or(RequirementVersion::Ambiguous, RequirementVersion::Exact); + } + if let Some(version) = tail.strip_prefix("===").or_else(|| tail.strip_prefix("==")) { + let version = version.trim(); + if !version.is_empty() + && !version + .chars() + .any(|character| character.is_whitespace() || ",*<>=~".contains(character)) + { + return RequirementVersion::Exact(version.to_string()); + } + } + RequirementVersion::Ambiguous +} + +pub(super) fn rewrite( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + let Some(content) = files.get("requirements.txt") else { + return; + }; + let name_re = Regex::new( + r"^([A-Za-z0-9][A-Za-z0-9._-]*)(\s*\[[^\]\r\n]*\])?\s*(?:[=<>~!]=?|@|;|\(|\s|$)", + ) + .expect("static requirements-name regex is valid"); + let mut requirements = logical_requirements(content); + let mut row_counts = BTreeMap::::new(); + for requirement in &requirements { + if let Some(captures) = name_re.captures(requirement.text.trim()) { + *row_counts + .entry(canonicalize_pypi_name(&captures[1])) + .or_default() += 1; + } + } + let mut override_versions = BTreeMap::>::new(); + for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + override_versions + .entry(canonicalize_pypi_name(&dep.name)) + .or_default() + .insert(&dep.version); + } + let mut changed = false; + for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + let Some(sha256) = &dep.integrity.sha256 else { + result.warnings.push(RewriteWarning { + code: "redirect_requirements_missing_sha256".into(), + detail: format!("{} has no sha256 integrity", dep.name), + }); + continue; + }; + let target = canonicalize_pypi_name(&dep.name); + let mut matched = false; + for requirement in &mut requirements { + let text = requirement.text.trim(); + let Some(captures) = name_re.captures(text) else { + continue; + }; + if canonicalize_pypi_name(&captures[1]) != target { + continue; + } + if requirement.unterminated { + matched = true; + result.warnings.push(RewriteWarning { + code: "redirect_requirements_continuation".into(), + detail: format!( + "{}@{} has an unterminated continuation; not rewritten", + dep.name, dep.version + ), + }); + continue; + } + let (body, comment) = unquoted_index(text, '#', true) + .map_or((text, ""), |index| (&text[..index], &text[index..])); + let cleaned = without_hashes(body); + let (specifier, marker) = unquoted_index(&cleaned, ';', false) + .map_or((cleaned.as_str(), ""), |index| { + (&cleaned[..index], &cleaned[index..]) + }); + match requirement_version(specifier, &name_re, &target) { + RequirementVersion::Exact(version) if version != dep.version => continue, + RequirementVersion::Exact(_) => {} + RequirementVersion::Unpinned + if row_counts.get(&target) == Some(&1) + && override_versions + .get(&target) + .is_some_and(|versions| versions.len() == 1) => {} + _ => { + matched = true; + result.warnings.push(RewriteWarning { + code: "redirect_requirements_version_ambiguous".into(), + detail: format!( + "requirements.txt does not uniquely pin {}@{}; not rewritten", + dep.name, dep.version + ), + }); + continue; + } + } + matched = true; + let options = requirement_tokens(specifier) + .into_iter() + .skip_while(|token| !token.starts_with("--")) + .collect::>() + .join(" "); + let extras = captures + .get(2) + .map_or("", |capture| capture.as_str().trim()); + let prefix_body = requirement + .original + .strip_prefix('\u{feff}') + .unwrap_or(&requirement.original); + let indent = &prefix_body + [..prefix_body.len() - prefix_body.trim_start_matches([' ', '\t']).len()]; + let bom = if requirement.original.starts_with('\u{feff}') { + "\u{feff}" + } else { + "" + }; + let mut rewritten = format!("{bom}{indent}{}{extras} @ {}", dep.name, dep.artifact_url); + for suffix in [marker.trim(), options.as_str()] { + if !suffix.is_empty() { + rewritten.push(' '); + rewritten.push_str(suffix); + } + } + rewritten.push_str(&format!(" --hash=sha256:{sha256}")); + if !comment.is_empty() { + rewritten.push(' '); + rewritten.push_str(comment); + } + if rewritten != requirement.original { + result.edits.push(FileEdit { + path: "requirements.txt".into(), + kind: "redirect_requirements_line".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(Value::String(requirement.original.clone())), + new: Some(Value::String(rewritten.clone())), + }); + requirement.text = rewritten + .strip_prefix('\u{feff}') + .unwrap_or(&rewritten) + .to_owned(); + requirement.original = rewritten; + changed = true; + } + } + if !matched { + result.warnings.push(RewriteWarning { + code: "redirect_requirements_entry_not_found".into(), + detail: format!("no requirements.txt entry for {}@{}", dep.name, dep.version), + }); + } + } + if changed { + let output = requirements + .into_iter() + .map(|requirement| requirement.original + &requirement.ending) + .collect(); + result.files.insert("requirements.txt".into(), output); + } +} + +#[cfg(test)] +mod tests { + use super::super::{rewrite_registry_redirect, Integrity}; + use super::*; + + const URL: &str = "https://patch.socket.dev/patch/pypi/requests/2.28.1/11111111-1111-1111-1111-111111111111/33333333-3333-3333-3333-333333333333/requests-2.28.1-py3-none-any.whl"; + const HASH: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + fn patch() -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "requests".into(), + namespace: None, + version: "2.28.1".into(), + token: "11111111-1111-1111-1111-111111111111".into(), + patch_uuid: "33333333-3333-3333-3333-333333333333".into(), + artifact_url: URL.into(), + integrity: Integrity { + sha256: Some(HASH.into()), + ..Default::default() + }, + berry_zip_url: None, + registry_override: None, + } + } + + fn input(text: &str) -> BTreeMap { + BTreeMap::from([("requirements.txt".into(), text.into())]) + } + + #[test] + fn continued_hashes_extras_and_markers_are_rewritten_together() { + let source = "flask==2.0.1\nrequests[security,socks]==2.28.1 ; python_version >= \"3.7\" \\\n --hash=sha256:OLD_ONE \\\n --hash sha256:OLD_TWO # via application\ncertifi==2024.2.2\n"; + let result = rewrite_registry_redirect(&input(source), &[patch()]); + let expected = format!("flask==2.0.1\nrequests[security,socks] @ {URL} ; python_version >= \"3.7\" --hash=sha256:{HASH} # via application\ncertifi==2024.2.2\n"); + assert_eq!(result.files.get("requirements.txt"), Some(&expected)); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.edits.len(), 1); + assert_eq!( + result.edits[0].original, + Some(Value::String( + source + .split_once('\n') + .unwrap() + .1 + .rsplit_once('\n') + .unwrap() + .0 + .rsplit_once('\n') + .unwrap() + .0 + .to_owned() + )) + ); + let rerun = rewrite_registry_redirect(&input(&expected), &[patch()]); + assert!(rerun.files.is_empty() && rerun.edits.is_empty()); + assert!(rerun.warnings.is_empty()); + } + + #[test] + fn markers_after_hashes_and_hash_text_in_quoted_markers_are_preserved() { + let source = "requests==2.28.1 --hash=sha256:OLD ; platform_version != \"text --hash=keep # retained\"\n"; + let result = rewrite_registry_redirect(&input(source), &[patch()]); + assert_eq!( + result.files["requirements.txt"], + format!("requests @ {URL} ; platform_version != \"text --hash=keep # retained\" --hash=sha256:{HASH}\n") + ); + } + + #[test] + fn line_endings_bom_indentation_and_unrelated_bytes_are_preserved() { + for ending in ["\n", "\r\n"] { + let source = format!("\u{feff} requests==2.28.1 \\{ending}\t--hash=sha256:OLD{ending}# unchanged{ending}flask==2.0.1"); + let result = rewrite_registry_redirect(&input(&source), &[patch()]); + assert_eq!( + result.files["requirements.txt"], + format!("\u{feff} requests @ {URL} --hash=sha256:{HASH}{ending}# unchanged{ending}flask==2.0.1") + ); + assert!(result.edits[0] + .original + .as_ref() + .unwrap() + .as_str() + .unwrap() + .contains(ending)); + } + let result = rewrite_registry_redirect(&input("requests==2.28.1"), &[patch()]); + assert!(!result.files["requirements.txt"].ends_with('\n')); + } + + #[test] + fn full_line_comments_do_not_continue_into_requirements() { + for prefix in ["", "\u{feff}"] { + let source = format!("{prefix}# documentation \\\nrequests==2.28.1\n"); + let result = rewrite_registry_redirect(&input(&source), &[patch()]); + assert_eq!( + result.files["requirements.txt"], + format!("{prefix}# documentation \\\nrequests @ {URL} --hash=sha256:{HASH}\n") + ); + } + } + + #[test] + fn non_hash_options_are_preserved() { + let source = "requests==2.28.1 --config-settings=key=value --hash=sha256:OLD ; python_version >= \"3.7\"\n"; + let result = rewrite_registry_redirect(&input(source), &[patch()]); + assert_eq!(result.files["requirements.txt"], format!("requests @ {URL} ; python_version >= \"3.7\" --config-settings=key=value --hash=sha256:{HASH}\n")); + } + + #[test] + fn unterminated_continuation_is_unchanged_and_warned() { + for source in ["requests==2.28.1 \\", "requests==2.28.1 \\\n"] { + let result = rewrite_registry_redirect(&input(source), &[patch()]); + assert!(result.files.is_empty() && result.edits.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!( + result.warnings[0].code, + "redirect_requirements_continuation" + ); + } + } + + #[test] + fn absent_package_retains_entry_not_found_warning() { + let result = rewrite_registry_redirect(&input("flask==2.0.1\n"), &[patch()]); + assert!(result.files.is_empty() && result.edits.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!( + result.warnings[0].code, + "redirect_requirements_entry_not_found" + ); + } + + #[test] + fn conditional_versions_keep_their_own_artifacts_and_hashes() { + let unchanged = "requests==2.32.0 ; python_version >= '3.10' \\\n --hash=sha256:OTHER_ONE \\\n --hash=sha256:OTHER_TWO\n"; + let source = format!( + "requests[socks]==2.28.1 ; python_version < '3.10' \\\n --hash=sha256:OLD\n{unchanged}" + ); + let result = rewrite_registry_redirect(&input(&source), &[patch()]); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.edits.len(), 1); + assert_eq!( + result.files["requirements.txt"], + format!("requests[socks] @ {URL} ; python_version < '3.10' --hash=sha256:{HASH}\n{unchanged}") + ); + } + + #[test] + fn multiple_version_overrides_are_independent_of_order() { + let source = "requests==2.28.1 ; python_version < '3.10'\nrequests==2.32.0 ; python_version >= '3.10'\n"; + let mut other = patch(); + other.version = "2.32.0".into(); + other.artifact_url = URL.replace("2.28.1", "2.32.0"); + other.integrity.sha256 = Some("d".repeat(64)); + let expected = format!( + "requests @ {URL} ; python_version < '3.10' --hash=sha256:{HASH}\nrequests @ {} ; python_version >= '3.10' --hash=sha256:{}\n", + other.artifact_url, + "d".repeat(64) + ); + for overrides in [vec![patch(), other.clone()], vec![other.clone(), patch()]] { + let result = rewrite_registry_redirect(&input(source), &overrides); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.edits.len(), 2); + assert_eq!(result.files["requirements.txt"], expected); + let rerun = rewrite_registry_redirect(&input(&expected), &overrides); + assert!(rerun.files.is_empty() && rerun.edits.is_empty()); + assert!(rerun.warnings.is_empty(), "{:?}", rerun.warnings); + } + } + + #[test] + fn archive_urls_select_the_matching_distribution_version() { + let other = URL.replace("2.28.1", "2.32.0"); + let previous = URL.replace("11111111", "22222222"); + let source = format!( + "requests @ {previous} --hash=sha256:OLD\nrequests @ {other} --hash=sha256:OTHER\n" + ); + let result = rewrite_registry_redirect(&input(&source), &[patch()]); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!( + result.files["requirements.txt"], + format!( + "requests @ {URL} --hash=sha256:{HASH}\nrequests @ {other} --hash=sha256:OTHER\n" + ) + ); + assert_eq!(result.edits.len(), 1); + for source in [ + "requests @ https://files.pythonhosted.org/requests-2.28.1.tar.gz#sha256=old", + "requests ( == 2.28.1 )", + "requests===2.28.1", + ] { + let result = rewrite_registry_redirect(&input(source), &[patch()]); + assert_eq!( + result.files["requirements.txt"], + format!("requests @ {URL} --hash=sha256:{HASH}") + ); + } + } + + #[test] + fn ambiguous_versions_are_preserved_and_reported() { + for source in [ + "requests ; python_version < '3.10'\nrequests ; python_version >= '3.10'\n", + "requests>=2.0\n", + "requests==2.*\n", + "requests @ https://example.test/download\n", + ] { + let result = rewrite_registry_redirect(&input(source), &[patch()]); + assert!(result.files.is_empty() && result.edits.is_empty()); + assert!(!result.warnings.is_empty()); + assert!(result + .warnings + .iter() + .all(|warning| warning.code == "redirect_requirements_version_ambiguous")); + } + let result = rewrite_registry_redirect(&input("requests\n"), &[patch()]); + assert_eq!( + result.files["requirements.txt"], + format!("requests @ {URL} --hash=sha256:{HASH}\n") + ); + let mut other = patch(); + other.version = "2.32.0".into(); + other.artifact_url = URL.replace("2.28.1", "2.32.0"); + let result = rewrite_registry_redirect(&input("requests\n"), &[patch(), other]); + assert!(result.files.is_empty() && result.edits.is_empty()); + assert_eq!(result.warnings.len(), 2); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index cb1f4639..d50be2dd 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -3,6 +3,8 @@ pub mod fs; pub(crate) mod http; pub mod process; pub mod purl; +pub mod python_lock; +pub mod python_script; pub(crate) mod serde; pub mod socket_cli_config; pub(crate) mod toml_edit_ext; diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs new file mode 100644 index 00000000..8bb4418b --- /dev/null +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -0,0 +1,858 @@ +use std::path::Path; + +use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; + +#[derive(Clone, Copy, Debug)] +pub enum ArtifactSource<'a> { + Url(&'a str), + Path(&'a str), +} + +impl ArtifactSource<'_> { + fn key(self) -> &'static str { + match self { + Self::Url(_) => "url", + Self::Path(_) => "path", + } + } + + fn location(self) -> String { + match self { + Self::Url(url) => url.to_string(), + Self::Path(path) => path.to_string(), + } + } +} + +pub fn is_python_lock_name(name: &str) -> bool { + name == "uv.lock" + || name.ends_with(".py.lock") + || name == "pylock.toml" + || (name.starts_with("pylock.") && name.ends_with(".toml")) +} + +pub fn python_lock_paths(root: &Path) -> std::io::Result> { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if is_python_lock_name(&name) { + paths.push(name); + } + } + paths.sort(); + Ok(paths) +} + +fn inline(entries: &[(&str, Value)]) -> Value { + let mut table = InlineTable::new(); + for (key, value) in entries { + table.insert(*key, value.clone()); + } + table.fmt(); + Value::InlineTable(table) +} + +fn matching_package(table: &Table, name: &str, version: &str) -> bool { + table + .get("name") + .and_then(Item::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + && table.get("version").and_then(Item::as_str) == Some(version) +} + +fn source_identity(source: &Item) -> Option<(&str, &str)> { + if let Some(value) = source.as_value() { + return source_value_identity(value); + } + let table = source.as_table()?; + ["registry", "url", "path"].into_iter().find_map(|key| { + table + .get(key) + .and_then(Item::as_str) + .map(|value| (key, value)) + }) +} + +fn source_value_identity(source: &Value) -> Option<(&str, &str)> { + if let Some(value) = source.as_str() { + return Some(("legacy", value)); + } + let table = source.as_inline_table()?; + ["registry", "url", "path"].into_iter().find_map(|key| { + table + .get(key) + .and_then(Value::as_str) + .map(|value| (key, value)) + }) +} + +fn rewrite_reference_value( + value: &mut Value, + name: &str, + version: &str, + original_source: &Item, + source: &Item, +) { + match value { + Value::Array(array) => { + for value in array.iter_mut() { + rewrite_reference_value(value, name, version, original_source, source); + } + } + Value::InlineTable(table) => { + if table + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + && table + .get("version") + .and_then(Value::as_str) + .is_none_or(|value| value == version) + && table.get("source").is_some_and(|value| { + source_value_identity(value) == source_identity(original_source) + }) + { + if let Some(value) = source.as_value() { + table.insert("source", value.clone()); + } + } + for (_, value) in table.iter_mut() { + rewrite_reference_value(value, name, version, original_source, source); + } + } + _ => {} + } +} + +fn rewrite_references( + item: &mut Item, + name: &str, + version: &str, + original_source: &Item, + source: &Item, +) { + match item { + Item::Value(value) => { + rewrite_reference_value(value, name, version, original_source, source); + } + Item::Table(table) => { + if matching_package(table, name, version) + && table + .get("source") + .is_some_and(|value| source_identity(value) == source_identity(original_source)) + { + table.insert("source", source.clone()); + } + for (_, item) in table.iter_mut() { + rewrite_references(item, name, version, original_source, source); + } + } + Item::ArrayOfTables(tables) => { + for table in tables.iter_mut() { + for (_, item) in table.iter_mut() { + rewrite_references(item, name, version, original_source, source); + } + if matching_package(table, name, version) + && table.get("source").is_some_and(|value| { + source_identity(value) == source_identity(original_source) + }) + { + table.insert("source", source.clone()); + } + } + } + Item::None => {} + } +} + +fn rewrite_manifest(document: &mut DocumentMut, name: &str, artifact: ArtifactSource<'_>) { + let Some(manifest) = document + .get_mut("manifest") + .and_then(Item::as_table_like_mut) + else { + return; + }; + if !manifest.contains_key("requirements") { + return; + } + let mut direct = false; + if let Some(requirements) = manifest + .get_mut("requirements") + .and_then(Item::as_array_mut) + { + for requirement in requirements + .iter_mut() + .filter_map(Value::as_inline_table_mut) + { + if requirement + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + { + requirement.remove("specifier"); + requirement.remove("url"); + requirement.remove("path"); + requirement.insert(artifact.key(), Value::from(artifact.location())); + direct = true; + } + } + } + if direct { + return; + } + let overrides = manifest + .entry("overrides") + .or_insert(Item::Value(Value::Array(Array::new()))); + let Some(overrides) = overrides.as_array_mut() else { + return; + }; + for requirement in overrides.iter_mut().filter_map(Value::as_inline_table_mut) { + if requirement + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + { + requirement.remove("specifier"); + requirement.remove("url"); + requirement.remove("path"); + requirement.insert(artifact.key(), Value::from(artifact.location())); + return; + } + } + overrides.push_formatted(inline(&[ + ("name", Value::from(name)), + (artifact.key(), Value::from(artifact.location())), + ])); +} + +pub fn check_python_lock_source_scope(text: &str, name: &str, version: &str) -> Result<(), String> { + let document: DocumentMut = text + .parse() + .map_err(|error| format!("invalid Python lock: {error}"))?; + let name = canonicalize_pypi_name(name); + for collection in ["package", "distribution"] { + if let Some(packages) = document.get(collection).and_then(Item::as_array_of_tables) { + if packages.iter().any(|package| { + package + .get("name") + .and_then(Item::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + && package + .get("version") + .and_then(Item::as_str) + .is_some_and(|value| value != version) + }) { + return Err(format!("{name} resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required")); + } + } + } + Ok(()) +} + +fn rewrite_requirement_sources(item: &mut Item, name: &str, artifact: ArtifactSource<'_>) { + if let Some(requirements) = item.as_array_mut() { + for requirement in requirements + .iter_mut() + .filter_map(Value::as_inline_table_mut) + { + if requirement + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + { + requirement.remove("specifier"); + requirement.remove("url"); + requirement.remove("path"); + requirement.insert(artifact.key(), Value::from(artifact.location())); + } + } + } else if let Some(table) = item.as_table_like_mut() { + for (_, item) in table.iter_mut() { + rewrite_requirement_sources(item, name, artifact); + } + } +} + +pub fn complete_python_lock_metadata( + text: &str, + project: Option<&str>, + name: &str, + version: &str, + artifact: ArtifactSource<'_>, + wheel_metadata: Option<&str>, +) -> Result { + let mut document: DocumentMut = text + .parse() + .map_err(|error| format!("invalid Python lock: {error}"))?; + if document + .get("package") + .and_then(Item::as_array_of_tables) + .is_none() + { + return Ok(text.to_string()); + } + let name = canonicalize_pypi_name(name); + if let Some(project) = project { + let project: DocumentMut = project + .parse() + .map_err(|error| format!("invalid pyproject.toml: {error}"))?; + let packages = document + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + .expect("package collection checked"); + for package in packages.iter_mut() { + let root = package + .get("source") + .and_then(Item::as_table_like) + .is_some_and(|source| { + ["virtual", "editable"] + .into_iter() + .any(|key| source.get(key).and_then(Item::as_str) == Some(".")) + }); + if root { + if let Some(metadata) = package + .get_mut("metadata") + .and_then(Item::as_table_like_mut) + { + for key in ["requires-dist", "requires-dev"] { + if let Some(requirements) = metadata.get_mut(key) { + rewrite_requirement_sources(requirements, &name, artifact); + } + } + } + } + } + let overridden = project + .get("tool") + .and_then(Item::as_table_like) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table_like) + .and_then(|uv| uv.get("override-dependencies")) + .and_then(Item::as_array) + .is_some_and(|overrides| { + overrides.iter().filter_map(Value::as_str).any(|specifier| { + canonicalize_pypi_name( + specifier + .split(|ch: char| { + !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' && ch != '.' + }) + .next() + .unwrap_or_default(), + ) == name + }) + }); + if overridden { + let manifest = document + .entry("manifest") + .or_insert(Item::Table(Table::new())) + .as_table_like_mut() + .ok_or("uv manifest must be a table")?; + let overrides = manifest + .entry("overrides") + .or_insert(Item::Value(Value::Array(Array::new()))); + let exists = overrides.as_array().is_some_and(|overrides| { + overrides + .iter() + .filter_map(Value::as_inline_table) + .any(|requirement| { + requirement + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + }) + }); + if exists { + rewrite_requirement_sources(overrides, &name, artifact); + } else { + overrides + .as_array_mut() + .ok_or("uv manifest overrides must be an array")? + .push_formatted(inline(&[ + ("name", Value::from(name.as_str())), + (artifact.key(), Value::from(artifact.location())), + ])); + } + } + } + if let Some(metadata) = wheel_metadata { + let metadata: DocumentMut = metadata + .parse() + .map_err(|error| format!("invalid wheel metadata: {error}"))?; + let metadata = metadata + .get("package") + .and_then(Item::as_table_like) + .and_then(|package| package.get("metadata")) + .and_then(Item::as_table) + .ok_or("wheel metadata has no package metadata table")?; + for package in document + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + .expect("package collection checked") + .iter_mut() + { + if matching_package(package, &name, version) { + let mut metadata = metadata.clone(); + metadata.set_position(None); + package.insert("metadata", Item::Table(metadata)); + } + } + } + Ok(document.to_string()) +} + +pub fn rewrite_python_lock( + text: &str, + name: &str, + version: &str, + artifact: ArtifactSource<'_>, + sha256: &str, +) -> Result, String> { + let mut document: DocumentMut = text + .parse() + .map_err(|error| format!("invalid Python lock: {error}"))?; + if document + .get("manifest") + .and_then(Item::as_table_like) + .is_some_and(|manifest| manifest.contains_key("requirements")) + { + check_python_lock_source_scope(text, name, version)?; + } + let pep751 = document.get("lock-version").is_some(); + let legacy = document.get("distribution").is_some(); + if pep751 { + if document.get("lock-version").and_then(Item::as_str) != Some("1.0") { + return Err("unsupported PEP 751 lock version".to_string()); + } + } else if document.get("version").and_then(Item::as_integer) != Some(1) { + return Err("unsupported uv lock version".to_string()); + } + let collection = if pep751 { + "packages" + } else if legacy { + "distribution" + } else { + "package" + }; + let name = canonicalize_pypi_name(name); + let Some(packages) = document + .get_mut(collection) + .and_then(Item::as_array_of_tables_mut) + else { + return Ok(None); + }; + let matches: Vec = packages + .iter() + .enumerate() + .filter_map(|(index, table)| matching_package(table, &name, version).then_some(index)) + .collect(); + if matches.len() > 1 { + return Err(format!( + "multiple lock entries for {name}@{version}; source selection is ambiguous" + )); + } + let Some(index) = matches.first() else { + return Ok(None); + }; + let package = packages + .get_mut(*index) + .expect("matching package index exists"); + let original_source = package.get("source").cloned(); + if !pep751 + && !original_source.as_ref().is_some_and(|source| { + source_identity(source).is_some_and(|(kind, value)| { + kind != "legacy" + || value.starts_with("registry+") + || value.starts_with("direct+") + || value.starts_with("path+") + }) + }) + { + return Ok(None); + } + if pep751 && (package.contains_key("vcs") || package.contains_key("directory")) { + return Ok(None); + } + let location = artifact.location(); + let filename = location + .split(['?', '#']) + .next() + .unwrap_or(&location) + .rsplit('/') + .next() + .unwrap_or(&location); + let wheel = filename.ends_with(".whl"); + if !wheel + && !filename.ends_with(".tar.gz") + && !filename.ends_with(".zip") + && !filename.ends_with(".tar.bz2") + && !filename.ends_with(".tar.xz") + { + return Err("patch artifact is not a Python distribution archive".to_string()); + } + for key in ["sdist", "wheel", "wheels", "archive"] { + package.remove(key); + } + if pep751 { + package.remove("index"); + let hashes = inline(&[("sha256", Value::from(sha256))]); + package.insert( + "archive", + Item::Value(inline(&[ + (artifact.key(), Value::from(location)), + ("hashes", hashes), + ])), + ); + } else { + let source = if legacy { + match artifact { + ArtifactSource::Url(_) => Item::Value(Value::from(format!("direct+{location}"))), + ArtifactSource::Path(_) => return Err("uv 0.1 lockfiles require absolute file URLs; portable vendoring needs uv >=0.2".to_string()), + } + } else { + Item::Value(inline(&[(artifact.key(), Value::from(location.clone()))])) + }; + package.insert("source", source.clone()); + let artifact_key = if matches!(artifact, ArtifactSource::Path(_)) && wheel { + "filename" + } else { + "url" + }; + let artifact_location = if artifact_key == "filename" { + filename + } else { + &location + }; + let entry = inline(&[ + (artifact_key, Value::from(artifact_location)), + ("hash", Value::from(format!("sha256:{sha256}"))), + ]); + if legacy && wheel { + let mut table = Table::new(); + table["url"] = toml_edit::value(artifact_location); + table["hash"] = toml_edit::value(format!("sha256:{sha256}")); + let mut array = ArrayOfTables::new(); + array.push(table); + package.insert("wheel", Item::ArrayOfTables(array)); + } else if wheel { + let mut array = Array::new(); + array.push_formatted(entry); + package.insert("wheels", Item::Value(Value::Array(array))); + } else { + package.insert("sdist", Item::Value(entry)); + } + if let Some(original_source) = original_source { + rewrite_references( + document.as_item_mut(), + &name, + version, + &original_source, + &source, + ); + } + } + if !pep751 && !legacy { + rewrite_manifest(&mut document, &name, artifact); + } + Ok(Some(document.to_string())) +} + +#[cfg(test)] +mod tests { + use super::{is_python_lock_name, rewrite_python_lock, ArtifactSource}; + + const URL: &str = "https://patch.socket.dev/pkg/urllib3-1.26.18-py2.py3-none-any.whl"; + const SHA256: &str = "ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6"; + const NATIVE: &str = r#"version = 1 +revision = 3 + +[[package]] +name = "project" +version = "1" +source = { virtual = "." } +dependencies = [ + { name = "urllib3", version = "1.26.18", source = {registry='https://pypi.org/simple'} }, + { name = "urllib3", version = "2.0.0", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "urllib3" +version = "1.26.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://pypi.org/urllib3-1.26.18.tar.gz", hash = "sha256:old", size = 123 } +wheels = [{ url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:old", size = 123, upload-time = "2023-10-17T17:47:01.725Z" }] + +[[package]] +name = "urllib3" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://pypi.org/urllib3-2.0.0-py3-none-any.whl", hash = "sha256:other" }] +"#; + + #[test] + fn hosted_native_uses_direct_source_and_keeps_other_versions() { + let rewritten = rewrite_python_lock( + NATIVE, + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256, + ) + .unwrap() + .unwrap(); + let document: toml_edit::DocumentMut = rewritten.parse().unwrap(); + let packages = document["package"].as_array_of_tables().unwrap(); + assert_eq!( + packages.get(1).unwrap()["source"]["url"].as_str(), + Some(URL) + ); + assert!(packages.get(1).unwrap().get("sdist").is_none()); + assert_eq!( + packages.get(1).unwrap()["wheels"].as_array().unwrap().len(), + 1 + ); + assert!(!rewritten.contains("upload-time")); + assert!(!rewritten.contains("size = 123")); + assert_eq!( + packages.get(0).unwrap()["dependencies"][0]["source"]["url"].as_str(), + Some(URL) + ); + assert_eq!( + packages.get(0).unwrap()["dependencies"][1]["source"]["registry"].as_str(), + Some("https://pypi.org/simple") + ); + assert!(rewritten.contains("urllib3-2.0.0-py3-none-any.whl")); + assert_eq!( + rewrite_python_lock( + &rewritten, + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256 + ) + .unwrap() + .unwrap(), + rewritten + ); + } + + #[test] + fn hosted_source_archive_never_occupies_a_wheel_slot() { + let url = "https://patch.socket.dev/pkg/urllib3-1.26.18.tar.gz"; + let rewritten = rewrite_python_lock( + NATIVE, + "urllib3", + "1.26.18", + ArtifactSource::Url(url), + SHA256, + ) + .unwrap() + .unwrap(); + let document: toml_edit::DocumentMut = rewritten.parse().unwrap(); + let package = document["package"] + .as_array_of_tables() + .unwrap() + .get(1) + .unwrap(); + assert!(package.get("wheels").is_none()); + assert_eq!(package["sdist"]["url"].as_str(), Some(url)); + } + + #[test] + fn legacy_distribution_updates_source_qualified_edges() { + let text = r#"version = 1 +[[distribution]] +name = "project" +version = "1" +source = "directory+file:///project" +[[distribution.dependencies]] +name = "urllib3" +version = "1.26.18" +source = "registry+https://pypi.org/simple" +[[distribution]] +name = "urllib3" +version = "1.26.18" +source = "registry+https://pypi.org/simple" +[distribution.sdist] +url = "https://pypi.org/urllib3-1.26.18.tar.gz" +hash = "sha256:old" +[[distribution.wheel]] +url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl" +hash = "sha256:old" +"#; + let rewritten = + rewrite_python_lock(text, "urllib3", "1.26.18", ArtifactSource::Url(URL), SHA256) + .unwrap() + .unwrap(); + assert_eq!(rewritten.matches(&format!("direct+{URL}")).count(), 2); + assert!(!rewritten.contains("registry+")); + assert!(!rewritten.contains("distribution.sdist")); + assert!(rewritten.contains("[[distribution.wheel]]")); + assert_eq!( + rewrite_python_lock( + &rewritten, + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256 + ) + .unwrap() + .unwrap(), + rewritten + ); + } + + #[test] + fn pep751_replaces_registry_artifacts_with_one_archive() { + let text = r#"lock-version = "1.0" +created-by = "uv" +[[packages]] +name = "urllib3" +version = "1.26.18" +marker = "python_version >= '3.9'" +sdist = { url = "https://pypi.org/urllib3.tar.gz", hashes = {sha256 = "old"} } +wheels = [{url = "https://pypi.org/urllib3.whl", hashes = {sha256 = "old"}}] +"#; + for source in [ + ArtifactSource::Url(URL), + ArtifactSource::Path(".socket/vendor/pypi/id/urllib3-1.26.18-py2.py3-none-any.whl"), + ] { + let rewritten = rewrite_python_lock(text, "urllib3", "1.26.18", source, SHA256) + .unwrap() + .unwrap(); + let document: toml_edit::DocumentMut = rewritten.parse().unwrap(); + let package = document["packages"] + .as_array_of_tables() + .unwrap() + .get(0) + .unwrap(); + assert!(package.get("sdist").is_none()); + assert!(package.get("wheels").is_none()); + assert_eq!( + package["archive"][source.key()].as_str(), + Some(source.location().as_str()) + ); + assert_eq!( + package["archive"]["hashes"]["sha256"].as_str(), + Some(SHA256) + ); + assert_eq!(package["marker"].as_str(), Some("python_version >= '3.9'")); + } + } + + #[test] + fn rejects_unknown_versions_and_ambiguous_sources() { + assert!(rewrite_python_lock( + "version = 2", + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256 + ) + .is_err()); + assert!(rewrite_python_lock( + "lock-version = '2.0'", + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256 + ) + .is_err()); + assert!(rewrite_python_lock( + &NATIVE.replace("2.0.0", "1.26.18"), + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256 + ) + .is_err()); + assert!(rewrite_python_lock( + NATIVE, + "urllib3", + "1.26.19", + ArtifactSource::Url(URL), + SHA256 + ) + .unwrap() + .is_none()); + assert!(rewrite_python_lock("version = 1\n[[package]]\nname='urllib3'\nversion='1.26.18'\nsource={git='https://example.test/repo'}", "urllib3", "1.26.18", ArtifactSource::Url(URL), SHA256).unwrap().is_none()); + } + + #[test] + fn script_manifest_tracks_direct_and_transitive_sources() { + for (dependency, expected_key) in [("urllib3", "requirements"), ("requests", "overrides")] { + let native = NATIVE.replace( + "name = \"urllib3\"\nversion = \"2.0.0\"", + "name = \"unrelated\"\nversion = \"2.0.0\"", + ); + let text = native.replacen("[[package]]", &format!("[manifest]\nrequirements = [{{name=\"{dependency}\", specifier=\"==1.26.18\", extras=[\"socks\"], marker=\"python_version >= '3.9'\"}}]\n\n[[package]]"), 1); + let rewritten = rewrite_python_lock( + &text, + "urllib3", + "1.26.18", + ArtifactSource::Url(URL), + SHA256, + ) + .unwrap() + .unwrap(); + let document: toml_edit::DocumentMut = rewritten.parse().unwrap(); + let requirement = document["manifest"][expected_key][0] + .as_inline_table() + .unwrap(); + assert_eq!(requirement["url"].as_str(), Some(URL)); + assert!(requirement.get("specifier").is_none()); + if dependency == "urllib3" { + assert_eq!( + requirement["extras"] + .as_array() + .unwrap() + .get(0) + .unwrap() + .as_str(), + Some("socks") + ); + assert_eq!( + requirement["marker"].as_str(), + Some("python_version >= '3.9'") + ); + } + } + } + + #[test] + fn refuses_global_sources_for_marker_separated_versions() { + let text = NATIVE.replacen("[[package]]", "[manifest]\nrequirements=[{name='urllib3',specifier='==1.26.18',marker=\"python_version < '3.10'\"},{name='urllib3',specifier='==2.0.0',marker=\"python_version >= '3.10'\"}]\n[[package]]", 1); + for artifact in [ + ArtifactSource::Url(URL), + ArtifactSource::Path(".socket/vendor/pypi/id/urllib3-1.26.18-py2.py3-none-any.whl"), + ] { + assert!( + rewrite_python_lock(&text, "urllib3", "1.26.18", artifact, SHA256) + .unwrap_err() + .contains("multiple versions") + ); + } + } + + #[test] + fn discovers_supported_lock_filenames_only() { + for name in [ + "uv.lock", + "example.py.lock", + "pylock.toml", + "pylock.dev.toml", + ] { + assert!(is_python_lock_name(name)); + } + for name in ["poetry.lock", "script.py", "uv.lock.bak", "pylock.toml.bak"] { + assert!(!is_python_lock_name(name)); + } + } +} diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs new file mode 100644 index 00000000..00d4384b --- /dev/null +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -0,0 +1,347 @@ +use std::ops::Range; + +use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::python_lock::ArtifactSource; + +pub(crate) fn script_metadata(text: &str) -> Result<(Range, String), String> { + let mut offset = 0; + let mut start = None; + let mut metadata = String::new(); + let mut block = None; + for line in text.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed == "# /// script" { + if start.is_some() || block.is_some() { + return Err("multiple PEP 723 script metadata blocks".to_string()); + } + start = Some(offset + line.len()); + } else if let Some(begin) = start { + if trimmed == "# ///" { + block = Some((begin..offset, metadata.clone())); + start = None; + } else { + let content = trimmed + .strip_prefix("# ") + .or_else(|| trimmed.strip_prefix('#')) + .ok_or_else(|| "invalid PEP 723 metadata comment".to_string())?; + metadata.push_str(content); + metadata.push('\n'); + } + } + offset += line.len(); + } + if start.is_some() { + return Err("unclosed PEP 723 script metadata block".to_string()); + } + block.ok_or_else(|| "script has no PEP 723 metadata block".to_string()) +} + +pub(crate) fn replace_script_metadata(text: &str, metadata: &str) -> Result { + let (span, _) = script_metadata(text)?; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let mut replacement = String::new(); + for line in metadata.lines() { + replacement.push('#'); + if !line.is_empty() { + replacement.push(' '); + replacement.push_str(line); + } + replacement.push_str(newline); + } + let mut output = text.to_string(); + output.replace_range(span, &replacement); + Ok(output) +} + +fn dependency_name(specifier: &str) -> String { + canonicalize_pypi_name( + specifier + .trim() + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' && ch != '.') + .next() + .unwrap_or_default(), + ) +} + +fn same_hosted_artifact(previous: &str, current: &str) -> bool { + let (Ok(previous), Ok(current)) = (reqwest::Url::parse(previous), reqwest::Url::parse(current)) + else { + return false; + }; + if previous.origin() != current.origin() + || previous.username() != current.username() + || previous.password() != current.password() + || previous.query() != current.query() + || previous.fragment() != current.fragment() + { + return false; + } + let previous_path: Vec<_> = previous.path_segments().into_iter().flatten().collect(); + let current_path: Vec<_> = current.path_segments().into_iter().flatten().collect(); + if previous_path.len() != current_path.len() || current_path.len() < 3 { + return false; + } + let grant_index = current_path.len() - 3; + previous_path[..grant_index] == current_path[..grant_index] + && previous_path[grant_index + 1..] == current_path[grant_index + 1..] + && uuid::Uuid::parse_str(previous_path[grant_index]).is_ok() + && uuid::Uuid::parse_str(current_path[grant_index]).is_ok() + && uuid::Uuid::parse_str(current_path[grant_index + 1]).is_ok() +} + +fn dotted_table() -> Item { + let mut table = Table::new(); + table.set_dotted(true); + Item::Table(table) +} + +fn rewrite_sources( + document: &mut DocumentMut, + name: &str, + version: &str, + artifact: ArtifactSource<'_>, + direct: bool, +) -> Result<(), String> { + let (key, location) = match artifact { + ArtifactSource::Url(location) => ("url", location), + ArtifactSource::Path(location) => ("path", location), + }; + let tool = document.entry("tool").or_insert(dotted_table()); + let uv = tool + .as_table_like_mut() + .ok_or("Python tool metadata must be a table")? + .entry("uv") + .or_insert(dotted_table()); + let uv = uv + .as_table_like_mut() + .ok_or("Python tool.uv metadata must be a table")?; + let sources = uv.entry("sources").or_insert(dotted_table()); + let sources = sources + .as_table_like_mut() + .ok_or("Python tool.uv.sources must be a table")?; + let existing = sources + .iter() + .find(|(candidate, _)| canonicalize_pypi_name(candidate) == name) + .map(|(key, value)| (key.to_string(), value.clone())); + if let Some((existing_name, existing)) = existing { + let previous = existing + .as_table_like() + .filter(|table| table.len() == 1) + .and_then(|table| table.get(key)) + .and_then(Item::as_str); + let same = previous.is_some_and(|previous| { + previous == location || (key == "url" && same_hosted_artifact(previous, location)) + }); + if !same { + return Err(format!("Python project already declares a source for {existing_name}; revert it before applying a different patch")); + } + if previous != Some(location) { + let mut source = InlineTable::new(); + source.insert(key, Value::from(location)); + source.fmt(); + sources.insert(&existing_name, Item::Value(Value::InlineTable(source))); + } + } else { + let mut source = InlineTable::new(); + source.insert(key, Value::from(location)); + source.fmt(); + sources.insert(name, Item::Value(Value::InlineTable(source))); + } + if !direct { + let specifier = format!("{name}=={version}"); + let overrides = uv + .entry("override-dependencies") + .or_insert(Item::Value(Value::Array(Array::new()))); + let overrides = overrides + .as_array_mut() + .ok_or("Python override-dependencies must be an array")?; + let existing = overrides + .iter() + .filter_map(Value::as_str) + .find(|spec| dependency_name(spec) == name) + .map(str::to_string); + if let Some(existing) = existing { + if existing != specifier { + return Err(format!( + "Python project already overrides {name}; revert it before applying a patch" + )); + } + } else { + overrides.push(specifier); + } + } + Ok(()) +} + +fn contains_dependency(item: Option<&Item>, name: &str) -> bool { + item.and_then(Item::as_array).is_some_and(|dependencies| { + dependencies + .iter() + .filter_map(Value::as_str) + .any(|specifier| dependency_name(specifier) == name) + }) +} + +pub fn rewrite_project_metadata( + text: &str, + name: &str, + version: &str, + artifact: ArtifactSource<'_>, +) -> Result, String> { + let mut document: DocumentMut = text + .parse() + .map_err(|error| format!("invalid pyproject.toml: {error}"))?; + let name = canonicalize_pypi_name(name); + let project = document + .get("project") + .and_then(Item::as_table_like) + .ok_or("pyproject.toml has no project table")?; + let direct = contains_dependency(project.get("dependencies"), &name) + || project + .get("optional-dependencies") + .and_then(Item::as_table_like) + .is_some_and(|groups| { + groups + .iter() + .any(|(_, group)| contains_dependency(Some(group), &name)) + }) + || document + .get("dependency-groups") + .and_then(Item::as_table_like) + .is_some_and(|groups| { + groups + .iter() + .any(|(_, group)| contains_dependency(Some(group), &name)) + }); + if document + .get("tool") + .and_then(Item::as_table_like) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table_like) + .is_some_and(|uv| uv.contains_key("workspace")) + { + return Err( + "hosted sources for uv workspaces require a package-scoped source mapping".to_string(), + ); + } + rewrite_sources(&mut document, &name, version, artifact, direct)?; + let output = document.to_string(); + Ok((output != text).then_some(output)) +} + +pub fn rewrite_script_metadata( + text: &str, + name: &str, + version: &str, + artifact: ArtifactSource<'_>, +) -> Result, String> { + let (_, metadata) = script_metadata(text)?; + let mut document: DocumentMut = metadata + .parse() + .map_err(|error| format!("invalid script metadata: {error}"))?; + let name = canonicalize_pypi_name(name); + let direct = document + .get("dependencies") + .and_then(Item::as_array) + .is_some_and(|deps| { + deps.iter() + .filter_map(Value::as_str) + .any(|spec| dependency_name(spec) == name) + }); + rewrite_sources(&mut document, &name, version, artifact, direct)?; + let output = replace_script_metadata(text, &document.to_string())?; + Ok((output != text).then_some(output)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sources_are_paired_without_changing_the_script() { + let script = "#!/usr/bin/env python3\n# /// script\n# dependencies = [\"urllib3==1.26.18\"]\n# ///\nprint('preserved')\n"; + let output = rewrite_script_metadata( + script, + "urllib3", + "1.26.18", + ArtifactSource::Path(".socket/vendor/pypi/patched.whl"), + ) + .unwrap() + .unwrap(); + assert!(output.starts_with("#!/usr/bin/env python3\n# /// script\n")); + assert!(output.ends_with("# ///\nprint('preserved')\n")); + let (_, metadata) = script_metadata(&output).unwrap(); + let document: DocumentMut = metadata.parse().unwrap(); + assert_eq!( + document["tool"]["uv"]["sources"]["urllib3"]["path"].as_str(), + Some(".socket/vendor/pypi/patched.whl") + ); + assert!(rewrite_script_metadata( + &output, + "urllib3", + "1.26.18", + ArtifactSource::Path(".socket/vendor/pypi/patched.whl") + ) + .unwrap() + .is_none()); + } + + #[test] + fn hosted_grant_refresh_preserves_patch_identity() { + let previous = "https://patch.socket.dev/pkg/pypi/11111111-1111-4111-8111-111111111111/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl"; + let current = previous.replace( + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ); + let script = "# /// script\n# dependencies = [\"urllib3==1.26.18\"]\n# ///\n"; + let old = + rewrite_script_metadata(script, "urllib3", "1.26.18", ArtifactSource::Url(previous)) + .unwrap() + .unwrap(); + let new = + rewrite_script_metadata(&old, "urllib3", "1.26.18", ArtifactSource::Url(¤t)) + .unwrap() + .unwrap(); + assert!(new.contains(¤t)); + assert!(!new.contains(previous)); + let different_patch = current.replace( + "e828efa5-5c6d-43f3-9909-03f5ac232b98", + "33333333-3333-4333-8333-333333333333", + ); + assert!(rewrite_script_metadata( + &old, + "urllib3", + "1.26.18", + ArtifactSource::Url(&different_patch) + ) + .is_err()); + let different_host = current.replace("patch.socket.dev", "example.test"); + assert!(rewrite_script_metadata( + &old, + "urllib3", + "1.26.18", + ArtifactSource::Url(&different_host) + ) + .is_err()); + } + + #[test] + fn transitive_sources_are_bound_to_an_override() { + let output = rewrite_script_metadata( + "# /// script\n# dependencies = [\"requests\"]\n# ///\n", + "urllib3", + "1.26.18", + ArtifactSource::Url("https://example.test/patch.whl"), + ) + .unwrap() + .unwrap(); + let (_, metadata) = script_metadata(&output).unwrap(); + let document: DocumentMut = metadata.parse().unwrap(); + assert_eq!( + document["tool"]["uv"]["override-dependencies"][0].as_str(), + Some("urllib3==1.26.18") + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index aa3a5452..73e55493 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -21,6 +21,7 @@ use std::collections::HashMap; use std::path::Path; use serde_json::Value; +use toml_edit::{DocumentMut, Item, TableLike, Value as TomlValue}; use crate::crawlers::composer_crawler::normalize_version; use crate::crawlers::python_crawler::canonicalize_pypi_name; @@ -1090,8 +1091,21 @@ fn parse_gem_spec_line(line: &str) -> Option<(String, String)> { /// DISCOVERY-only entries (no recorded URL; platform-independent wheel /// choice is not derivable offline). Pipenv/pdm locks: not yet read. async fn inventory_pypi_locks(project_root: &Path) -> Option> { - if let Some(out) = inventory_uv_lock(project_root).await { - return Some(out); + let mut out = Vec::new(); + let mut found = false; + if let Ok(paths) = crate::utils::python_lock::python_lock_paths(project_root) { + for path in paths { + let Ok(text) = read_regular_to_string(&project_root.join(path)).await else { + continue; + }; + if let Some(entries) = python_lock_inventory(&text) { + found = true; + out.extend(entries); + } + } + } + if found { + return Some(dedup_prefer_integrity(out)); } if let Some(out) = inventory_poetry_lock(project_root).await { return Some(out); @@ -1099,84 +1113,122 @@ async fn inventory_pypi_locks(project_root: &Path) -> Option> inventory_requirements_txt(project_root).await } -/// uv.lock: TOML `[[package]]` blocks with `name`/`version` and -/// `wheels = [{ url, hash = "sha256:…" }, …]` entries. -async fn inventory_uv_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("uv.lock")) - .await - .ok()?; - let mut out = Vec::new(); - // Line-oriented: uv emits `[[package]]` blocks; wheels live either as - // inline `{ url = "…", hash = "sha256:…" }` table rows or one-line - // arrays. A pure wheel ends `-none-any.whl` ([`pure_wheel_from_uv_unit`], - // the same rule the ledger recovery applies). - let mut name: Option = None; - let mut version: Option = None; - let mut sourced_registry = true; - let mut wheel: Option<(String, String)> = None; - let flush = |name: &mut Option, - version: &mut Option, - sourced_registry: &mut bool, - wheel: &mut Option<(String, String)>, - out: &mut Vec| { - if let (Some(n), Some(v)) = (name.take(), version.take()) { - let canonical = canonicalize_pypi_name(&n); - if *sourced_registry - && path_safety::is_safe_single_segment(&canonical) - && path_safety::is_safe_single_segment(&v) - { - let (resolved, integrity) = match wheel.take() { - Some((url, sha)) => (http_url(&url), LockIntegrity::Sha256Hex(sha)), - None => (None, LockIntegrity::None), - }; - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{canonical}@{v}"), - name: canonical, - version: v, - resolved, - integrity, - }); +fn python_archive(archive: &dyn TableLike) -> Option<(String, String)> { + let url = archive.get("url")?.as_str()?; + if !url.split(['?', '#']).next()?.ends_with("-none-any.whl") { + return None; + } + let sha = archive + .get("hash") + .and_then(Item::as_str) + .and_then(|value| value.strip_prefix("sha256:")) + .or_else(|| { + archive + .get("hashes")? + .as_table_like()? + .get("sha256")? + .as_str() + })?; + if !is_hex_of_len(sha, 64) { + return None; + } + Some((http_url(url)?, sha.to_ascii_lowercase())) +} + +fn python_package_archive(package: &dyn TableLike) -> Option<(String, String)> { + if let Some(archive) = package + .get("archive") + .and_then(Item::as_table_like) + .and_then(python_archive) + { + return Some(archive); + } + if let Some(wheels) = package.get("wheels").and_then(Item::as_array) { + for wheel in wheels.iter().filter_map(TomlValue::as_inline_table) { + if let Some(archive) = python_archive(wheel) { + return Some(archive); + } + } + } + if let Some(wheels) = package.get("wheel").and_then(Item::as_array_of_tables) { + for wheel in wheels.iter() { + if let Some(archive) = python_archive(wheel) { + return Some(archive); } } - *sourced_registry = true; - *wheel = None; + } + None +} + +fn python_lock_inventory(text: &str) -> Option> { + let document: DocumentMut = text.parse().ok()?; + let pep751 = document.get("lock-version").is_some(); + let collection = if pep751 { + if document.get("lock-version").and_then(Item::as_str) != Some("1.0") { + return None; + } + "packages" + } else { + if document.get("version").and_then(Item::as_integer) != Some(1) { + return None; + } + if document.contains_key("distribution") { + "distribution" + } else { + "package" + } }; - for line in text.lines() { - let t = line.trim(); - if t == "[[package]]" { - flush( - &mut name, - &mut version, - &mut sourced_registry, - &mut wheel, - &mut out, - ); + let mut out = Vec::new(); + let packages = document.get(collection)?.as_array_of_tables()?; + for package in packages.iter() { + let Some(name) = package + .get("name") + .and_then(Item::as_str) + .map(canonicalize_pypi_name) + else { + continue; + }; + let Some(version) = package.get("version").and_then(Item::as_str) else { + continue; + }; + if !path_safety::is_safe_single_segment(&name) + || !path_safety::is_safe_single_segment(version) + { continue; } - if let Some(v) = t.strip_prefix("name = ") { - name = Some(v.trim_matches('"').to_string()); - } else if let Some(v) = t.strip_prefix("version = ") { - version = Some(v.trim_matches('"').to_string()); - } else if t.starts_with("source = ") { - // Registry packages: `source = { registry = "…" }`; editable/ - // virtual/path/git sources are not fetchable artifacts. - sourced_registry = t.contains("registry"); - } else if wheel.is_none() { - // One line may hold several `{ url = "…", hash = "sha256:…" }` - // wheels (one-line arrays); pair the pure wheel with ITS OWN - // hash, never the line's first url/hash. - wheel = pure_wheel_from_uv_unit(t); + let remote = if pep751 { + !package.contains_key("vcs") + && !package.contains_key("directory") + && !package + .get("archive") + .and_then(Item::as_table_like) + .is_some_and(|archive| archive.contains_key("path")) + } else { + package.get("source").is_some_and(|source| { + source.as_str().is_some_and(|value| { + value.starts_with("registry+") || value.starts_with("direct+") + }) || source.as_table_like().is_some_and(|table| { + table.contains_key("registry") || table.contains_key("url") + }) + }) + }; + if !remote { + continue; } + let (resolved, integrity) = match python_package_archive(package) { + Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)), + None => (None, LockIntegrity::None), + }; + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{name}@{version}"), + name, + version: version.to_string(), + resolved, + integrity, + }); } - flush( - &mut name, - &mut version, - &mut sourced_registry, - &mut wheel, - &mut out, - ); - Some(dedup_prefer_integrity(out)) + Some(out) } /// poetry.lock: `[[package]]` blocks with `name`/`version` — discovery @@ -1401,6 +1453,31 @@ pub async fn recover_lock_entry( .to_string(), ); } + for wiring in entry + .wiring + .iter() + .filter(|wiring| wiring.kind == "python_lock_document") + { + if let Some(text) = wiring.original.as_ref().and_then(Value::as_str) { + if let Some(entries) = python_lock_inventory(text) { + if let Some(resolution) = entries.into_iter().find(|candidate| { + candidate.name == name + && candidate.version == version + && candidate.resolved.is_some() + && candidate.integrity != LockIntegrity::None + }) { + return Ok(resolution); + } + } + } + } + if entry + .wiring + .iter() + .any(|wiring| wiring.kind == "python_lock_document") + { + return Err("the pre-vendor Python lock has no hash-pinned pure wheel for this package; reinstall it before repair".to_string()); + } // Every pypi package manager records the pre-vendor resolution under // its own wiring kind — uv writes `uv_lock_package`, pdm // `pdm_lock_package`, poetry `poetry_lock_package`, pipenv @@ -1410,6 +1487,7 @@ pub async fn recover_lock_entry( entry, &[ "uv_lock_package", + "python_lock_document", "pdm_lock_package", "poetry_lock_package", "pipenv_lock_entry", @@ -1458,6 +1536,73 @@ pub async fn wired_vendor_integrity( ) -> Option { let rel = artifact_rel.trim_start_matches("./"); + if rel.starts_with(".socket/vendor/pypi/") { + let mut pinned = None; + for path in crate::utils::python_lock::python_lock_paths(project_root).ok()? { + let Ok(text) = read_regular_to_string(&project_root.join(path)).await else { + continue; + }; + let Ok(document) = text.parse::() else { + continue; + }; + let collection = if document.contains_key("lock-version") { + "packages" + } else { + "package" + }; + let Some(packages) = document.get(collection).and_then(Item::as_array_of_tables) else { + continue; + }; + for package in packages.iter() { + let archive = package.get("archive").and_then(Item::as_table_like); + let source = + archive.or_else(|| package.get("source").and_then(Item::as_table_like)); + if source + .and_then(|source| source.get("path")) + .and_then(Item::as_str) + .is_none_or(|path| path.trim_start_matches("./") != rel) + { + continue; + } + let sha = if let Some(archive) = archive { + archive + .get("hashes") + .and_then(Item::as_table_like) + .and_then(|hashes| hashes.get("sha256")) + .and_then(Item::as_str) + } else { + package + .get("wheels") + .and_then(Item::as_array) + .and_then(|wheels| { + wheels + .iter() + .filter_map(TomlValue::as_inline_table) + .find_map(|wheel| { + if wheel.get("filename").and_then(TomlValue::as_str) + != rel.rsplit('/').next() + { + return None; + } + wheel + .get("hash") + .and_then(TomlValue::as_str) + .and_then(|value| value.strip_prefix("sha256:")) + }) + }) + }; + let sha = sha + .filter(|sha| is_hex_of_len(sha, 64)) + .map(str::to_ascii_lowercase)?; + if pinned.as_ref().is_some_and(|previous| previous != &sha) { + return None; + } + pinned = Some(sha); + } + } + return pinned.map(LockIntegrity::Sha256Hex); + } + // JSON locks: resolved == "file:" (npm writes exactly this form). for lock in ["npm-shrinkwrap.json", "package-lock.json"] { let Ok(bytes) = read_regular(&project_root.join(lock)).await else { @@ -2891,6 +3036,69 @@ checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert_eq!(rack.integrity, LockIntegrity::Sha256Hex("c".repeat(64))); } + #[tokio::test] + async fn inventories_script_and_pylock_files_without_installed_packages() { + let tmp = tempfile::tempdir().unwrap(); + let sha = "a".repeat(64); + write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{registry='https://pypi.org/simple'}}\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; + write(tmp.path(), "pylock.dev.toml", &format!("lock-version='1.0'\n[[packages]]\nname='bravo'\nversion='2'\narchive={{url='https://pypi.org/bravo-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n[[packages]]\nname='local'\nversion='1'\narchive={{path='.socket/vendor/pypi/uuid/local-1-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n")).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!( + entry(&entries, "alpha").integrity, + LockIntegrity::Sha256Hex(sha.clone()) + ); + assert_eq!( + entry(&entries, "bravo").integrity, + LockIntegrity::Sha256Hex(sha) + ); + assert!(!entries.iter().any(|entry| entry.name == "local")); + } + + #[tokio::test] + async fn pylock_repair_uses_the_exact_artifact_hash_and_refuses_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let path = ".socket/vendor/pypi/uuid/alpha-1-py3-none-any.whl"; + let sha = "a".repeat(64); + let pylock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\narchive={{path='{path}',hashes={{sha256='{sha}'}}}}\n"); + write(tmp.path(), "pylock.toml", &pylock).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), path).await, + Some(LockIntegrity::Sha256Hex(sha.clone())) + ); + assert_eq!( + wired_vendor_integrity(tmp.path(), &format!("{path}.other")).await, + None + ); + write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{path='{path}'}}\nwheels=[{{filename='alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), path).await, + Some(LockIntegrity::Sha256Hex(sha.clone())) + ); + write( + tmp.path(), + "pylock.toml", + &pylock.replace(&sha, &"b".repeat(64)), + ) + .await; + assert_eq!(wired_vendor_integrity(tmp.path(), path).await, None); + } + + #[test] + fn legacy_and_pep751_archive_hashes_stay_with_their_own_wheels() { + let sha = "b".repeat(64); + let legacy = format!("version=1\n[[distribution]]\nname='alpha'\nversion='1'\nsource='registry+https://pypi.org/simple'\n[[distribution.wheel]]\nurl='https://pypi.org/alpha-1-py3-none-any.whl'\nhash='sha256:{sha}'\n"); + assert_eq!( + python_lock_inventory(&legacy).unwrap()[0].integrity, + LockIntegrity::Sha256Hex(sha.clone()) + ); + let lock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl'}},{{url='https://pypi.org/alpha-1-cp312-cp312-macosx.whl',hashes={{sha256='{sha}'}}}}]\n"); + let entries = python_lock_inventory(&lock).unwrap(); + assert_eq!(entries[0].integrity, LockIntegrity::None); + assert_eq!(entries[0].resolved, None); + assert!(python_lock_inventory("version=2\n[[package]]\nname='x'\nversion='1'").is_none()); + } + #[tokio::test] async fn uv_lock_inventories_pure_wheels() { let tmp = tempfile::tempdir().unwrap(); @@ -3279,7 +3487,10 @@ source = { editable = "." } LockIntegrity::Sha256Hex("d".repeat(64)), "the [metadata] line's checksum must not bleed into the last block" ); - assert!(!entries.iter().any(|e| e.name.contains("..")), "{entries:?}"); + assert!( + !entries.iter().any(|e| e.name.contains("..")), + "{entries:?}" + ); assert!(!entries.iter().any(|e| e.name == "foo"), "{entries:?}"); } @@ -3533,7 +3744,12 @@ packages: "[metadata]\nlock-version = \"2.0\"\n", ) .await; - write(tmp.path(), "requirements.txt", "requests==2.31.0\nbad==vNaN\n").await; + write( + tmp.path(), + "requirements.txt", + "requests==2.31.0\nbad==vNaN\n", + ) + .await; let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); assert_eq!( sorted_pairs(&entries), @@ -3557,7 +3773,8 @@ packages: /// http(s) all yield None — fail-closed, never a guessed pairing. #[tokio::test] async fn pure_wheel_rejects_short_hash_missing_hash_and_non_http_url() { - let short = "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\", hash = \"sha256:abcd\" }]"; + let short = + "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\", hash = \"sha256:abcd\" }]"; assert_eq!(pure_wheel_from_uv_unit(short), None, "short hash"); let hashless = "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\" }]"; @@ -3655,6 +3872,23 @@ mod recover_tests { } } + #[tokio::test] + async fn python_document_recovery_selects_the_requested_package() { + let tmp = tempfile::tempdir().unwrap(); + let sha = "c".repeat(64); + let lock = format!("lock-version='1.0'\n[[packages]]\nname='other'\nversion='1'\narchive={{url='https://pypi.org/other-1-py3-none-any.whl',hashes={{sha256='{}'}}}}\n[[packages]]\nname='target'\nversion='2'\narchive={{url='https://pypi.org/target-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n", "d".repeat(64)); + let record = rec("python_lock_document", serde_json::json!(lock)); + let ledger = entry("pypi", "pkg:pypi/target@2", vec![record.clone()]); + let recovered = recover_lock_entry(tmp.path(), &ledger).await.unwrap(); + assert_eq!( + recovered.resolved.as_deref(), + Some("https://pypi.org/target-2-py3-none-any.whl") + ); + assert_eq!(recovered.integrity, LockIntegrity::Sha256Hex(sha)); + let absent = entry("pypi", "pkg:pypi/target@3", vec![record]); + assert!(recover_lock_entry(tmp.path(), &absent).await.is_err()); + } + #[tokio::test] async fn npm_lock_entry_fragment_recovers_sri_and_url() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index abf9f232..72fef912 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -67,6 +67,7 @@ pub mod nuget_feed; pub mod pnpm_lock; pub mod pnpm_lock_legacy; pub mod pypi; +mod pypi_lock; pub mod pypi_pdm; pub mod pypi_pipenv; pub mod pypi_poetry; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index e660c647..ecf73eab 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -10,6 +10,7 @@ use std::path::Path; use sha2::{Digest as _, Sha256}; +use crate::api::client::ApiClient; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; @@ -43,6 +44,7 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo enum PypiFlavor { /// `uv.lock`-managed project → paired pyproject + lock surgery. UvProject, + PythonLocks, /// `poetry.lock`-managed project → lock-only `[[package]]` splice. Poetry, /// `pdm.lock`-managed project → lock-only `[[package]]` splice. @@ -57,6 +59,7 @@ impl PypiFlavor { fn as_str(self) -> &'static str { match self { PypiFlavor::UvProject => "uv", + PypiFlavor::PythonLocks => "python-lock", PypiFlavor::Poetry => "poetry", PypiFlavor::Pdm => "pdm", PypiFlavor::Pipenv => "pipenv", @@ -65,6 +68,65 @@ impl PypiFlavor { } } +fn validate_hosted_wheel_sha256(sha256: &str) -> Result<(), String> { + if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("hosted wheel sha256 must be 64 hexadecimal characters".to_string()); + } + Ok(()) +} + +fn decode_hosted_wheel_metadata(bytes: &[u8], sha256: &str) -> Result, String> { + validate_hosted_wheel_sha256(sha256)?; + if !hex::encode(Sha256::digest(bytes)).eq_ignore_ascii_case(sha256) { + return Err("hosted wheel sha256 does not match the published artifact".to_string()); + } + let metadata = super::pypi_uv::wheel_metadata_text(bytes) + .ok_or_else(|| "hosted wheel has no readable size-bounded core metadata".to_string())?; + let headers: Vec<_> = metadata + .lines() + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .collect(); + for required in ["Metadata-Version", "Name", "Version"] { + if !headers + .iter() + .any(|(name, value)| name.eq_ignore_ascii_case(required) && !value.trim().is_empty()) + { + return Err(format!("hosted wheel core metadata is missing {required}")); + } + } + let rendered = super::pypi_uv::render_package_metadata_block(&metadata); + if rendered.is_none() + && headers.iter().any(|(name, _)| { + name.eq_ignore_ascii_case("Requires-Dist") + || name.eq_ignore_ascii_case("Provides-Extra") + }) + { + return Err( + "hosted wheel dependency metadata cannot be represented in a uv lockfile".to_string(), + ); + } + if let Some(rendered) = &rendered { + rendered + .parse::() + .map_err(|error| format!("hosted wheel dependency metadata is invalid: {error}"))?; + } + Ok(rendered) +} + +pub async fn fetch_hosted_wheel_metadata( + client: &ApiClient, + url: &str, + sha256: &str, +) -> Result, String> { + validate_hosted_wheel_sha256(sha256)?; + let bytes = client + .download_artifact(url) + .await + .map_err(|error| format!("cannot fetch hosted wheel metadata: {error}"))?; + decode_hosted_wheel_metadata(&bytes, sha256) +} + const SETUP_ALTERNATIVE: &str = "use the `socket-patch setup` .pth install hook instead, which patches installed \ site-packages without lockfile edits"; @@ -101,6 +163,7 @@ async fn read_regular_to_string(path: &Path) -> std::io::Result { /// stale-but-valid, which is otherwise invisible. async fn detect_pypi_flavor( project_root: &Path, + target: Option<(&str, &str)>, ) -> Result<(PypiFlavor, Vec), (&'static str, String)> { let exists = |name: &str| { let p = project_root.join(name); @@ -113,7 +176,7 @@ async fn detect_pypi_flavor( let has_pipfile = exists("Pipfile").await; // Coexisting tool locks: wire the precedence winner, warn about the rest. - let present: Vec<&str> = [ + let mut present: Vec<&str> = [ ("uv.lock", has_uv_lock), ("poetry.lock", has_poetry_lock), ("pdm.lock", has_pdm_lock), @@ -122,7 +185,46 @@ async fn detect_pypi_flavor( .into_iter() .filter_map(|(name, present)| present.then_some(name)) .collect(); + let additional_locks: Vec = crate::utils::python_lock::python_lock_paths(project_root) + .map_err(|error| ("pypi_lock_read_failed", error.to_string()))? + .into_iter() + .filter(|path| path != "uv.lock") + .collect(); let mut warnings = Vec::new(); + let matching_additional_lock = if has_uv_lock { + false + } else if let Some((name, version)) = target { + super::pypi_lock::contains_target(project_root, &additional_locks, name, version).await? + } else { + !additional_locks.is_empty() + }; + if !has_uv_lock && matching_additional_lock { + if exists("requirements.txt").await { + present.push("requirements.txt"); + } + if !present.is_empty() { + warnings.push(VendorWarning::new( + "pypi_multiple_lockfiles", + format!( + "wiring {}; installs driven by {} retain their existing sources", + additional_locks.join(", "), + present.join(", ") + ), + )); + } + return Ok((PypiFlavor::PythonLocks, warnings)); + } + if has_uv_lock { + present.extend(additional_locks.iter().map(String::as_str)); + } else if !additional_locks.is_empty() { + warnings.push(VendorWarning::new( + "pypi_unmatched_lockfiles", + format!( + "{} do not contain this package version; their sources are unchanged", + additional_locks.join(", ") + ), + )); + } if present.len() > 1 { let winner = present[0]; let losers = present[1..].join(", "); @@ -224,6 +326,7 @@ async fn detect_pypi_flavor( /// project is reused so the lock is parsed once). enum WiringPlan { Uv(Box), + PythonLocks(super::pypi_lock::PythonLocks), Requirements, Poetry(Box), Pdm(Box), @@ -359,10 +462,11 @@ pub async fn vendor_pypi( ); }; - let (flavor, flavor_warnings) = match detect_pypi_flavor(project_root).await { - Ok(f) => f, - Err((code, detail)) => return refused(code, detail), - }; + let (flavor, flavor_warnings) = + match detect_pypi_flavor(project_root, Some((&canon_name, version))).await { + Ok(f) => f, + Err((code, detail)) => return refused(code, detail), + }; // Pre-flight the wiring guards BEFORE building anything, so refusals // leave the tree byte-untouched. @@ -394,6 +498,25 @@ pub async fn vendor_pypi( Err((code, detail)) => return refused(code, detail), } } + PypiFlavor::PythonLocks => { + let project = match super::pypi_lock::load_python_locks( + project_root, + &canon_name, + version, + &record.uuid, + ) + .await + { + Ok(project) => project, + Err((code, detail)) => return refused(code, detail), + }; + if project.in_sync { + wired_pin = project.pin; + WiringPlan::InSync + } else { + WiringPlan::PythonLocks(project) + } + } PypiFlavor::Requirements => { match preflight_requirements(project_root, &canon_name, version, &record.uuid).await { Ok(RequirementsTarget::InSync { pin }) => { @@ -551,6 +674,9 @@ pub async fn vendor_pypi( if platform_locked { let per_flavor = match flavor { PypiFlavor::UvProject => "uv.lock now resolves it from this single-platform wheel only", + PypiFlavor::PythonLocks => { + "Python lockfiles now resolve it from this single-platform wheel only" + } PypiFlavor::Poetry => { "poetry.lock now resolves it from this single-platform wheel only" } @@ -638,6 +764,16 @@ pub async fn vendor_pypi( ) .await .map(|(wiring, meta)| (wiring, MetaSlot::Uv(Some(meta)))), + WiringPlan::PythonLocks(project) => super::pypi_lock::wire_python_locks( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|wiring| (wiring, MetaSlot::None)), WiringPlan::Requirements => wire_requirements( project_root, &canon_name, @@ -748,6 +884,9 @@ pub async fn revert_pypi_opts( } = opts; let mut outcome = match entry.flavor.as_deref() { Some("uv") => revert_uv(entry, project_root, dry_run).await, + Some("python-lock") => { + super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await + } Some("requirements") => revert_requirements(entry, project_root, dry_run).await, Some("poetry") => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, Some("pdm") => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, @@ -1110,7 +1249,7 @@ mod tests { async fn flavor_routing_table_v2_precedence() { let flavor = |tmp: &Path| { let tmp = tmp.to_path_buf(); - async move { detect_pypi_flavor(&tmp).await.map(|(f, _)| f) } + async move { detect_pypi_flavor(&tmp, None).await.map(|(f, _)| f) } }; // 1. uv.lock wins outright (even over requirements + other markers). @@ -1118,6 +1257,13 @@ mod tests { touch(tmp.path(), "uv.lock", "version = 1\n").await; touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::UvProject); + touch(tmp.path(), "pylock.toml", "lock-version = \"1.0\"\n").await; + let (selected, warnings) = detect_pypi_flavor(tmp.path(), None).await.unwrap(); + assert_eq!(selected, PypiFlavor::UvProject); + assert!(warnings + .iter() + .any(|warning| warning.code == "pypi_multiple_lockfiles" + && warning.detail.contains("pylock.toml"))); // 2-4. Tool locks route to their flavors. let tmp = tempfile::tempdir().unwrap(); @@ -1136,7 +1282,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "poetry.lock", "").await; touch(tmp.path(), "Pipfile.lock", "{}").await; - let (f, warnings) = detect_pypi_flavor(tmp.path()).await.unwrap(); + let (f, warnings) = detect_pypi_flavor(tmp.path(), None).await.unwrap(); assert_eq!(f, PypiFlavor::Poetry); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].code, "pypi_multiple_lockfiles"); @@ -1154,7 +1300,7 @@ mod tests { "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n", ) .await; - let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + let err = detect_pypi_flavor(tmp.path(), None).await.unwrap_err(); assert_eq!(err.0, "pypi_uv_no_lockfile"); assert!(err.1.contains("uv lock")); assert!(err.1.contains("socket-patch setup")); @@ -1166,21 +1312,21 @@ mod tests { "[tool.poetry]\nname = \"x\"\n", ) .await; - let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + let err = detect_pypi_flavor(tmp.path(), None).await.unwrap_err(); assert_eq!(err.0, "pypi_poetry_no_lockfile"); assert!(err.1.contains("poetry lock")); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "pyproject.toml", "[tool.pdm]\n").await; assert_eq!( - detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + detect_pypi_flavor(tmp.path(), None).await.unwrap_err().0, "pypi_pdm_no_lockfile" ); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "Pipfile", "").await; assert_eq!( - detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + detect_pypi_flavor(tmp.path(), None).await.unwrap_err().0, "pypi_pipenv_no_lockfile" ); @@ -1212,13 +1358,13 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n").await; assert_eq!( - detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + detect_pypi_flavor(tmp.path(), None).await.unwrap_err().0, "pypi_pyproject_only" ); // 8. nothing at all. let tmp = tempfile::tempdir().unwrap(); - let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + let err = detect_pypi_flavor(tmp.path(), None).await.unwrap_err(); assert_eq!(err.0, "pypi_no_requirements"); assert!(err.1.contains("socket-patch setup")); } @@ -1260,7 +1406,7 @@ mod tests { // the runtime waits for on shutdown; connect a writer to release // it so the test can FAIL instead of hanging the whole suite. let deadline = std::time::Duration::from_secs(5); - let Ok(routed) = tokio::time::timeout(deadline, detect_pypi_flavor(tmp.path())).await + let Ok(routed) = tokio::time::timeout(deadline, detect_pypi_flavor(tmp.path(), None)).await else { let _ = std::fs::OpenOptions::new().write(true).open(&fifo); panic!("detect_pypi_flavor must complete promptly with a FIFO pyproject.toml"); @@ -1277,6 +1423,59 @@ mod tests { assert!(!has_table("[tool.uvloop]\n", "tool.uv")); } + fn metadata_wheel(metadata: &str) -> Vec { + super::super::common::write_zip_entries(&[( + "widget-1.0.dist-info/METADATA".to_string(), + metadata.as_bytes().to_vec(), + 0o644, + )]) + .unwrap() + } + + #[test] + fn hosted_wheel_metadata_verifies_hash_and_preserves_dependencies() { + let bytes = metadata_wheel("Metadata-Version: 2.1\nName: widget\nVersion: 1.0\nRequires-Dist: requests[socks]>=2; python_version >= '3.9'\nProvides-Extra: secure\n\nBody\n"); + let sha = hex::encode(Sha256::digest(&bytes)); + let block = decode_hosted_wheel_metadata(&bytes, &sha).unwrap().unwrap(); + let document: toml_edit::DocumentMut = block.parse().unwrap(); + assert_eq!( + document["package"]["metadata"]["requires-dist"][0]["name"].as_str(), + Some("requests") + ); + assert_eq!( + document["package"]["metadata"]["requires-dist"][0]["specifier"].as_str(), + Some(">=2") + ); + assert_eq!( + document["package"]["metadata"]["requires-dist"][0]["extras"][0].as_str(), + Some("socks") + ); + assert_eq!( + document["package"]["metadata"]["provides-extras"][0].as_str(), + Some("secure") + ); + assert!(decode_hosted_wheel_metadata(&bytes, &"0".repeat(64)) + .unwrap_err() + .contains("does not match")); + assert!(decode_hosted_wheel_metadata(&bytes, "short") + .unwrap_err() + .contains("64 hexadecimal")); + } + + #[test] + fn hosted_wheel_metadata_distinguishes_no_dependencies_from_invalid_wheels() { + let bytes = metadata_wheel("Metadata-Version: 2.1\nName: widget\nVersion: 1.0\n\nRequires-Dist: description-only\n"); + assert!( + decode_hosted_wheel_metadata(&bytes, &hex::encode(Sha256::digest(&bytes))) + .unwrap() + .is_none() + ); + for bytes in [b"not a zip".to_vec(), metadata_wheel("not metadata"), metadata_wheel("Metadata-Version: 2.1\nName: widget\nVersion: 1.0\nRequires-Dist: other @ https://example.test/other.whl\n")] { + let sha = hex::encode(Sha256::digest(&bytes)); + assert!(decode_hosted_wheel_metadata(&bytes, &sha).is_err()); + } + } + struct E2eFixture { _tmp: tempfile::TempDir, root: PathBuf, @@ -2991,7 +3190,10 @@ wheels = [ "no files hash line ⇒ no pin (the guard stays off rather than guessing)" ); assert_eq!( - splice_lock_wired_pin(&poetry, ".socket/vendor/pypi/00000000-0000-4000-8000-000000000000"), + splice_lock_wired_pin( + &poetry, + ".socket/vendor/pypi/00000000-0000-4000-8000-000000000000" + ), None, "a foreign uuid dir pins nothing of ours" ); @@ -3017,10 +3219,7 @@ wheels = [ } } }); - assert_eq!( - pipenv_wired_pin(&lock, &dir_rel), - Some((rel_wheel, sha)) - ); + assert_eq!(pipenv_wired_pin(&lock, &dir_rel), Some((rel_wheel, sha))); let no_ref = serde_json::json!({ "default": {"six": {"version": "==1.16.0", "hashes": ["sha256:eee"]}} }); @@ -4289,8 +4488,16 @@ wheels = [ "version = [broken\n", "pypi_poetry_lock_parse_failed", ), - ("pdm.lock", "version = [broken\n", "pypi_pdm_lock_parse_failed"), - ("Pipfile.lock", "{ not json", "pypi_pipenv_lock_parse_failed"), + ( + "pdm.lock", + "version = [broken\n", + "pypi_pdm_lock_parse_failed", + ), + ( + "Pipfile.lock", + "{ not json", + "pypi_pipenv_lock_parse_failed", + ), ]; for (lock_file, broken, expected_code) in cases { let fx = e2e_fixture().await; @@ -4322,7 +4529,11 @@ wheels = [ POETRY_LOCK_REGISTRY, "pypi_poetry_source_already_exists", ), - ("pdm.lock", PDM_LOCK_REGISTRY, "pypi_pdm_source_already_exists"), + ( + "pdm.lock", + PDM_LOCK_REGISTRY, + "pypi_pdm_source_already_exists", + ), ( "Pipfile.lock", PIPENV_LOCK_REGISTRY, @@ -4333,8 +4544,7 @@ wheels = [ let fx = e2e_fixture().await; swap_to_lock_flavor(&fx, &[(lock_file, lock_text)]).await; let sources = PatchSources::blobs_only(&fx.blobs); - let VendorOutcome::Done { result, .. } = vendor_six(&fx, &sources, None).await - else { + let VendorOutcome::Done { result, .. } = vendor_six(&fx, &sources, None).await else { panic!("{lock_file}: first vendor must be Done"); }; assert!(result.success, "{lock_file}: {:?}", result.error); @@ -4394,8 +4604,7 @@ wheels = [ let fx = e2e_fixture().await; swap_to_lock_flavor(&fx, &[(lock_file, lock_text)]).await; let sources = PatchSources::blobs_only(&fx.blobs); - let VendorOutcome::Done { result, entry, .. } = - vendor_six(&fx, &sources, None).await + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await else { panic!("{lock_file}: first vendor must be Done"); }; @@ -4583,4 +4792,160 @@ wheels = [ "the hand-stripped lock is left alone" ); } + #[tokio::test] + async fn standalone_python_locks_vendor_and_revert_real_wheels() { + let original = r#"# original byte formatting +lock-version = "1.0" +created-by = "uv" +requires-python = ">=3.9" + +[[packages]] +name = "six" +version = "1.16.0" +wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "upstream"}}] +"#; + for name in ["pylock.toml", "pylock.production.toml"] { + full_cycle_lock_flavor("python-lock", name, original, "python_lock_document").await; + } + } + + #[tokio::test] + async fn script_python_lock_pairs_metadata_and_restores_original_bytes() { + let fx = e2e_fixture().await; + let script = r#"#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = ["six==1.16.0"] +# /// +print('preserved') +"#; + let lock = r#"version = 1 +revision = 3 +requires-python = ">=3.9" + +[manifest] +requirements = [{name = "six", specifier = "==1.16.0"}] + +[[package]] +name = "six" +version = "1.16.0" +source = {registry = "https://pypi.org/simple"} +wheels = [{url = "https://files.pythonhosted.org/six.whl", hash = "sha256:upstream"}] +"#; + swap_to_lock_flavor(&fx, &[("example.py", script), ("example.py.lock", lock)]).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("expected completed vendor"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let script_after = tokio::fs::read_to_string(fx.root.join("example.py")) + .await + .unwrap(); + assert!(script_after.contains(&entry.artifact.path)); + assert!(script_after.ends_with("# ///\nprint('preserved')\n")); + let lock_after = tokio::fs::read_to_string(fx.root.join("example.py.lock")) + .await + .unwrap(); + let document: toml_edit::DocumentMut = lock_after.parse().unwrap(); + assert_eq!( + document["manifest"]["requirements"][0]["path"].as_str(), + Some(entry.artifact.path.as_str()) + ); + assert!(lock_after.contains(&entry.artifact.sha256)); + let VendorOutcome::Done { + result: repeated, + entry: repeated_entry, + .. + } = vendor_six(&fx, &sources, None).await + else { + panic!("expected idempotent vendor"); + }; + assert!(repeated.success); + assert!(repeated_entry.is_none()); + for (file, original, tampered) in [ + ( + "example.py", + &script_after, + script_after.replace(&entry.artifact.path, "user/six.whl"), + ), + ( + "example.py.lock", + &lock_after, + lock_after.replace(&entry.artifact.sha256, &"f".repeat(64)), + ), + ] { + touch(&fx.root, file, &tampered).await; + let script_before = tokio::fs::read_to_string(fx.root.join("example.py")) + .await + .unwrap(); + let lock_before = tokio::fs::read_to_string(fx.root.join("example.py.lock")) + .await + .unwrap(); + let refused = revert_pypi(&entry, &fx.root, false).await; + assert!(refused.success); + assert!(refused.kept_artifact); + assert!(refused.drift_skipped()); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("example.py")) + .await + .unwrap(), + script_before + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("example.py.lock")) + .await + .unwrap(), + lock_before + ); + assert!(fx.root.join(&entry.artifact.path).is_file()); + touch(&fx.root, file, original).await; + } + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("example.py")) + .await + .unwrap(), + script + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("example.py.lock")) + .await + .unwrap(), + lock + ); + assert!(!uuid_dir_of(&fx).exists()); + } + #[tokio::test] + async fn unrelated_script_lock_does_not_block_requirements_vendoring() { + let fx = e2e_fixture().await; + let unrelated = "version = 1\n[[package]]\nname = \"other\"\nversion = \"1\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + touch(&fx.root, "job.py.lock", unrelated).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_six(&fx, &sources, None).await; + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { + panic!("expected requirements fallback, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(entry.flavor.as_deref(), Some("requirements")); + assert!(read_requirements(&fx).await.contains(&entry.artifact.path)); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("job.py.lock")) + .await + .unwrap(), + unrelated + ); + assert!(warnings + .iter() + .any(|warning| warning.code == "pypi_unmatched_lockfiles")); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs new file mode 100644 index 00000000..2bde43c8 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -0,0 +1,821 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use tokio::io::AsyncReadExt as _; +use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, open_regular_file}; +use crate::utils::python_lock::{ + is_python_lock_name, python_lock_paths, rewrite_python_lock, ArtifactSource, +}; +use crate::utils::python_script::{ + replace_script_metadata, rewrite_script_metadata, script_metadata, +}; + +use super::common::record; +use super::state::{VendorEntry, WiringAction, WiringRecord}; +use super::{RevertOutcome, VendorWarning}; + +const KIND: &str = "python_lock_document"; +const SCRIPT_KIND: &str = "python_script_metadata"; +type Failure = (&'static str, String); + +struct LockFile { + name: String, + text: String, + script: Option, +} + +pub(super) struct PythonLocks { + files: Vec, + pub in_sync: bool, + pub pin: Option<(String, String)>, +} + +async fn read_file(path: &Path) -> Result { + let metadata = tokio::fs::symlink_metadata(path).await.map_err(|error| { + ( + "pypi_lock_read_failed", + format!("cannot read {}: {error}", path.display()), + ) + })?; + if !metadata.is_file() { + return Err(( + "pypi_lock_read_failed", + format!("{} is not a regular file", path.display()), + )); + } + let (mut file, _) = open_regular_file(path) + .await + .map_err(|error| ("pypi_lock_read_failed", error.to_string()))?; + let mut text = String::new(); + file.read_to_string(&mut text) + .await + .map_err(|error| ("pypi_lock_read_failed", error.to_string()))?; + Ok(text) +} + +fn package<'a>(document: &'a DocumentMut, name: &str, version: &str) -> Option<&'a Table> { + let collection = if document.contains_key("lock-version") { + "packages" + } else { + "package" + }; + document + .get(collection)? + .as_array_of_tables()? + .iter() + .find(|table| { + table + .get("name") + .and_then(Item::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + && table.get("version").and_then(Item::as_str) == Some(version) + }) +} + +fn source_path(table: &Table) -> Option<&str> { + table + .get("archive") + .or_else(|| table.get("source"))? + .as_table_like()? + .get("path")? + .as_str() +} + +fn source_sha(table: &Table) -> Option { + if let Some(sha) = table + .get("archive") + .and_then(Item::as_table_like) + .and_then(|archive| archive.get("hashes")) + .and_then(Item::as_table_like) + .and_then(|hashes| hashes.get("sha256")) + .and_then(Item::as_str) + { + return Some(sha.to_string()); + } + table.get("wheels")?.as_array()?.iter().find_map(|wheel| { + wheel + .as_inline_table()? + .get("hash")? + .as_str()? + .strip_prefix("sha256:") + .map(str::to_string) + }) +} + +pub(super) async fn contains_target( + root: &Path, + paths: &[String], + name: &str, + version: &str, +) -> Result { + for path in paths { + let text = read_file(&root.join(path)).await?; + let document: DocumentMut = text + .parse() + .map_err(|error| ("pypi_lock_parse_failed", format!("{path}: {error}")))?; + if package(&document, name, version).is_some() { + return Ok(true); + } + } + Ok(false) +} + +pub(super) async fn load_python_locks( + root: &Path, + name: &str, + version: &str, + uuid: &str, +) -> Result { + let paths = + python_lock_paths(root).map_err(|error| ("pypi_lock_read_failed", error.to_string()))?; + let directory = format!(".socket/vendor/pypi/{uuid}/"); + let placeholder = format!("{directory}{name}-{version}-py3-none-any.whl"); + let mut files = Vec::new(); + let mut in_sync = true; + let mut pin = None; + for path in paths.into_iter().filter(|path| path != "uv.lock") { + let text = read_file(&root.join(&path)).await?; + let rewritten = rewrite_python_lock( + &text, + name, + version, + ArtifactSource::Path(&placeholder), + &"0".repeat(64), + ) + .map_err(|error| ("pypi_lock_unsupported", format!("{path}: {error}")))?; + if rewritten.is_none() { + continue; + } + let document: DocumentMut = text + .parse() + .map_err(|error| ("pypi_lock_parse_failed", format!("{path}: {error}")))?; + let Some(package) = package(&document, name, version) else { + continue; + }; + if let Some(path) = source_path(package) { + let path = path.trim_start_matches("./"); + if !path.starts_with(&directory) { + return Err(("pypi_lock_source_already_exists", format!("{name} already uses {path}; run vendor --revert before changing its source"))); + } + let sha = source_sha(package) + .filter(|sha| sha.len() == 64 && sha.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| { + ( + "pypi_lock_missing_hash", + format!("{path} has no vendored wheel hash"), + ) + })?; + let candidate = (path.to_string(), sha); + if pin.as_ref().is_some_and(|pin| pin != &candidate) { + return Err(( + "pypi_lock_conflicting_pins", + format!("lockfiles disagree on the vendored artifact for {name}"), + )); + } + pin = Some(candidate); + } else { + in_sync = false; + } + let script = if let Some(script_name) = path + .strip_suffix(".lock") + .filter(|name| name.ends_with(".py")) + { + if document + .get("package") + .and_then(Item::as_array_of_tables) + .is_some_and(|packages| { + packages + .iter() + .filter(|package| { + package + .get("name") + .and_then(Item::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) + }) + .count() + > 1 + }) + { + return Err(("pypi_script_multiple_versions", format!("{path} resolves multiple versions of {name}; a script source override cannot select only {version}"))); + } + let script = read_file(&root.join(script_name)).await?; + let script_wheel = source_path(package).unwrap_or(&placeholder); + if rewrite_script_metadata(&script, name, version, ArtifactSource::Path(script_wheel)) + .map_err(|error| { + ( + "pypi_script_metadata_invalid", + format!("{script_name}: {error}"), + ) + })? + .is_some() + { + in_sync = false; + } + Some(script) + } else { + None + }; + files.push(LockFile { + name: path, + text, + script, + }); + } + if files.is_empty() { + return Err(( + "pypi_lock_package_missing", + format!("no supported script lock or PEP 751 lockfile contains {name}@{version}"), + )); + } + Ok(PythonLocks { + files, + in_sync, + pin, + }) +} + +pub(super) async fn wire_python_locks( + project: &PythonLocks, + root: &Path, + name: &str, + version: &str, + wheel: &str, + sha: &str, +) -> Result, Failure> { + let mut edits = Vec::new(); + for file in &project.files { + let Some(mut rewritten) = + rewrite_python_lock(&file.text, name, version, ArtifactSource::Path(wheel), sha) + .map_err(|error| ("pypi_lock_unsupported", error))? + else { + continue; + }; + if let Some(script) = &file.script { + let script_name = file.name.strip_suffix(".lock").expect("script lock suffix"); + if let Some(script_output) = + rewrite_script_metadata(script, name, version, ArtifactSource::Path(wheel)) + .map_err(|error| ("pypi_script_metadata_invalid", error))? + { + edits.push(( + script_name.to_string(), + script.clone(), + script_output, + SCRIPT_KIND, + )); + } + if let Some(metadata) = super::pypi_uv::wheel_metadata_block(&root.join(wheel)).await { + let metadata = metadata + .strip_prefix("[package.metadata]\n") + .unwrap_or(&metadata); + let metadata: DocumentMut = metadata.parse().map_err(|error| { + ("pypi_lock_parse_failed", format!("wheel metadata: {error}")) + })?; + let mut document: DocumentMut = rewritten.parse().map_err(|error| { + ("pypi_lock_parse_failed", format!("rewritten lock: {error}")) + })?; + if let Some(packages) = document + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + { + for package in packages.iter_mut() { + if package.get("name").and_then(Item::as_str) == Some(name) + && package.get("version").and_then(Item::as_str) == Some(version) + { + let mut metadata = metadata.as_table().clone(); + metadata.set_position(None); + package.insert("metadata", Item::Table(metadata)); + } + } + } + rewritten = document.to_string(); + } + } + if rewritten != file.text { + edits.push((file.name.clone(), file.text.clone(), rewritten, KIND)); + } + } + for (file, original, _, _) in &edits { + if read_file(&root.join(file)).await? != *original { + return Err(( + "pypi_lock_changed", + format!("{file} changed during vendoring"), + )); + } + } + let mut written: Vec<(&String, &String)> = Vec::new(); + for (file, original, new, _) in &edits { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), new.as_bytes()).await + { + let mut rollback_errors = Vec::new(); + for (file, original) in written.into_iter().rev() { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), original.as_bytes()).await + { + rollback_errors.push(format!("{file}: {error}")); + } + } + return Err(( + "pypi_lock_write_failed", + format!( + "cannot write {file}: {error}; rollback errors: {}", + rollback_errors.join(", ") + ), + )); + } + written.push((file, original)); + } + Ok(edits + .into_iter() + .map(|(file, original, new, kind)| { + record( + &file, + kind, + WiringAction::Rewritten, + name, + Some(original), + new, + ) + }) + .collect()) +} + +fn item_text(item: &Item) -> String { + let mut document = DocumentMut::new(); + document.insert("item", item.clone()); + document.to_string() +} + +fn equal_item(left: Option<&Item>, right: Option<&Item>) -> bool { + left.map(item_text) == right.map(item_text) +} + +fn same_identity(live: &dyn TableLike, expected: &dyn TableLike) -> bool { + ["name", "version"] + .iter() + .all(|key| equal_item(live.get(key), expected.get(key))) +} + +fn restore_table(live: &mut dyn TableLike, original: &dyn TableLike, new: &dyn TableLike) -> bool { + let keys: BTreeSet = original + .iter() + .chain(new.iter()) + .map(|(key, _)| key.to_string()) + .collect(); + let mut drifted = false; + for key in keys { + let before = original.get(&key); + let after = new.get(&key); + if equal_item(before, after) || equal_item(live.get(&key), before) { + continue; + } + if equal_item(live.get(&key), after) { + if let Some(before) = before { + live.insert(&key, before.clone()); + } else { + live.remove(&key); + } + continue; + } + let Some(current) = live.get_mut(&key) else { + drifted = true; + continue; + }; + match (before, after) { + (Some(before), Some(after)) => drifted |= restore_item(current, before, after), + (None, Some(after)) if current.is_table_like() && after.is_table_like() => { + let empty = Item::Table(Table::new()); + drifted |= restore_item(current, &empty, after); + if current + .as_table_like() + .is_some_and(|table| table.is_empty()) + { + live.remove(&key); + } + } + _ => drifted = true, + } + } + drifted +} + +fn restore_value(live: &mut Value, original: &Value, new: &Value) -> bool { + if original.to_string() == new.to_string() || live.to_string() == original.to_string() { + return false; + } + if live.to_string() == new.to_string() { + *live = original.clone(); + return false; + } + if let (Some(live), Some(original), Some(new)) = ( + live.as_inline_table_mut(), + original.as_inline_table(), + new.as_inline_table(), + ) { + if !same_identity(live, new) { + return true; + } + return restore_table(live, original, new); + } + if let (Some(live), Some(original), Some(new)) = + (live.as_array_mut(), original.as_array(), new.as_array()) + { + if live.len() != new.len() || original.len() != new.len() { + return true; + } + let mut drifted = false; + for ((live, original), new) in live.iter_mut().zip(original.iter()).zip(new.iter()) { + drifted |= restore_value(live, original, new); + } + return drifted; + } + true +} + +fn restore_item(live: &mut Item, original: &Item, new: &Item) -> bool { + if item_text(original) == item_text(new) || item_text(live) == item_text(original) { + return false; + } + if item_text(live) == item_text(new) { + *live = original.clone(); + return false; + } + if let (Some(live), Some(original), Some(new)) = ( + live.as_table_like_mut(), + original.as_table_like(), + new.as_table_like(), + ) { + if !same_identity(live, new) { + return true; + } + return restore_table(live, original, new); + } + if let (Some(live), Some(original), Some(new)) = ( + live.as_array_of_tables_mut(), + original.as_array_of_tables(), + new.as_array_of_tables(), + ) { + if live.len() != new.len() || original.len() != new.len() { + return true; + } + let mut drifted = false; + for ((live, original), new) in live.iter_mut().zip(original.iter()).zip(new.iter()) { + if same_identity(live, new) { + drifted |= restore_table(live, original, new); + } else { + drifted = true; + } + } + return drifted; + } + if let (Some(live), Some(original), Some(new)) = + (live.as_value_mut(), original.as_value(), new.as_value()) + { + return restore_value(live, original, new); + } + true +} + +fn restore_document(live: &str, original: &str, new: &str) -> Result<(String, bool), String> { + if live == new || live == original { + return Ok((original.to_string(), false)); + } + let current_text = live.to_string(); + let mut live: DocumentMut = live + .parse() + .map_err(|error| format!("invalid live TOML: {error}"))?; + let original: DocumentMut = original + .parse() + .map_err(|error| format!("invalid original TOML: {error}"))?; + let new: DocumentMut = new + .parse() + .map_err(|error| format!("invalid vendored TOML: {error}"))?; + let drifted = restore_item(live.as_item_mut(), original.as_item(), new.as_item()); + Ok(( + if drifted { + current_text + } else { + live.to_string() + }, + drifted, + )) +} + +fn allowed_file(file: &str, kind: &str) -> bool { + Path::new(file).file_name().and_then(|name| name.to_str()) == Some(file) + && ((kind == KIND && is_python_lock_name(file) && file != "uv.lock") + || (kind == SCRIPT_KIND && file.ends_with(".py"))) +} + +pub(super) async fn revert_python_locks( + entry: &VendorEntry, + root: &Path, + dry_run: bool, +) -> RevertOutcome { + let mut warnings = Vec::new(); + let mut edits = Vec::new(); + for record in entry.wiring.iter().rev() { + if !allowed_file(&record.file, &record.kind) { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "unexpected Python wiring file or kind: {} ({})", + record.file, record.kind + ), + )); + continue; + } + let (Some(original), Some(new)) = ( + record.original.as_ref().and_then(serde_json::Value::as_str), + record.new.as_ref().and_then(serde_json::Value::as_str), + ) else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("{} has no recorded original", record.file), + )); + continue; + }; + let live = match read_file(&root.join(&record.file)).await { + Ok(live) => live, + Err((_, error)) => return RevertOutcome::failed(error), + }; + let restored = if record.kind == SCRIPT_KIND { + (|| { + if live == new || live == original { + return Ok((original.to_string(), false)); + } + let (_, live_metadata) = script_metadata(&live)?; + let (_, original_metadata) = script_metadata(original)?; + let (_, new_metadata) = script_metadata(new)?; + let (restored, drifted) = + restore_document(&live_metadata, &original_metadata, &new_metadata)?; + Ok((replace_script_metadata(&live, &restored)?, drifted)) + })() + } else { + restore_document(&live, original, new) + }; + let (restored, drifted) = match restored { + Ok(result) => result, + Err(error) => return RevertOutcome::failed(error), + }; + if drifted { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{} changed since vendoring; conflicting fields were preserved", + record.file + ), + )); + } + if restored != live { + edits.push((record.file.clone(), live, restored)); + } + } + if !dry_run && warnings.is_empty() { + for (file, original, _) in &edits { + match read_file(&root.join(file)).await { + Ok(live) if live == *original => {} + Ok(_) => return RevertOutcome::failed(format!("{file} changed during revert")), + Err((_, error)) => return RevertOutcome::failed(error), + } + } + let mut written: Vec<(&String, &String)> = Vec::new(); + for (file, original, restored) in &edits { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), restored.as_bytes()).await + { + let mut rollback_errors = Vec::new(); + for (file, original) in written.into_iter().rev() { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), original.as_bytes()) + .await + { + rollback_errors.push(format!("{file}: {error}")); + } + } + return RevertOutcome::failed(format!( + "cannot restore {file}: {error}; rollback errors: {}", + rollback_errors.join(", ") + )); + } + written.push((file, original)); + } + } + RevertOutcome { + success: true, + warnings, + error: None, + kept_artifact: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK: &str = "# original\nlock-version = \"1.0\"\n\n[[packages]]\nname = \"one\"\nversion = \"1\"\nwheels = [{url = \"https://example.test/one.whl\", hashes = {sha256 = \"one\"}}]\n\n[[packages]]\nname = \"two\"\nversion = \"2\"\nwheels = [{url = \"https://example.test/two.whl\", hashes = {sha256 = \"two\"}}]\n"; + + #[tokio::test] + async fn script_wheel_metadata_stays_with_its_package() { + let temp = tempfile::tempdir().unwrap(); + let lock = "version = 1\n[[package]]\nname = \"one\"\nversion = \"1\"\nsource = {registry = \"https://pypi.org/simple\"}\n[[package]]\nname = \"two\"\nversion = \"2\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + let script = "# /// script\n# dependencies = [\"two==2\"]\n# ///\n"; + tokio::fs::write(temp.path().join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write(temp.path().join("job.py"), script) + .await + .unwrap(); + let bytes = super::super::common::write_zip_entries(&[( + "two-2.dist-info/METADATA".to_string(), + b"Metadata-Version: 2.1\nName: two\nVersion: 2\nRequires-Dist: one>=1\n".to_vec(), + 0o644, + )]) + .unwrap(); + tokio::fs::write(temp.path().join("two-2-py3-none-any.whl"), bytes) + .await + .unwrap(); + let project = load_python_locks( + temp.path(), + "two", + "2", + "11111111-1111-4111-8111-111111111111", + ) + .await + .unwrap(); + wire_python_locks( + &project, + temp.path(), + "two", + "2", + "two-2-py3-none-any.whl", + &"a".repeat(64), + ) + .await + .unwrap(); + let output = tokio::fs::read_to_string(temp.path().join("job.py.lock")) + .await + .unwrap(); + let document: DocumentMut = output.parse().unwrap(); + let packages = document["package"].as_array_of_tables().unwrap(); + assert!(packages.get(0).unwrap().get("metadata").is_none()); + assert_eq!( + packages.get(1).unwrap()["metadata"]["requires-dist"][0]["name"].as_str(), + Some("one") + ); + assert_eq!( + packages.get(1).unwrap()["metadata"]["requires-dist"][0]["specifier"].as_str(), + Some(">=1") + ); + } + + #[tokio::test] + async fn script_global_sources_cannot_replace_multiple_versions() { + let temp = tempfile::tempdir().unwrap(); + let lock = "version = 1\n[[package]]\nname = \"one\"\nversion = \"1\"\nsource = {registry = \"https://pypi.org/simple\"}\n[[package]]\nname = \"one\"\nversion = \"2\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + tokio::fs::write(temp.path().join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write( + temp.path().join("job.py"), + "# /// script\n# dependencies = [\"one\"]\n# ///\n", + ) + .await + .unwrap(); + let result = load_python_locks( + temp.path(), + "one", + "1", + "11111111-1111-4111-8111-111111111111", + ) + .await; + assert!(matches!(result, Err(("pypi_script_multiple_versions", _)))); + assert_eq!( + tokio::fs::read_to_string(temp.path().join("job.py.lock")) + .await + .unwrap(), + lock + ); + assert!(!temp.path().join(".socket").exists()); + } + + #[test] + fn separate_package_reverts_do_not_depend_on_order() { + let first = rewrite_python_lock( + LOCK, + "one", + "1", + ArtifactSource::Path(".socket/vendor/one-1-py3-none-any.whl"), + "first", + ) + .unwrap() + .unwrap(); + let both = rewrite_python_lock( + &first, + "two", + "2", + ArtifactSource::Path(".socket/vendor/two-2-py3-none-any.whl"), + "second", + ) + .unwrap() + .unwrap(); + let (second_only, drifted) = restore_document(&both, LOCK, &first).unwrap(); + assert!(!drifted); + assert!(second_only.contains("https://example.test/one.whl")); + assert!(second_only.contains(".socket/vendor/two-2-py3-none-any.whl")); + let (restored, drifted) = restore_document(&second_only, &first, &both).unwrap(); + assert!(!drifted); + assert_eq!(restored, LOCK); + } + + #[test] + fn script_sources_revert_out_of_order_without_empty_tables() { + for suffix in ["", "\n[tool.uv.sources]\n"] { + let metadata = format!("dependencies = [\"one==1\", \"two==2\"]\n{suffix}"); + let script = + replace_script_metadata("# /// script\n# ///\nprint('unchanged')\n", &metadata) + .unwrap(); + let first = rewrite_script_metadata( + &script, + "one", + "1", + ArtifactSource::Path(".socket/vendor/one.whl"), + ) + .unwrap() + .unwrap(); + let both = rewrite_script_metadata( + &first, + "two", + "2", + ArtifactSource::Path(".socket/vendor/two.whl"), + ) + .unwrap() + .unwrap(); + let (_, first_metadata) = script_metadata(&first).unwrap(); + let (_, both_metadata) = script_metadata(&both).unwrap(); + let (second_only, drifted) = + restore_document(&both_metadata, &metadata, &first_metadata).unwrap(); + assert!(!drifted); + let (restored, drifted) = + restore_document(&second_only, &first_metadata, &both_metadata).unwrap(); + assert!(!drifted); + assert_eq!(restored, metadata); + } + } + + #[test] + fn conflicting_fields_are_preserved_and_reported() { + let patched = rewrite_python_lock( + LOCK, + "one", + "1", + ArtifactSource::Path(".socket/vendor/one-1-py3-none-any.whl"), + "first", + ) + .unwrap() + .unwrap(); + let edited = patched.replace( + ".socket/vendor/one-1-py3-none-any.whl", + "user/one-1-py3-none-any.whl", + ); + let (restored, drifted) = restore_document(&edited, LOCK, &patched).unwrap(); + assert!(drifted); + assert_eq!(restored, edited); + } + + #[test] + fn reordered_packages_cannot_receive_another_packages_original() { + let patched = rewrite_python_lock( + LOCK, + "one", + "1", + ArtifactSource::Path(".socket/vendor/one-1-py3-none-any.whl"), + "first", + ) + .unwrap() + .unwrap(); + let edited = patched.replace("name = \"one\"", "name = \"other\""); + let (restored, drifted) = restore_document(&edited, LOCK, &patched).unwrap(); + assert!(drifted); + assert_eq!(restored, edited); + } + + #[test] + fn recorded_files_cannot_escape_the_project() { + for file in [ + "../pylock.toml", + "/tmp/pylock.toml", + "nested/pylock.toml", + "uv.lock", + ] { + assert!(!allowed_file(file, KIND)); + } + assert!(allowed_file("pylock.toml", KIND)); + assert!(allowed_file("job.py.lock", KIND)); + assert!(allowed_file("job.py", SCRIPT_KIND)); + assert!(!allowed_file("../job.py", SCRIPT_KIND)); + } +} diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 32bd589d..9cd66557 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -111,6 +111,13 @@ pub(super) async fn load_uv_project(root: &Path) -> Result=0.2 for portable native vendoring, or use a requirements.txt installation".to_string(), + )); + } + // Workspaces resolve all members into ONE shared lock whose fragments we // have no fixtures for; refuse rather than guess (fail-closed). if pyproject @@ -1280,14 +1287,14 @@ struct MetaDep { /// can't be read, has no `*.dist-info/METADATA`, or (like `six`) declares no /// requires-dist / provides-extras — uv omits the block in that case too, so /// the fixtures that pass no block stay byte-exact. -async fn wheel_metadata_block(wheel_path: &Path) -> Option { +pub(super) async fn wheel_metadata_block(wheel_path: &Path) -> Option { let bytes = tokio::fs::read(wheel_path).await.ok()?; let text = wheel_metadata_text(&bytes)?; render_package_metadata_block(&text) } /// Extract the top-level `*.dist-info/METADATA` text from a wheel zip. -fn wheel_metadata_text(bytes: &[u8]) -> Option { +pub(super) fn wheel_metadata_text(bytes: &[u8]) -> Option { use std::io::Read as _; let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?; let mut metadata_name: Option = None; @@ -1304,13 +1311,16 @@ fn wheel_metadata_text(bytes: &[u8]) -> Option { } } let metadata_name = metadata_name?; - let mut entry = archive.by_name(&metadata_name).ok()?; + let entry = archive.by_name(&metadata_name).ok()?; if entry.size() > MAX_WHEEL_METADATA_BYTES { return None; } let mut text = String::new(); - entry.read_to_string(&mut text).ok()?; - Some(text) + entry + .take(MAX_WHEEL_METADATA_BYTES + 1) + .read_to_string(&mut text) + .ok()?; + (text.len() as u64 <= MAX_WHEEL_METADATA_BYTES).then_some(text) } /// Collect the `Requires-Dist` / `Provides-Extra` header values from a wheel's @@ -1432,7 +1442,7 @@ fn render_requires_dist_entry(dep: &MetaDep) -> String { /// provides-extras) or a `Requires-Dist` line fails to parse — in which case /// we emit no block rather than risk malformed TOML (`uv sync` then heals it, /// the pre-fix behavior, instead of failing to parse the lock). -fn render_package_metadata_block(metadata_text: &str) -> Option { +pub(super) fn render_package_metadata_block(metadata_text: &str) -> Option { let (requires_raw, provides_raw) = parse_core_metadata_fields(metadata_text); if requires_raw.is_empty() && provides_raw.is_empty() { return None; diff --git a/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected-edits.json index d40e2b2d..69e31d3c 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected-edits.json @@ -4,7 +4,7 @@ "kind": "redirect_uv_lock_wheel", "action": "rewritten", "key": "click@8.1.7", - "original": "source = { registry = \"https://pypi.org/simple\" }\nwheels = [\n { url = \"https://files.pythonhosted.org/packages/00/2e/click-8.1.7-py3-none-any.whl\", hash = \"sha256:0000000000000000000000000000000000000000000000000000000000000000\" },\n]\n", - "new": "source = { registry = \"https://pypi.org/simple\" }\nwheels = [\n { url = \"https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl\", hash = \"sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" },\n]\n" + "original": "[[package]]\nname = \"click\"\nversion = \"8.1.7\"\nsource = { registry = \"https://pypi.org/simple\" }\nwheels = [\n { url = \"https://files.pythonhosted.org/packages/00/2e/click-8.1.7-py3-none-any.whl\", hash = \"sha256:0000000000000000000000000000000000000000000000000000000000000000\" },\n]\n", + "new": "[[package]]\nname = \"click\"\nversion = \"8.1.7\"\nsource = { url = \"https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl\" }\nwheels = [{ url = \"https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl\", hash = \"sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" }]\n" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected/uv.lock b/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected/uv.lock index 73444628..6d2cfa34 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected/uv.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/pypi/uv/basic/expected/uv.lock @@ -4,7 +4,5 @@ requires-python = ">=3.8" [[package]] name = "click" version = "8.1.7" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl", hash = "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }, -] +source = { url = "https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl" } +wheels = [{ url = "https://patch.socket.dev/patch/pypi/click/8.1.7/11111111-1111-1111-1111-111111111111/88888888-8888-8888-8888-888888888888/click-8.1.7-py3-none-any.whl", hash = "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }] diff --git a/crates/socket-patch-core/tests/uv_hosted.rs b/crates/socket-patch-core/tests/uv_hosted.rs new file mode 100644 index 00000000..8c432806 --- /dev/null +++ b/crates/socket-patch-core/tests/uv_hosted.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeMap; + +use socket_patch_core::patch::redirect::{ + revert_remaining_redirect_edits, rewrite_registry_redirect, DepOverride, Integrity, + RedirectState, +}; + +fn patch(name: &str) -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: name.into(), + namespace: None, + version: "1.0.0".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: "22222222-2222-4222-8222-222222222222".into(), + artifact_url: format!("https://patch.socket.dev/pkg/{name}-1.0.0-py3-none-any.whl"), + berry_zip_url: None, + registry_override: None, + integrity: Integrity { + sha256: Some("a".repeat(64)), + ..Integrity::default() + }, + } +} + +fn files() -> BTreeMap { + let mut lock = "version = 1\nrevision = 3\n\n[manifest]\nrequirements = [{ name = \"alpha\", specifier = \"==1.0.0\" }, { name = \"bravo\", specifier = \"==1.0.0\" }]\n".to_string(); + for name in ["alpha", "bravo"] { + lock.push_str(&format!("\n[[package]]\nname = \"{name}\"\nversion = \"1.0.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://pypi.org/{name}-1.0.0-py3-none-any.whl\", hash = \"sha256:original\" }}]\n")); + } + BTreeMap::from([ + ("example.py.lock".to_string(), lock), + ("example.py".to_string(), "# /// script\n# dependencies = [\"alpha==1.0.0\", \"bravo==1.0.0\"]\n# ///\nprint('preserved')\n".to_string()), + ]) +} + +#[tokio::test] +async fn script_redirect_and_revert_restore_every_original_byte() { + let original = files(); + let overrides = [patch("alpha"), patch("bravo")]; + let result = rewrite_registry_redirect(&original, &overrides); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.files.len(), 2); + let again = rewrite_registry_redirect(&result.files, &overrides); + assert!(again.warnings.is_empty(), "{:?}", again.warnings); + assert!(again.files.is_empty()); + assert!(again.edits.is_empty()); + + let directory = tempfile::tempdir().unwrap(); + for (path, contents) in &result.files { + tokio::fs::write(directory.path().join(path), contents) + .await + .unwrap(); + } + let mut state = RedirectState { + edits: result.edits, + ..RedirectState::default() + }; + let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + assert!(state.edits.is_empty()); + for (path, contents) in original { + assert_eq!( + tokio::fs::read_to_string(directory.path().join(path)) + .await + .unwrap(), + contents + ); + } +} + +#[test] +fn conflicting_or_missing_script_metadata_refuses_lock_changes() { + let mut original = files(); + original.insert("example.py".into(), "# /// script\n# dependencies = [\"alpha==1.0.0\"]\n# [tool.uv.sources]\n# alpha = { git = \"https://example.test/alpha\" }\n# ///\n".into()); + let result = rewrite_registry_redirect(&original, &[patch("alpha")]); + assert!(result.files.is_empty()); + assert!(result.edits.is_empty()); + assert_eq!(result.warnings[0].code, "redirect_uv_script_unsupported"); + + original.remove("example.py"); + let result = rewrite_registry_redirect(&original, &[patch("alpha")]); + assert!(result.files.is_empty()); + assert!(result.edits.is_empty()); + assert_eq!(result.warnings[0].code, "redirect_uv_script_missing"); +} + +#[tokio::test] +async fn script_drift_keeps_both_paired_files_during_revert() { + let result = rewrite_registry_redirect(&files(), &[patch("alpha")]); + let directory = tempfile::tempdir().unwrap(); + for (path, contents) in &result.files { + tokio::fs::write(directory.path().join(path), contents) + .await + .unwrap(); + } + let changed = result.files["example.py"].replace("dependencies =", "dependencies ="); + tokio::fs::write(directory.path().join("example.py"), &changed) + .await + .unwrap(); + let mut state = RedirectState { + edits: result.edits, + ..RedirectState::default() + }; + let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; + assert!(!outcome.fully_reverted()); + assert_eq!( + tokio::fs::read_to_string(directory.path().join("example.py")) + .await + .unwrap(), + changed + ); + assert_eq!( + tokio::fs::read_to_string(directory.path().join("example.py.lock")) + .await + .unwrap(), + result.files["example.py.lock"] + ); + assert!(!state.edits.is_empty()); +} + +#[tokio::test] +async fn native_projects_keep_sources_and_metadata_in_sync() { + use socket_patch_core::patch::redirect::rewrite_registry_redirect_with_python_metadata; + + for direct in [true, false] { + let declared = if direct { "alpha" } else { "bravo" }; + let project = format!( + "[project]\nname = 'project'\nversion = '1'\ndependencies = ['{declared}==1.0.0']\n" + ); + let lock = format!("version = 1\nrevision = 3\n[[package]]\nname = 'project'\nversion = '1'\nsource = {{virtual='.'}}\ndependencies=[{{name='{declared}'}}]\n[package.metadata]\nrequires-dist=[{{name='{declared}',specifier='==1.0.0'}}]\n[[package]]\nname='alpha'\nversion='1.0.0'\nsource={{registry='https://pypi.org/simple'}}\nwheels=[{{url='https://pypi.org/alpha-1.0.0-py3-none-any.whl',hash='sha256:original'}}]\n"); + let original = BTreeMap::from([ + ("pyproject.toml".to_string(), project), + ("uv.lock".to_string(), lock), + ]); + let dep = patch("alpha"); + let metadata = BTreeMap::from([( + dep.artifact_url.clone(), + "[package.metadata]\nrequires-dist = []\nprovides-extras = ['testing']\n".to_string(), + )]); + let result = rewrite_registry_redirect_with_python_metadata( + &original, + std::slice::from_ref(&dep), + &metadata, + ); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + let project: toml_edit::DocumentMut = result.files["pyproject.toml"].parse().unwrap(); + assert_eq!( + project["tool"]["uv"]["sources"]["alpha"]["url"].as_str(), + Some(dep.artifact_url.as_str()) + ); + let lock: toml_edit::DocumentMut = result.files["uv.lock"].parse().unwrap(); + let packages = lock["package"].as_array_of_tables().unwrap(); + if direct { + assert_eq!( + packages.get(0).unwrap()["metadata"]["requires-dist"][0]["url"].as_str(), + Some(dep.artifact_url.as_str()) + ); + } else { + assert_eq!( + lock["manifest"]["overrides"][0]["url"].as_str(), + Some(dep.artifact_url.as_str()) + ); + assert_eq!( + project["tool"]["uv"]["override-dependencies"][0].as_str(), + Some("alpha==1.0.0") + ); + } + assert_eq!( + packages.get(1).unwrap()["metadata"]["provides-extras"][0].as_str(), + Some("testing") + ); + let again = + rewrite_registry_redirect_with_python_metadata(&result.files, &[dep], &metadata); + assert!(again.files.is_empty(), "{:?}", again.files); + assert!(again.warnings.is_empty(), "{:?}", again.warnings); + let directory = tempfile::tempdir().unwrap(); + for (file, content) in result.files { + tokio::fs::write(directory.path().join(file), content) + .await + .unwrap(); + } + let mut state = RedirectState { + edits: result.edits, + ..RedirectState::default() + }; + let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + for (file, content) in original { + assert_eq!( + tokio::fs::read_to_string(directory.path().join(file)) + .await + .unwrap(), + content + ); + } + } +} + +#[test] +fn native_project_refuses_global_sources_for_other_locked_versions() { + let lock = "version=1\n[[package]]\nname='alpha'\nversion='1.0.0'\nsource={registry='https://pypi.org/simple'}\nwheels=[{url='https://pypi.org/alpha-1.0.0-py3-none-any.whl',hash='sha256:old'}]\n[[package]]\nname='alpha'\nversion='2.0.0'\nsource={registry='https://pypi.org/simple'}\n"; + let files = BTreeMap::from([("uv.lock".to_string(), lock.to_string()), ("pyproject.toml".to_string(), "[project]\nname='project'\ndependencies=['alpha==1.0.0; python_version < \"3.10\"', 'alpha==2.0.0; python_version >= \"3.10\"']\n".to_string())]); + let result = rewrite_registry_redirect(&files, &[patch("alpha")]); + assert!(result.files.is_empty()); + assert!(result.edits.is_empty()); + assert!(result + .warnings + .iter() + .any(|warning| warning.detail.contains("multiple versions"))); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 152cb250..eb1ea778 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -15,7 +15,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | |-----------|------------------------|------------------------------|--------------------------| | npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v5.4/v6.0/v9 — every major since pnpm 7), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | -| PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | +| PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt. Native uv vendoring requires uv ≥ 0.2; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). **poetry / pdm / pipenv locks are not rewritten** — use vendored | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md new file mode 100644 index 00000000..d8991ef9 --- /dev/null +++ b/docs/testing/uv-compatibility.md @@ -0,0 +1,203 @@ +# uv compatibility and production backtests + +`socket-patch` supports hosted and vendored Python patches in native uv locks, +PEP 723 script locks, PEP 751 locks, and `requirements.txt`. The tests use real +uv binaries, real PyPI artifacts, and the public Socket patch service. Successful +rewriting alone is not an installation result: the backtest reinstalls from the +rewritten files and compares the installed bytes with the published patch. + +This supplements the existing [hosted](hosted-production-e2e.md) and +[vendored](vendored-production-e2e.md) production suites. See the +[ecosystem matrix](../ecosystems.md#mode--ecosystem-matrix) for other package +managers. + +## Formats and rewrite behavior + +| Input | Hosted | Vendored | +|-------|--------|----------| +| `requirements.txt`, including uv-generated hash continuations | Exact version pins become direct artifact URLs with the patched SHA-256. Extras and markers are retained; hashes for the replaced artifact are removed. | Requirements refer to a committed wheel under `.socket/vendor/pypi/` with its hash. | +| `uv.lock`, native `version = 1`, `[[package]]` | The package source and artifact entry agree on the hosted URL and hash. A paired `pyproject.toml` receives the corresponding uv source configuration. | The package source refers to the committed wheel. The paired `pyproject.toml` records that source. | +| `uv.lock`, experimental uv 0.1 `[[distribution]]` | Uses the legacy direct-source and artifact grammar, including source-qualified dependency references. | Refused: this grammar needs absolute file URLs, which cannot provide portable vendoring. Use uv 0.2 or newer. | +| `*.py.lock` with its PEP 723 `*.py` script | Rewrites the lock and the script's uv source metadata together. | Rewrites the lock and script metadata together and commits the patched wheel. | +| `pylock.toml` and `pylock..toml`, PEP 751 `lock-version = "1.0"` | Uses one `archive` URL with the patched SHA-256. | Uses one `archive` path with the committed wheel's SHA-256. | + +Replacing a wheel removes stale sdist entries, sizes, and upload times. A source +archive occupies an sdist/archive entry rather than a wheel entry. Native uv +dependency references are updated when they identify the replaced source. + +The native project and script metadata edits matter for ordinary resolution: +changing only the lock's source can make `uv sync --locked` reject the lock, or +let an ordinary `uv sync` restore the registry source. The backtest records +frozen, locked, and ordinary installation outcomes separately where supported. + +## Limits + +- uv 0.0 has no native `uv.lock`; its compatibility lane is compiled requirements. + uv 0.0.5 rejects the bare local wheel paths emitted by vendored requirements; + use hosted mode or upgrade uv. Vendored requirements passed from uv 0.1.45 + onward in this matrix. +- Native lock versions other than `version = 1`, and PEP 751 versions other than + `lock-version = "1.0"`, are refused. Native lock revisions and command + availability are measured separately by the matrix below. +- A script lock requires its paired script and a valid PEP 723 metadata block. + Missing metadata or an incompatible existing source is reported before either + file is rewritten. +- Native projects and scripts resolving multiple versions of the same package + are refused when a global uv source would replace another version. Supporting + those cases requires marker-specific source mappings. Standalone PEP 751 + rewriting selects the exact package version; duplicate entries for the same + name and version are refused when source selection is ambiguous. +- Hosted requirements select exact `==`/`===` pins or identifiable archive URLs. + Other versions remain unchanged. A bare requirement is rewritten only when + one row and one override version identify the selection. Ranges, wildcard + pins, opaque URLs, and ambiguous unpinned rows are reported as + `redirect_requirements_version_ambiguous` and preserved. +- A script lock does not replace the main project's lockfile selection merely + by sharing its directory. An unrelated script lock does not block vendoring + a package from the project's requirements or Poetry lock. When multiple + applicable package-manager locks coexist, the CLI reports its precedence + choice and the locks it leaves unchanged. +- Vendored installation needs the committed artifact tree. Older uv versions + can also require build dependencies for the root project; an unavailable + offline build dependency is distinct from failure to install the patched wheel. + +Revert state retains the original wiring. Script and lock edits are treated as a +pair: conflicting changes preserve both files and their recovery state rather +than restoring only one side. Tests also cover restoring one package while +preserving another package's vendored entries. + +## Reproduce the release-family matrix + +The matrix pins these 14 binaries: `0.0.5`, `0.1.45`, `0.2.37`, `0.3.5`, +`0.4.30`, `0.5.31`, `0.6.0`, `0.6.17`, `0.7.22`, `0.8.24`, `0.9.30`, +`0.10.12`, `0.11.33`, and `0.12.13`. They cover uv 0.0 through 0.12 and the +additional 0.6 lock-revision boundary. This is release-family coverage, not a +claim that every patch release was tested. + +From the repository root on macOS or Linux: + +```sh +cargo build -p socket-patch-cli +python3 scripts/backtest-uv.py \ + --socket-patch target/debug/socket-patch \ + --socket-patch-revision "$(git rev-parse HEAD)" \ + --python /path/to/python3 \ + --output /tmp/socket-patch-uv-backtest +``` + +Use Python 3.9 to match the recorded probes; the fixtures declare it as their +minimum. +`--versions` can select a smaller diagnostic run. The script downloads pinned uv +binaries and the pristine urllib3 wheel from PyPI and verifies their registry +hashes. It runs against the public patch proxy without an API token. + +For each binary, the run records command lines, exit codes, output, artifact +hashes, and installed `urllib3/response.py` hashes. It exercises native locks, +plain and hashed requirements, requirements/PEP 751 exports, standalone PEP 751 +compilation, and script locks where the uv binary provides those commands. +Unsupported commands remain visible in the results; they are not counted as +successful installation tests. A successful CLI exit with a refusal warning is +also not counted as a successful rewrite. + +Keep live download grants out of committed evidence. Published patch UUIDs, +archive filenames, hashes, uv versions, and redacted command results are enough +to identify a run. Compare the fresh installed bytes with the patched artifact, +not just with a URL or a success message. + +## Full matrix results + +The complete run finished on **2026-09-14**, using macOS **26.6.2 arm64** and +Python **3.9.6**. It tested socket-patch source commit +`e11bd419ea9c01b3ecd1aa894b55874718a3ff0a` (`socket-patch 4.0.0`), with binary +SHA-256: + +```text +eb5f6695a06c2124ac5c09f2117bf42e8777d767aec09c1de6ee16cd9dc4adee +``` + +All **230 installed-byte comparisons passed**, with zero mismatches. All **99 +recorded lock-preservation checks passed**. This includes frozen and locked +installs where supported; ordinary installs also delivered the patched bytes. +The [machine-readable results](uv-compatibility/results.json) contain all 495 +observations and their command definitions. The [binary catalog](uv-compatibility/binaries.json) +records each uv wheel's public PyPI source and verified hash. + +Each paired result below is **hosted / vendored**. “Pass” means the installed +`urllib3/response.py` matched the published patch; “—” means that uv binary did +not provide the format or command. Requirements include plain and hashed +compilation. PEP 751 covers both standalone locks and exported locks. + +| uv | Native grammar | Native H/V | Requirements H/V | Requirements export H/V | Scripts H/V | PEP 751 H/V | Verified installs | +|----|----------------|------------|------------------|-------------------------|-------------|-------------|-------------------| +| 0.0.5 | No native lock | — / — | Pass / rejected path | — / — | — / — | — / — | 2 | +| 0.1.45 | `distribution`, v1 | Pass / refused | Pass / Pass | — / — | — / — | — / — | 6 | +| 0.2.37 | `package`, v1 | Pass / Pass¹ | Pass / Pass | — / — | — / — | — / — | 10 | +| 0.3.5 | `package`, v1 | Pass / Pass | Pass / Pass | — / — | — / — | — / — | 10 | +| 0.4.30 | `package`, v1 | Pass / Pass | Pass / Pass | Pass / Pass | — / — | — / — | 12 | +| 0.5.31 | `package`, v1 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | — / — | 18 | +| 0.6.0 | `package`, v1 r1 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | — / — | 18 | +| 0.6.17 | `package`, v1 r2 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.7.22 | `package`, v1 r2 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.8.24 | `package`, v1 r3 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.9.30 | `package`, v1 r3 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.10.12 | `package`, v1 r3 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.11.33 | `package`, v1 r3 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | +| 0.12.13 | `package`, v1 r3 | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | Pass / Pass | 22 | + +The nonzero outcomes were the documented boundaries: + +- uv 0.0.5 rejected vendored requirements' local wheel path syntax, although + both hosted requirements variants installed the patch. +- Native vendoring on uv 0.1.45 was refused with + `pypi_uv_legacy_lock_unsupported`; hosted native installs and both requirements + modes installed the patch. +- ¹ uv 0.2.37's cold offline native install could not build the root fixture + because `setuptools>=40.8.0` was absent from its empty cache. The network-enabled + retry installed the patched wheel. Its subsequent locked and ordinary + installation checks also passed. +- Export, script-lock, and PEP 751 commands unavailable in older binaries were + recorded as unavailable, not installation successes. Some older uv binaries + accepted an output filename ending in `pylock.toml` but emitted requirements + text; those results have `formatSupported: false`. + +## Completed conditional-requirements and refusal checks + +The following checks ran on 2026-09-14 with uv `0.12.13`, Python `3.9.6`, the +rebuilt CLI, real PyPI distributions, and the public Socket patch service. +Their [sanitized command evidence](uv-compatibility/conditional-probes.json) +records both the installed hashes and the byte-preservation assertions. These +supplemental probes were captured during implementation; their individual CLI +binary hashes were not recorded, so they are kept separate from the exact-source +matrix above. + +| Case | Observed result | +|------|-----------------| +| `urllib3==1.26.18` for Python below 3.10; `urllib3==2.6.3` for Python 3.10 and newer | Only 1.26.18 received a patch. The complete 2.6.3 requirement and original hash continuations remained byte-identical. Fresh hash-verified installation succeeded. | +| The same markers selecting 1.26.18 and 2.0.0, both with published patches | Each version received its own artifact URL and hash. A repeated scan and fresh hash-verified installation succeeded; one override did not replace the other's version. | +| Hashed `urllib3[socks]==1.26.18` with a platform marker | Extras and the marker survived the rewrite; unrelated dependency hashes were preserved. Fresh hash-verified installation succeeded. | +| A PEP 723 script locking both 1.26.18 and 2.0.0 | Hosted and vendored scans reported the competing-version refusal. Both the script and its lock remained byte-identical. | +| A native project locking both 1.26.18 and 2.0.0 | Hosted scan reported the competing-version refusal. Both `pyproject.toml` and `uv.lock` remained byte-identical. | + +The requirements inputs were generated with real uv compilation, including: + +```sh +uv pip compile requirements.in \ + --universal --python-version 3.9 \ + --generate-hashes --no-strip-markers \ + --output-file requirements.txt +socket-patch scan --mode hosted --json --yes --no-telemetry +uv venv .venv-sync --python /path/to/python3.9 +uv pip sync --python .venv-sync/bin/python \ + --require-hashes requirements.txt +``` + +The extras case also used `--no-strip-extras`. For the selected 1.26.18 patch, +UUID `e828efa5-5c6d-43f3-9909-03f5ac232b98`, the freshly installed +`urllib3/response.py` matched the published patched wheel's SHA-256: + +```text +21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4 +``` + +This hash records the patch selected for that run. Production patch ordering +can change; a later run must compare against the artifact it actually selects. diff --git a/docs/testing/uv-compatibility/binaries.json b/docs/testing/uv-compatibility/binaries.json new file mode 100644 index 00000000..5a97503d --- /dev/null +++ b/docs/testing/uv-compatibility/binaries.json @@ -0,0 +1,100 @@ +[ + { + "version": "0.0.5", + "url": "https://files.pythonhosted.org/packages/0c/49/fe6bd6b2ca5b661461d608218ca79a004ae0f51dd069e33b629714fb442b/uv-0.0.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", + "sha256": "2fb16b693c8997100040291890522880dde83f9fda533845ec24352d8becded5", + "uploaded": "2024-02-15T18:56:14.105999Z", + "filename": "uv-0.0.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl" + }, + { + "version": "0.1.45", + "url": "https://files.pythonhosted.org/packages/7f/15/46efcaa86ebef51b8663d8beb95c8376fbdaaf45aeffbc269bf0a3527092/uv-0.1.45-py3-none-macosx_11_0_arm64.whl", + "sha256": "4e5d55f0f8b6ae416c72d78106e224c8e8338356da21ddebecc7b1723de80924", + "uploaded": "2024-05-20T21:05:54.623510Z", + "filename": "uv-0.1.45-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.2.37", + "url": "https://files.pythonhosted.org/packages/e6/84/2c973ddb320642d02d2d117123a61ec6666bbc0143f5263b1e0c791c1252/uv-0.2.37-py3-none-macosx_11_0_arm64.whl", + "sha256": "99d4f0f510c5aa807ef1141fd8cb31f25fb53587dadacb0e28e4f51eaca6f0ad", + "uploaded": "2024-08-16T02:45:56.044623Z", + "filename": "uv-0.2.37-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.3.5", + "url": "https://files.pythonhosted.org/packages/83/8e/956ad3788cfa863cc8de148907e371b025acd97f7a0bb2a9e78ce63c2b1e/uv-0.3.5-py3-none-macosx_11_0_arm64.whl", + "sha256": "89c1515200a838014b1fa6c9cfb2b9a055bcad3178ccf7d31768bf38b43cac65", + "uploaded": "2024-08-27T17:10:36.293543Z", + "filename": "uv-0.3.5-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.4.30", + "url": "https://files.pythonhosted.org/packages/67/37/8994c3d0be99851a21a6ee01bbf3cb35ddc4b202a2f6f4014098d5893660/uv-0.4.30-py3-none-macosx_11_0_arm64.whl", + "sha256": "353617bfcf72e1eabade426d83fb86a69d11273d1612aabc3f4566d41c596c97", + "uploaded": "2024-11-05T01:13:59.667063Z", + "filename": "uv-0.4.30-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.5.31", + "url": "https://files.pythonhosted.org/packages/1f/5a/1eb42f481a9f9010c8c194d70ab375a6eda96d67ca1fd011bf869d4016c8/uv-0.5.31-py3-none-macosx_11_0_arm64.whl", + "sha256": "335c16f91b46b4f4a3b31c18cf112a0643d59d4c1708a177103621da0addbaef", + "uploaded": "2025-02-12T21:28:12.802785Z", + "filename": "uv-0.5.31-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.6.0", + "url": "https://files.pythonhosted.org/packages/6d/c3/c1e81bfdd3414492650ca2647dbb466bb9e0063ee71020dc49f5f011f9ac/uv-0.6.0-py3-none-macosx_11_0_arm64.whl", + "sha256": "26d655adf59ec088f07a2459de3f5e0565e8f84f389bfe936a354e5e169dfc8f", + "uploaded": "2025-02-14T18:20:31.778738Z", + "filename": "uv-0.6.0-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.6.17", + "url": "https://files.pythonhosted.org/packages/a5/4f/66c7153120c155446f319647c1bafec2d9288f2b48d769cd9f9da39aa1f2/uv-0.6.17-py3-none-macosx_11_0_arm64.whl", + "sha256": "ce243bec19c47cc274e7e9eedbaeeb3dacbe94430b0f085dd506ba15a41676ee", + "uploaded": "2025-04-25T18:51:15.410083Z", + "filename": "uv-0.6.17-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.7.22", + "url": "https://files.pythonhosted.org/packages/64/f5/0ee734f5e988fd8f26aad1f150703fe8c7d664029c9c677b989b69caf104/uv-0.7.22-py3-none-macosx_11_0_arm64.whl", + "sha256": "573edda226dc26e6fea03aa89a45af2f2a367ad1f466af15c4eb54286dae042f", + "uploaded": "2025-07-17T17:00:10.010548Z", + "filename": "uv-0.7.22-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.8.24", + "url": "https://files.pythonhosted.org/packages/ea/00/08f4e93989129bb3378f20315dddcac6f8cf26a12bdd90443a340e7ecdb4/uv-0.8.24-py3-none-macosx_11_0_arm64.whl", + "sha256": "a2bd708a545c1c21d7be8575f4cff00d0cff26be13fc81e3f7e54b8751fb90c0", + "uploaded": "2025-10-07T03:33:24.533046Z", + "filename": "uv-0.8.24-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.9.30", + "url": "https://files.pythonhosted.org/packages/42/5f/3ccc9415ef62969ed01829572338ea7bdf4c5cf1ffb9edc1f8cb91b571f3/uv-0.9.30-py3-none-macosx_11_0_arm64.whl", + "sha256": "777ecd117cf1d8d6bb07de8c9b7f6c5f3e802415b926cf059d3423699732eb8c", + "uploaded": "2026-02-04T21:45:40.881824Z", + "filename": "uv-0.9.30-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.10.12", + "url": "https://files.pythonhosted.org/packages/ce/db/c41ace81b8ef5d5952433df38e321c0b6e5f88ce210c508b14f84817963f/uv-0.10.12-py3-none-macosx_11_0_arm64.whl", + "sha256": "551f799d53e397843b6cde7e3c61de716fb487da512a21a954b7d0cbc06967e0", + "uploaded": "2026-03-19T21:50:53.693778Z", + "filename": "uv-0.10.12-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.11.33", + "url": "https://files.pythonhosted.org/packages/4d/e5/17a4e36299e9bd5e8101680be697c7832afac686d1fe8b28be28046c1d95/uv-0.11.33-py3-none-macosx_11_0_arm64.whl", + "sha256": "8017991a398a55d177c33ecdb29beb33e7b53969921183e4681e3e5b278d73c2", + "uploaded": "2026-07-28T10:24:17.589531Z", + "filename": "uv-0.11.33-py3-none-macosx_11_0_arm64.whl" + }, + { + "version": "0.12.13", + "url": "https://files.pythonhosted.org/packages/2a/33/ef14dc7c9c4cfaf0c3a4aed5a298b76b5dea94c75b233f7620c49c9b2e09/uv-0.12.13-py3-none-macosx_11_0_arm64.whl", + "sha256": "f86e5f02883c2e7a21bf522f4aa520c20d4f779d9a3368fbd6cdfe4a8f9549b5", + "uploaded": "2026-09-10T19:25:08.355776Z", + "filename": "uv-0.12.13-py3-none-macosx_11_0_arm64.whl" + } +] diff --git a/docs/testing/uv-compatibility/conditional-probes.json b/docs/testing/uv-compatibility/conditional-probes.json new file mode 100644 index 00000000..8cdb6af5 --- /dev/null +++ b/docs/testing/uv-compatibility/conditional-probes.json @@ -0,0 +1,573 @@ +{ + "date": "2026-09-14", + "uvVersion": "0.12.13", + "pythonVersion": "3.9.6", + "provenance": "Supplemental real-service probes captured during implementation; an exact CLI binary hash was not recorded for these individual probes. The separately recorded release-family matrix identifies its tested source and binary.", + "redactions": [ + "Working directories and executable paths use placeholders.", + "Hosted artifact URLs are redacted to remove download grants.", + "CLI output keeps command results and refusal details; patch catalog descriptions are omitted." + ], + "cases": [ + { + "case": "conditional-unpatched-version", + "otherVersionRequirementByteIdentical": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "expectedPatchedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "commands": [ + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 0.82ms\nPrepared 1 package in 5ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "scan-single-published-version", + "exitCode": 0, + "cliResult": { + "status": "success", + "scannedPackages": 2, + "lockfileOnlyPackages": 1, + "redirect": { + "mode": "hosted", + "redirected": 1, + "rewrittenFiles": [ + "requirements.txt" + ], + "skipped": [], + "warnings": [], + "dryRun": false + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\n" + }, + { + "command": [ + "", + "venv", + ".venv-sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "fresh-venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv-sync\nActivate with: source .venv-sync/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "sync", + "--python", + "/.venv-sync/bin/python", + "--require-hashes", + "requirements.txt" + ], + "cwd": "", + "key": "hash-verified-sync", + "exitCode": 0, + "stderr": "Using Python 3.9.6 environment at: .venv-sync\nResolved 1 package in 551ms\nPrepared 1 package in 157ms\nInstalled 1 package in 5ms\n + urllib3==1.26.18 (from https://patch.socket.dev/\n" + } + ] + }, + { + "case": "conditional-two-patched-versions", + "bothPublishedVersionsRetainOwnArtifact": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "expectedPatchedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "commands": [ + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 1ms\nPrepared 1 package in 6ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "scan-two-published-versions-rerun", + "exitCode": 0, + "cliResult": { + "status": "success", + "scannedPackages": 1, + "lockfileOnlyPackages": 0, + "redirect": { + "mode": "hosted", + "redirected": 1, + "rewrittenFiles": [], + "skipped": [], + "warnings": [], + "dryRun": false + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\n" + }, + { + "command": [ + "", + "venv", + ".venv-sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "fresh-venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv-sync\nActivate with: source .venv-sync/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "sync", + "--python", + "/.venv-sync/bin/python", + "--require-hashes", + "requirements.txt" + ], + "cwd": "", + "key": "hash-verified-sync", + "exitCode": 0, + "stderr": "Using Python 3.9.6 environment at: .venv-sync\nResolved 1 package in 1.75s\nPrepared 1 package in 218ms\nInstalled 1 package in 4ms\n + urllib3==1.26.18 (from https://patch.socket.dev/\n" + } + ] + }, + { + "case": "extras-and-marker", + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "expectedPatchedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "commands": [ + { + "command": [ + "", + "pip", + "compile", + "requirements.in", + "--generate-hashes", + "--universal", + "--no-strip-extras", + "--no-strip-markers", + "-o", + "requirements.txt" + ], + "cwd": "", + "key": "compile", + "exitCode": 0, + "stdout": "# This file was autogenerated by uv via the following command:\n# uv pip compile requirements.in --generate-hashes --universal --no-strip-extras --no-strip-markers -o requirements.txt\npysocks==1.7.1 ; sys_platform == 'darwin' \\\n --hash=sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299 \\\n --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \\\n --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0\n # via urllib3\nurllib3[socks]==1.26.18 ; sys_platform == 'darwin' \\\n --hash=sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07 \\\n --hash=sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0\n # via -r requirements.in\n", + "stderr": "Resolved 2 packages in 261ms\n" + }, + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 0.84ms\nPrepared 1 package in 6ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "socket-patch", + "exitCode": 0, + "cliResult": { + "status": "success", + "scannedPackages": 2, + "lockfileOnlyPackages": 1, + "redirect": { + "mode": "hosted", + "redirected": 1, + "rewrittenFiles": [ + "requirements.txt" + ], + "skipped": [], + "warnings": [], + "dryRun": false + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\n" + }, + { + "command": [ + "", + "venv", + ".venv-sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "fresh-venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv-sync\nActivate with: source .venv-sync/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "sync", + "--python", + "/.venv-sync/bin/python", + "--require-hashes", + "requirements.txt" + ], + "cwd": "", + "key": "hashed-sync", + "exitCode": 0, + "stderr": "Using Python 3.9.6 environment at: .venv-sync\nResolved 2 packages in 424ms\nPrepared 2 packages in 142ms\nInstalled 2 packages in 4ms\n + pysocks==1.7.1\n + urllib3==1.26.18 (from https://patch.socket.dev/\n" + } + ] + }, + { + "case": "script-hosted-refusal", + "kind": "script", + "mode": "hosted", + "filesByteIdentical": { + "job.py": true, + "job.py.lock": true + }, + "commands": [ + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 1ms\nPrepared 1 package in 7ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "scan", + "exitCode": 0, + "cliResult": { + "status": "success", + "scannedPackages": 2, + "lockfileOnlyPackages": 1, + "redirect": { + "mode": "hosted", + "redirected": 0, + "rewrittenFiles": [], + "skipped": [], + "warnings": [ + { + "code": "redirect_uv_lock_unsupported", + "detail": "job.py.lock: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + }, + { + "code": "redirect_uv_lock_unsupported", + "detail": "job.py.lock: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + } + ], + "dryRun": false + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\nNon-interactive mode: auto-selecting first option.\n" + } + ] + }, + { + "case": "script-vendored-refusal", + "kind": "script", + "mode": "vendored", + "filesByteIdentical": { + "job.py": true, + "job.py.lock": true + }, + "commands": [ + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 1ms\nPrepared 1 package in 13ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "scan", + "exitCode": 1, + "cliResult": { + "status": "partial_failure", + "scannedPackages": 2, + "lockfileOnlyPackages": 1, + "vendor": { + "status": "partialFailure", + "events": [ + { + "action": "skipped", + "purl": "pkg:pypi/urllib3@2.0.0?artifact_id=py3-none-any-whl", + "reason": "pkg:pypi/urllib3@2.0.0?artifact_id=py3-none-any-whl is not installed; fetched the pristine artifact from https://files.pythonhosted.org/packages/ca/25/fe81738a115a2f1005b19bd69b6253b7b5cd6c9119164f13f02ec627fc41/urllib3-2.0.0-py3-none-any.whl (integrity verified) and vendored from that copy \u2014 the project tree was not touched", + "errorCode": "vendor_fetched_missing" + }, + { + "action": "failed", + "purl": "pkg:pypi/urllib3@1.26.18?artifact_id=py2-py3-none-any-whl", + "errorCode": "pypi_lock_unsupported", + "error": "job.py.lock: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + }, + { + "action": "failed", + "purl": "pkg:pypi/urllib3@2.0.0?artifact_id=py3-none-any-whl", + "errorCode": "pypi_lock_unsupported", + "error": "job.py.lock: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + } + ], + "summary": { + "discovered": 0, + "downloaded": 0, + "applied": 0, + "updated": 0, + "skipped": 0, + "failed": 2, + "removed": 0, + "verified": 0 + } + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\nNon-interactive mode: auto-selecting first option.\nNo SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNo SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNo SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\n" + } + ] + }, + { + "case": "project-hosted-refusal", + "kind": "project", + "mode": "hosted", + "filesByteIdentical": { + "pyproject.toml": true, + "uv.lock": true + }, + "commands": [ + { + "command": [ + "", + "lock", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "original-lock", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nResolved 3 packages in 251ms\nwarning: `urllib3==2.0.0` is yanked (reason: \"Truncated response bodies when streaming a large compressed body. Upgrade to at least 2.0.2 (See: https://github.com/urllib3/urllib3/issues/3009)\")\n" + }, + { + "command": [ + "", + "venv", + ".venv", + "--python", + "/usr/bin/python3" + ], + "cwd": "", + "key": "venv", + "exitCode": 0, + "stderr": "Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtual environment at: .venv\nActivate with: source .venv/bin/activate\n" + }, + { + "command": [ + "", + "pip", + "install", + "--python", + "/.venv/bin/python", + "" + ], + "cwd": "", + "key": "install-original", + "exitCode": 0, + "stderr": "Resolved 1 package in 0.79ms\nPrepared 1 package in 6ms\nInstalled 1 package in 1ms\n + urllib3==1.26.18 (from file://)\n" + }, + { + "command": [ + "", + "scan", + "--cwd", + "", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "", + "key": "scan", + "exitCode": 0, + "cliResult": { + "status": "success", + "scannedPackages": 2, + "lockfileOnlyPackages": 1, + "redirect": { + "mode": "hosted", + "redirected": 0, + "rewrittenFiles": [], + "skipped": [], + "warnings": [ + { + "code": "redirect_uv_project_unsupported", + "detail": "pyproject.toml: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + }, + { + "code": "redirect_uv_project_unsupported", + "detail": "pyproject.toml: urllib3 resolves to multiple versions; a global uv source would replace other versions, so marker-specific source mappings are required" + } + ], + "dryRun": false + } + }, + "stderr": "No SOCKET_API_TOKEN set (and no socket-cli login found) \u2014 using the public patch API proxy (free patches only). Run `socket login` or set SOCKET_API_TOKEN to access org patches.\nNon-interactive mode: auto-selecting first option.\nNon-interactive mode: auto-selecting first option.\n" + } + ] + } + ] +} diff --git a/docs/testing/uv-compatibility/results.json b/docs/testing/uv-compatibility/results.json new file mode 100644 index 00000000..bbc3323e --- /dev/null +++ b/docs/testing/uv-compatibility/results.json @@ -0,0 +1,3897 @@ +{ + "date": "2026-09-14", + "scope": "14 pinned uv releases on macOS-26.6.2-arm64-arm-64bit; interpreter /usr/bin/python3", + "socketPatchRevision": "e11bd419ea9c01b3ecd1aa894b55874718a3ff0a", + "socketPatchVersion": "socket-patch 4.0.0", + "socketPatchBinarySha256": "eb5f6695a06c2124ac5c09f2117bf42e8777d767aec09c1de6ee16cd9dc4adee", + "patchUuid": "e828efa5-5c6d-43f3-9909-03f5ac232b98", + "originalWheelSha256": "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + "patchedWheelSha256": "ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6", + "patchedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "commands": { + "lock": { + "args": [ + "/bin//uv", + "lock", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//original" + }, + "requirements-hosted-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//requirements-hosted", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//requirements-hosted" + }, + "requirements-hosted-pip-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt" + ], + "cwd": "/matrix//requirements-hosted" + }, + "requirements-vendored-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//requirements-vendored", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//requirements-vendored" + }, + "requirements-vendored-pip-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt" + ], + "cwd": "/matrix//requirements-vendored" + }, + "compile-plain": { + "args": [ + "/bin//uv", + "pip", + "compile", + "requirements.in", + "-o", + "requirements.txt" + ], + "cwd": "/matrix//requirements-plain-hosted" + }, + "requirements-plain-hosted-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//requirements-plain-hosted", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//requirements-plain-hosted" + }, + "requirements-plain-hosted-pip-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt" + ], + "cwd": "/matrix//requirements-plain-hosted" + }, + "compile-plain-variant-2": { + "args": [ + "/bin//uv", + "pip", + "compile", + "requirements.in", + "-o", + "requirements.txt" + ], + "cwd": "/matrix//requirements-plain-vendored" + }, + "requirements-plain-vendored-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//requirements-plain-vendored", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//requirements-plain-vendored" + }, + "requirements-plain-vendored-pip-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt" + ], + "cwd": "/matrix//requirements-plain-vendored" + }, + "script-lock-hosted": { + "args": [ + "/bin//uv", + "lock", + "--script", + "example.py", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//script-direct-hosted" + }, + "script-lock-vendored": { + "args": [ + "/bin//uv", + "lock", + "--script", + "example.py", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//script-direct-vendored" + }, + "compile-pylock-hosted": { + "args": [ + "/bin//uv", + "pip", + "compile", + "requirements.in", + "--python-version", + "3.9", + "-o", + "pylock.toml" + ], + "cwd": "/matrix//pylock-direct-hosted" + }, + "compile-pylock-vendored": { + "args": [ + "/bin//uv", + "pip", + "compile", + "requirements.in", + "--python-version", + "3.9", + "-o", + "pylock.toml" + ], + "cwd": "/matrix//pylock-direct-vendored" + }, + "project-hosted-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//project-hosted", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//project-hosted" + }, + "project-hosted-export-requirements-txt": { + "args": [ + "/bin//uv", + "export", + "--frozen", + "--format", + "requirements-txt", + "--output-file", + "export-requirements.txt" + ], + "cwd": "/matrix//project-hosted" + }, + "project-hosted-export-pylock.toml": { + "args": [ + "/bin//uv", + "export", + "--frozen", + "--format", + "pylock.toml", + "--output-file", + "pylock.toml" + ], + "cwd": "/matrix//project-hosted" + }, + "project-hosted-lock-sync": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-hosted" + }, + "project-vendored-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//project-vendored", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//project-vendored" + }, + "project-hosted-unfrozen-install": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-unfrozen-hosted" + }, + "project-hosted-lock-sync-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--frozen" + ], + "cwd": "/matrix//project-hosted" + }, + "project-vendored-export-requirements-txt": { + "args": [ + "/bin//uv", + "export", + "--frozen", + "--format", + "requirements-txt", + "--output-file", + "export-requirements.txt" + ], + "cwd": "/matrix//project-vendored" + }, + "project-vendored-export-pylock.toml": { + "args": [ + "/bin//uv", + "export", + "--frozen", + "--format", + "pylock.toml", + "--output-file", + "pylock.toml" + ], + "cwd": "/matrix//project-vendored" + }, + "project-vendored-lock-sync": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--frozen", + "--offline" + ], + "cwd": "/matrix//project-vendored" + }, + "project-vendored-frozen-sync-root-build-networked": { + "args": [ + "/bin//uv", + "sync", + "--frozen", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-vendored" + }, + "project-hosted-locked-install": { + "args": [ + "/bin//uv", + "sync", + "--locked", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-unfrozen-hosted" + }, + "project-vendored-locked-install": { + "args": [ + "/bin//uv", + "sync", + "--locked", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-unfrozen-vendored" + }, + "project-vendored-unfrozen-install": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3" + ], + "cwd": "/matrix//project-unfrozen-vendored" + }, + "project-hosted-lock-sync-variant-3": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--frozen", + "--no-install-project" + ], + "cwd": "/matrix//project-hosted" + }, + "project-vendored-lock-sync-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--frozen", + "--no-install-project", + "--offline" + ], + "cwd": "/matrix//project-vendored" + }, + "project-hosted-locked-install-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--locked", + "--python", + "/usr/bin/python3", + "--no-install-project" + ], + "cwd": "/matrix//project-unfrozen-hosted" + }, + "project-hosted-unfrozen-install-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--no-install-project" + ], + "cwd": "/matrix//project-unfrozen-hosted" + }, + "project-vendored-locked-install-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--locked", + "--python", + "/usr/bin/python3", + "--no-install-project" + ], + "cwd": "/matrix//project-unfrozen-vendored" + }, + "project-vendored-unfrozen-install-variant-2": { + "args": [ + "/bin//uv", + "sync", + "--python", + "/usr/bin/python3", + "--no-install-project" + ], + "cwd": "/matrix//project-unfrozen-vendored" + }, + "requirements-hosted-export-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt" + ], + "cwd": "/matrix//export-requirements-hosted" + }, + "requirements-vendored-export-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "requirements.txt", + "--offline" + ], + "cwd": "/matrix//export-requirements-vendored" + }, + "script-direct-hosted-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//script-direct-hosted", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//script-direct-hosted" + }, + "script-direct-hosted-install": { + "args": [ + "/bin//uv", + "run", + "--frozen", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-direct-hosted" + }, + "script-direct-vendored-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//script-direct-vendored", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//script-direct-vendored" + }, + "script-direct-vendored-install": { + "args": [ + "/bin//uv", + "run", + "--offline", + "--frozen", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-direct-vendored" + }, + "script-hosted-locked-install": { + "args": [ + "/bin//uv", + "run", + "--locked", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-unfrozen-hosted" + }, + "script-hosted-unfrozen-install": { + "args": [ + "/bin//uv", + "run", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-unfrozen-hosted" + }, + "script-vendored-locked-install": { + "args": [ + "/bin//uv", + "run", + "--locked", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-unfrozen-vendored" + }, + "script-vendored-unfrozen-install": { + "args": [ + "/bin//uv", + "run", + "--python", + "/usr/bin/python3", + "--script", + "example.py" + ], + "cwd": "/matrix//script-unfrozen-vendored" + }, + "pylock-hosted-export-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "pylock.toml" + ], + "cwd": "/matrix//export-pylock-hosted" + }, + "pylock-vendored-export-sync": { + "args": [ + "/bin//uv", + "pip", + "sync", + "pylock.toml", + "--offline" + ], + "cwd": "/matrix//export-pylock-vendored" + }, + "pylock-direct-hosted-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//pylock-direct-hosted", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//pylock-direct-hosted" + }, + "pylock-direct-hosted-install": { + "args": [ + "/bin//uv", + "pip", + "sync", + "pylock.toml" + ], + "cwd": "/matrix//pylock-direct-hosted" + }, + "pylock-direct-vendored-socket-patch": { + "args": [ + "", + "scan", + "--cwd", + "/matrix//pylock-direct-vendored", + "--mode", + "vendored", + "--json", + "--yes", + "--no-telemetry" + ], + "cwd": "/matrix//pylock-direct-vendored" + }, + "pylock-direct-vendored-install": { + "args": [ + "/bin//uv", + "pip", + "--offline", + "sync", + "pylock.toml" + ], + "cwd": "/matrix//pylock-direct-vendored" + } + }, + "versions": [ + { + "version": "0.0.5", + "lockSchema": null, + "lockVersion": null, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'lock'\n\n tip: a similar subcommand exists: 'uv pip compile'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 2, + "diagnostic": "error: Unexpected '.', expected '-c', '-e', '-r' or the start of a requirement in `requirements.txt` at position 144\n" + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 2, + "diagnostic": "error: Unexpected '.', expected '-c', '-e', '-r' or the start of a requirement in `requirements.txt` at position 126\n" + }, + { + "command": "script-lock-hosted", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'lock'\n\n tip: a similar subcommand exists: 'uv pip compile'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "script-lock-vendored", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'lock'\n\n tip: a similar subcommand exists: 'uv pip compile'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + } + ] + }, + { + "version": "0.1.45", + "lockSchema": "distribution", + "lockVersion": 1, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 1, + "vendorSummary": { + "applied": 0, + "failed": 1 + }, + "vendorErrors": [ + { + "action": "failed", + "purl": "pkg:pypi/urllib3@1.26.18?artifact_id=py2-py3-none-any-whl", + "errorCode": "pypi_uv_legacy_lock_unsupported", + "error": "uv 0.1 lockfiles require absolute file URLs; upgrade to uv >=0.2 for portable native vendoring, or use a requirements.txt installation" + } + ] + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "script-lock-vendored", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.2.37", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-lock-sync", + "exitCode": 2, + "lockUnchanged": true, + "diagnostic": "warning: `uv sync` is experimental and may change without warning\nUsing Python 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3\nCreating virtualenv at: .venv\nerror: Failed to prepare distributions\n Caused by: Failed to fetch wheel: socket-uv-patch-fixture @ file:///matrix/0.2.37/project-vendored\n Caused by: Failed to install requirements from setup.py build (resolve)\n Caused by: No solution found when resolving: setuptools>=40.8.0\n Caused by: Because setuptools was not found in the cache and you require setuptools>=40.8.0, we can conclude that your requirements are unsatisfiable.\n\nhint: Packages were unavailable because the network was disabled\n" + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-frozen-sync-root-build-networked", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "script-lock-vendored", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.3.5", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: unrecognized subcommand 'export'\n\nUsage: uv [OPTIONS] \n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "script-lock-vendored", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.4.30", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "script-lock-vendored", + "exitCode": 2, + "diagnostic": "error: unexpected argument '--script' found\n\nUsage: uv lock [OPTIONS]\n\nFor more information, try '--help'.\n" + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.5.31", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": null, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.6.0", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 1, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 2, + "diagnostic": "error: invalid value 'pylock.toml' for '--format '\n [possible values: requirements-txt]\n\nFor more information, try '--help'.\n" + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": false + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.6.17", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 2, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.7.22", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 2, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.8.24", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 3, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.9.30", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 3, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.10.12", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 3, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.11.33", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 3, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + }, + { + "version": "0.12.13", + "lockSchema": "package", + "lockVersion": 1, + "lockRevision": 3, + "observations": [ + { + "command": "lock", + "exitCode": 0 + }, + { + "command": "project-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pyproject.toml", + "uv.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "project-hosted-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-hosted-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-hosted-lock-sync-variant-3", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "project-vendored-export-requirements-txt", + "exitCode": 0 + }, + { + "command": "project-vendored-export-pylock.toml", + "exitCode": 0 + }, + { + "command": "project-vendored-lock-sync-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain", + "exitCode": 0 + }, + { + "command": "requirements-plain-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "requirements.txt" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "requirements-plain-hosted-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-plain-variant-2", + "exitCode": 0 + }, + { + "command": "requirements-plain-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "requirements-plain-vendored-pip-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-hosted-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "requirements-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "pylock-vendored-export-sync", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-hosted", + "exitCode": 0 + }, + { + "command": "script-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "example.py", + "example.py.lock" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "script-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-lock-vendored", + "exitCode": 0 + }, + { + "command": "script-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "script-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-hosted", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-hosted-socket-patch", + "exitCode": 0, + "rewrittenFiles": [ + "pylock.toml" + ], + "redirected": 1, + "warnings": [] + }, + { + "command": "pylock-direct-hosted-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "compile-pylock-vendored", + "exitCode": 0, + "formatSupported": true + }, + { + "command": "pylock-direct-vendored-socket-patch", + "exitCode": 0, + "vendorSummary": { + "applied": 1, + "failed": 0 + }, + "vendorErrors": [] + }, + { + "command": "pylock-direct-vendored-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-hosted-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-locked-install-variant-2", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "project-vendored-unfrozen-install-variant-2", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-hosted-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-locked-install", + "exitCode": 0, + "lockUnchanged": true, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + }, + { + "command": "script-vendored-unfrozen-install", + "exitCode": 0, + "installedResponseSha256": "21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4", + "installedPatch": true + } + ] + } + ] +} diff --git a/scripts/backtest-uv.py b/scripts/backtest-uv.py new file mode 100644 index 00000000..18173127 --- /dev/null +++ b/scripts/backtest-uv.py @@ -0,0 +1,803 @@ +import concurrent.futures +import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import zipfile + +import argparse +import io +import platform +import sys +import urllib.request + +VERSIONS = [ + '0.0.5', + '0.1.45', + '0.2.37', + '0.3.5', + '0.4.30', + '0.5.31', + '0.6.0', + '0.6.17', + '0.7.22', + '0.8.24', + '0.9.30', + '0.10.12', + '0.11.33', + '0.12.13', +] +parser = argparse.ArgumentParser() +parser.add_argument('--socket-patch', type=Path, required=True) +parser.add_argument('--socket-patch-revision', required=True) +parser.add_argument('--output', type=Path, required=True) +parser.add_argument('--python', default=sys.executable) +parser.add_argument('--versions', nargs='+', default=VERSIONS) +args = parser.parse_args() +ROOT = args.output.resolve() +CLI = args.socket_patch.resolve() +BOOTSTRAP = ROOT / 'bin/0.12.13/uv' +WHEEL = ROOT / 'urllib3-1.26.18-py2.py3-none-any.whl' +ENV = { + key: value + for key, value in os.environ.items() + if not key.startswith(('UV_', 'PIP_', 'PYTHON', 'SOCKET_')) and key != 'VIRTUAL_ENV' +} +ENV['SOCKET_NO_CONFIG'] = '1' +ENV['SOCKET_TELEMETRY_DISABLED'] = '1' +ROOT.mkdir(parents=True, exist_ok=True) + + +def fetch_json(url): + with urllib.request.urlopen(url, timeout=90) as response: + return json.load(response) + + +def download(file): + with urllib.request.urlopen(file['url'], timeout=90) as response: + data = response.read() + if hashlib.sha256(data).hexdigest() != file['digests']['sha256']: + raise ValueError('download hash mismatch: ' + file['filename']) + return data + + +def install_binaries(): + system = platform.system() + machine = platform.machine().lower() + if system == 'Darwin': + marker = 'macosx' + architecture = 'arm64' if machine in ['arm64', 'aarch64'] else 'x86_64' + elif system == 'Linux': + marker = 'manylinux' + architecture = 'aarch64' if machine in ['arm64', 'aarch64'] else 'x86_64' + else: + raise ValueError('This backtest requires macOS or Linux') + registry = fetch_json('https://pypi.org/pypi/uv/json') + records = [] + for version in dict.fromkeys([*args.versions, '0.12.13']): + file = next( + file + for file in registry['releases'][version] + if marker in file['filename'] and architecture in file['filename'] + ) + folder = ROOT / 'bin' / version + folder.mkdir(parents=True, exist_ok=True) + archive = zipfile.ZipFile(io.BytesIO(download(file))) + executable = next(name for name in archive.namelist() if name.endswith('/uv')) + (folder / 'uv').write_bytes(archive.read(executable)) + (folder / 'uv').chmod(0o755) + records.append( + { + 'version': version, + 'url': file['url'], + 'sha256': file['digests']['sha256'], + 'uploaded': file['upload_time_iso_8601'], + 'filename': file['filename'], + } + ) + (ROOT / 'binaries.json').write_text(json.dumps(records, indent=2) + '\n') + release = fetch_json('https://pypi.org/pypi/urllib3/1.26.18/json') + file = next(file for file in release['urls'] if file['filename'] == WHEEL.name) + WHEEL.write_bytes(download(file)) + + +def run(exe, args, cwd, key, rows): + env = dict(ENV, UV_CACHE_DIR=str(cwd / '.uv-cache')) + try: + out = subprocess.run( + [str(exe), *args], + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=180, + ) + row = { + 'key': key, + 'command': [str(exe), *args], + 'cwd': str(cwd), + 'exitCode': out.returncode, + 'stdout': out.stdout, + 'stderr': out.stderr, + } + except subprocess.TimeoutExpired as err: + row = { + 'key': key, + 'command': [str(exe), *args], + 'cwd': str(cwd), + 'exitCode': 124, + 'stdout': err.stdout.decode() + if isinstance(err.stdout, bytes) + else err.stdout or '', + 'stderr': 'timed out', + } + rows.append(row) + return row + + +def bootstrap(path, rows): + run(BOOTSTRAP, ['venv', '.venv', '--python', args.python], path, 'venv', rows) + run( + BOOTSTRAP, + ['pip', 'install', '--python', str(path / '.venv/bin/python'), str(WHEEL)], + path, + 'install-original', + rows, + ) + + +def project_matrix(version): + exe = ROOT / 'bin' / version / 'uv' + base = ROOT / 'matrix' / version + original = base / 'original' + original.mkdir(parents=True, exist_ok=True) + generation = [] + (original / 'pyproject.toml').write_text( + '[project]\nname = "socket-uv-patch-fixture"\n' + 'version = "0.1.0"\nrequires-python = ">=3.9"\n' + 'dependencies = ["urllib3==1.26.18"]\n' + ) + run(exe, ['lock', '--python', args.python], original, 'lock', generation) + if not (original / 'requirements.txt').exists(): + (original / 'requirements.in').write_text('urllib3==1.26.18\n') + run( + exe, + [ + 'pip', + 'compile', + 'requirements.in', + '--generate-hashes', + '-o', + 'requirements.txt', + ], + original, + 'compile', + generation, + ) + (base / 'generate.json').write_text( + json.dumps({'version': version, 'commands': generation}, indent=2) + '\n' + ) + rows = [] + for kind in ['project', 'requirements']: + if kind == 'project' and not (original / 'uv.lock').exists(): + continue + for mode in ['hosted', 'vendored']: + case = base / (kind + '-' + mode) + case.mkdir(exist_ok=True) + for name in ( + ['pyproject.toml', 'uv.lock'] + if kind == 'project' + else ['requirements.txt'] + ): + shutil.copyfile(original / name, case / name) + bootstrap(case, rows) + scan = run( + CLI, + [ + 'scan', + '--cwd', + str(case), + '--mode', + mode, + '--json', + '--yes', + '--no-telemetry', + ], + case, + kind + '-' + mode + '-socket-patch', + rows, + ) + if scan['exitCode'] != 0: + continue + if kind == 'project': + for fmt, filename in [ + ('requirements-txt', 'export-requirements.txt'), + ('pylock.toml', 'pylock.toml'), + ]: + run( + exe, + [ + 'export', + '--frozen', + '--format', + fmt, + '--output-file', + filename, + ], + case, + kind + '-' + mode + '-export-' + fmt, + rows, + ) + help_out = subprocess.run( + [str(exe), 'sync', '--help'], capture_output=True, text=True + ) + sync_args = ['sync', '--python', args.python] + if '--frozen' in help_out.stdout: + sync_args.append('--frozen') + if '--no-install-project' in help_out.stdout: + sync_args.append('--no-install-project') + else: + package = case / 'socket_uv_patch_fixture' + package.mkdir(exist_ok=True) + (package / '__init__.py').write_text('') + shutil.rmtree(case / '.venv') + shutil.rmtree(case / '.uv-cache', ignore_errors=True) + if mode == 'vendored' and '--offline' in help_out.stdout: + sync_args.append('--offline') + locked = (case / 'uv.lock').read_bytes() + sync = run( + exe, sync_args, case, kind + '-' + mode + '-lock-sync', rows + ) + sync['lockUnchanged'] = (case / 'uv.lock').read_bytes() == locked + if not sync['lockUnchanged']: + raise ValueError('Install modified the patched uv lock') + else: + shutil.rmtree(case / '.venv') + run( + BOOTSTRAP, + ['venv', '.venv', '--python', args.python], + case, + kind + '-' + mode + '-fresh-venv', + rows, + ) + shutil.rmtree(case / '.uv-cache', ignore_errors=True) + sync = run( + exe, + ['pip', 'sync', 'requirements.txt'], + case, + kind + '-' + mode + '-pip-sync', + rows, + ) + if sync['exitCode'] == 0: + targets = list( + (case / '.venv/lib').glob( + 'python*/site-packages/urllib3/response.py' + ) + ) + sync['installedResponseSha256'] = ( + hashlib.sha256(targets[0].read_bytes()).hexdigest() + if targets + else None + ) + wheels = ( + list((case / '.socket/vendor/pypi').glob('*/*.whl')) + if mode == 'vendored' + else [] + ) + if wheels: + sync['patchedResponseSha256'] = hashlib.sha256( + zipfile.ZipFile(wheels[0]).read('urllib3/response.py') + ).hexdigest() + (base / 'backtest.json').write_text( + json.dumps({'version': version, 'commands': rows}, indent=2) + '\n' + ) + return { + 'version': version, + 'commands': [ + (r['key'], r['exitCode']) + for r in rows + if r['key'] not in ['venv', 'install-original'] + ], + } + + +def requirements_matrix(version): + exe = ROOT / 'bin' / version / 'uv' + base = ROOT / 'matrix' / version + rows = [] + for mode in ['hosted', 'vendored']: + case = base / ('requirements-plain-' + mode) + case.mkdir(exist_ok=True) + (case / 'requirements.in').write_text('urllib3==1.26.18\n') + run( + exe, + ['pip', 'compile', 'requirements.in', '-o', 'requirements.txt'], + case, + 'compile-plain', + rows, + ) + bootstrap(case, rows) + scan = run( + CLI, + [ + 'scan', + '--cwd', + str(case), + '--mode', + mode, + '--json', + '--yes', + '--no-telemetry', + ], + case, + 'requirements-plain-' + mode + '-socket-patch', + rows, + ) + if scan['exitCode'] != 0: + continue + shutil.rmtree(case / '.venv') + run( + BOOTSTRAP, + ['venv', '.venv', '--python', args.python], + case, + 'fresh-venv', + rows, + ) + shutil.rmtree(case / '.uv-cache', ignore_errors=True) + sync = run( + exe, + ['pip', 'sync', 'requirements.txt'], + case, + 'requirements-plain-' + mode + '-pip-sync', + rows, + ) + if sync['exitCode'] == 0: + target = next( + (case / '.venv/lib').glob('python*/site-packages/urllib3/response.py') + ) + sync['installedResponseSha256'] = hashlib.sha256( + target.read_bytes() + ).hexdigest() + if version == '0.2.37': + case = base / 'project-vendored' + sync = run( + exe, + ['sync', '--frozen', '--python', args.python], + case, + 'project-vendored-frozen-sync-root-build-networked', + rows, + ) + if sync['exitCode'] == 0: + target = next( + (case / '.venv/lib').glob('python*/site-packages/urllib3/response.py') + ) + sync['installedResponseSha256'] = hashlib.sha256( + target.read_bytes() + ).hexdigest() + (base / 'backtest-extensions.json').write_text( + json.dumps({'version': version, 'commands': rows}, indent=2) + '\n' + ) + return { + 'version': version, + 'commands': [ + (r['key'], r['exitCode']) + for r in rows + if r['key'] + not in ['venv', 'install-original', 'fresh-venv', 'compile-plain'] + ], + } + + +def format_matrix(version): + exe = ROOT / 'bin' / version / 'uv' + base = ROOT / 'matrix' / version + rows = [] + for mode in ['hosted', 'vendored']: + for kind, name in [ + ('requirements', 'export-requirements.txt'), + ('pylock', 'pylock.toml'), + ]: + source = base / ('project-' + mode) + if not (source / name).is_file(): + continue + case = base / ('export-' + kind + '-' + mode) + case.mkdir(exist_ok=True) + target_name = ( + 'requirements.txt' if kind == 'requirements' else 'pylock.toml' + ) + shutil.copyfile(source / name, case / target_name) + if mode == 'vendored': + shutil.copytree( + source / '.socket', case / '.socket', dirs_exist_ok=True + ) + run( + BOOTSTRAP, + ['venv', '.venv', '--python', args.python], + case, + 'venv', + rows, + ) + sync_args = ['pip', 'sync', target_name] + if mode == 'vendored': + sync_args.append('--offline') + out = run(exe, sync_args, case, kind + '-' + mode + '-export-sync', rows) + if out['exitCode'] == 0: + target = next( + (case / '.venv/lib').glob( + 'python*/site-packages/urllib3/response.py' + ) + ) + out['installedResponseSha256'] = hashlib.sha256( + target.read_bytes() + ).hexdigest() + for kind in ['script', 'pylock']: + for mode in ['hosted', 'vendored']: + case = base / (kind + '-direct-' + mode) + case.mkdir(exist_ok=True) + if kind == 'script': + (case / 'example.py').write_text( + '# /// script\n# requires-python = ">=3.9"\n# dependencies = ["urllib3==1.26.18"]\n# ///\n' + 'import hashlib\nfrom pathlib import Path\nimport urllib3.response\n' + 'print(hashlib.sha256(Path(urllib3.response.__file__).read_bytes()).hexdigest())\n' + ) + out = run( + exe, + ['lock', '--script', 'example.py', '--python', args.python], + case, + 'script-lock-' + mode, + rows, + ) + else: + (case / 'requirements.in').write_text('urllib3==1.26.18\n') + out = run( + exe, + [ + 'pip', + 'compile', + 'requirements.in', + '--python-version', + '3.9', + '-o', + 'pylock.toml', + ], + case, + 'compile-pylock-' + mode, + rows, + ) + if ( + not (case / 'pylock.toml').is_file() + or 'lock-version = ' not in (case / 'pylock.toml').read_text() + ): + out['formatSupported'] = False + continue + out['formatSupported'] = True + if out['exitCode']: + continue + bootstrap(case, rows) + scan = run( + CLI, + [ + 'scan', + '--cwd', + str(case), + '--mode', + mode, + '--json', + '--yes', + '--no-telemetry', + ], + case, + kind + '-direct-' + mode + '-socket-patch', + rows, + ) + if scan['exitCode']: + continue + shutil.rmtree(case / '.venv') + shutil.rmtree(case / '.uv-cache', ignore_errors=True) + if kind == 'script': + install_args = [ + 'run', + '--frozen', + '--python', + args.python, + '--script', + 'example.py', + ] + else: + run( + BOOTSTRAP, + ['venv', '.venv', '--python', args.python], + case, + 'fresh-venv', + rows, + ) + install_args = ['pip', 'sync', 'pylock.toml'] + if mode == 'vendored': + install_args.insert(1, '--offline') + lockfile = case / ( + 'example.py.lock' if kind == 'script' else 'pylock.toml' + ) + locked = lockfile.read_bytes() + installed = run( + exe, + install_args, + case, + kind + '-direct-' + mode + '-install', + rows, + ) + installed['lockUnchanged'] = lockfile.read_bytes() == locked + if not installed['lockUnchanged']: + raise ValueError('Install modified the patched standalone lock') + if installed['exitCode'] == 0: + if kind == 'script': + digest = installed['stdout'].strip() + if not re.fullmatch(r'[0-9a-f]{64}', digest): + raise ValueError('Script did not report installed file hash') + else: + target = next( + (case / '.venv/lib').glob( + 'python*/site-packages/urllib3/response.py' + ) + ) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + installed['installedResponseSha256'] = digest + (base / 'format-backtest.json').write_text( + json.dumps({'version': version, 'commands': rows}, indent=2) + '\n' + ) + return { + 'version': version, + 'commands': [ + (x['key'], x['exitCode']) + for x in rows + if x['key'] not in ['venv', 'install-original'] + ], + } + + +def unfrozen_matrix(version): + exe = ROOT / 'bin' / version / 'uv' + base = ROOT / 'matrix' / version + rows = [] + for kind in ['project', 'script']: + for mode in ['hosted', 'vendored']: + source = base / ( + kind + ('-direct-' if kind == 'script' else '-') + mode + ) + names = ( + ['pyproject.toml', 'uv.lock'] + if kind == 'project' + else ['example.py', 'example.py.lock'] + ) + lockfile = source / names[1] + if ( + not lockfile.is_file() + or 'e828efa5-5c6d-43f3-9909-03f5ac232b98' + not in lockfile.read_text() + ): + continue + case = base / (kind + '-unfrozen-' + mode) + case.mkdir(exist_ok=True) + for name in names: + shutil.copyfile(source / name, case / name) + if mode == 'vendored' and (source / '.socket').is_dir(): + shutil.copytree( + source / '.socket', case / '.socket', dirs_exist_ok=True + ) + if kind == 'project': + install_args = ['sync', '--python', args.python] + help_out = subprocess.run( + [str(exe), 'sync', '--help'], capture_output=True, text=True + ) + if '--no-install-project' in help_out.stdout: + install_args.append('--no-install-project') + else: + package = case / 'socket_uv_patch_fixture' + package.mkdir(exist_ok=True) + (package / '__init__.py').write_text('') + else: + install_args = [ + 'run', '--python', args.python, '--script', 'example.py' + ] + help_out = subprocess.run( + [str(exe), 'run', '--help'], capture_output=True, text=True + ) + variants = [('unfrozen', install_args)] + if '--locked' in help_out.stdout: + variants.insert( + 0, ('locked', [install_args[0], '--locked', *install_args[1:]]) + ) + for label, command in variants: + locked = (case / names[1]).read_bytes() + installed = run( + exe, + command, + case, + kind + '-' + mode + '-' + label + '-install', + rows, + ) + if label == 'locked': + installed['lockUnchanged'] = ( + (case / names[1]).read_bytes() == locked + ) + if not installed['lockUnchanged']: + raise ValueError('Locked install modified the lockfile') + if installed['exitCode'] == 0: + if kind == 'script': + digest = installed['stdout'].strip() + if not re.fullmatch(r'[0-9a-f]{64}', digest): + raise ValueError('Script did not report installed file hash') + else: + target = next( + (case / '.venv/lib').glob( + 'python*/site-packages/urllib3/response.py' + ) + ) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + installed['installedResponseSha256'] = digest + (base / 'unfrozen-backtest.json').write_text( + json.dumps({'version': version, 'commands': rows}, indent=2) + '\n' + ) + return { + 'version': version, + 'commands': [(row['key'], row['exitCode']) for row in rows], + } + + +def write_summary(): + patched_response = ( + '21d9a7810de52973c88d9170f437e98921456bce445ab0618576987478a6a6e4' + ) + versions = [] + command_catalog = {} + for version in args.versions: + base = ROOT / 'matrix' / version + lockfile = base / 'original' / 'uv.lock' + lock = lockfile.read_text() if lockfile.exists() else '' + revision = re.search(r'^revision = (\d+)$', lock, re.MULTILINE) + observations = [] + for filename in [ + 'generate.json', + 'backtest.json', + 'backtest-extensions.json', + 'format-backtest.json', + 'unfrozen-backtest.json', + ]: + path = base / filename + if not path.exists(): + continue + for row in json.loads(path.read_text())['commands']: + key = row['key'] + if key in ['venv', 'install-original', 'fresh-venv'] or key.endswith( + '-fresh-venv' + ): + continue + if filename == 'generate.json' and key not in ['lock', 'version']: + continue + command = row['command'] + if command and not command[0].startswith('/'): + command = [str(ROOT / 'bin' / version / 'uv'), *command] + spec = { + 'args': [ + x.replace(str(CLI), '') + .replace(str(ROOT), '') + .replace(version, '') + for x in command + ], + 'cwd': row.get('cwd', str(base / 'original')) + .replace(str(ROOT), '') + .replace(version, ''), + } + command_id = key + variant = 1 + while ( + command_id in command_catalog + and command_catalog[command_id] != spec + ): + variant += 1 + command_id = key + '-variant-' + str(variant) + command_catalog[command_id] = spec + item = { + 'command': command_id, + 'exitCode': row.get('exitCode', row.get('status')), + } + if 'formatSupported' in row: + item['formatSupported'] = row['formatSupported'] + if 'lockUnchanged' in row: + item['lockUnchanged'] = row['lockUnchanged'] + if row.get('installedResponseSha256'): + item['installedResponseSha256'] = row['installedResponseSha256'] + item['installedPatch'] = ( + row['installedResponseSha256'] == patched_response + ) + if key.endswith('socket-patch'): + payload = json.loads(row['stdout']) + redirect = payload.get('redirect') + vendor = payload.get('vendor') + if redirect: + item['rewrittenFiles'] = redirect.get('rewrittenFiles', []) + item['redirected'] = redirect.get('redirected', 0) + item['warnings'] = [ + warning['code'] for warning in redirect.get('warnings', []) + ] + if vendor: + item['vendorSummary'] = { + key: vendor.get('summary', {}).get(key) + for key in ['applied', 'failed'] + } + item['vendorErrors'] = [ + event + for event in vendor.get('events', []) + if event.get('action') == 'failed' + ] + elif item['exitCode']: + item['diagnostic'] = row['stderr'].replace(str(ROOT), '')[ + :1000 + ] + observations.append(item) + versions.append( + { + 'version': version, + 'lockSchema': 'distribution' + if '[[distribution]]' in lock + else 'package' + if lock + else None, + 'lockVersion': 1 if lock else None, + 'lockRevision': int(revision.group(1)) if revision else None, + 'observations': observations, + } + ) + result = { + 'date': datetime.date.today().isoformat(), + 'scope': f'{len(args.versions)} pinned uv releases on {platform.platform()}; interpreter {args.python}', + 'socketPatchRevision': args.socket_patch_revision, + 'socketPatchVersion': subprocess.check_output( + [str(CLI), '--version'], text=True + ).strip(), + 'socketPatchBinarySha256': hashlib.sha256(CLI.read_bytes()).hexdigest(), + 'patchUuid': 'e828efa5-5c6d-43f3-9909-03f5ac232b98', + 'originalWheelSha256': hashlib.sha256(WHEEL.read_bytes()).hexdigest(), + 'patchedWheelSha256': 'ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6', + 'patchedResponseSha256': patched_response, + 'commands': command_catalog, + 'versions': versions, + } + text = json.dumps(result, indent=2) + '\n' + text = re.sub( + r'(https://patch\.socket\.dev/patch/pypi/urllib3/1\.26\.18/)[0-9a-f-]{36}/', + r'\g<1>11111111-1111-4111-8111-111111111111/', + text, + ) + (ROOT / 'results.json').write_text(text) + + +def backtest(version): + return [ + project_matrix(version), + requirements_matrix(version), + format_matrix(version), + unfrozen_matrix(version), + ] + + +if __name__ == '__main__': + install_binaries() + provenance = { + 'socketPatchRevision': args.socket_patch_revision, + 'socketPatchBinarySha256': hashlib.sha256(CLI.read_bytes()).hexdigest(), + 'socketPatchVersion': subprocess.check_output( + [str(CLI), '--version'], text=True + ).strip(), + 'platform': platform.platform(), + 'python': args.python, + } + (ROOT / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + for result in executor.map(backtest, args.versions): + print(json.dumps(result), flush=True) + + write_summary()