From e11bd419ea9c01b3ecd1aa894b55874718a3ff0a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 14 Sep 2026 15:35:14 -0400 Subject: [PATCH 1/2] Preserve patches across uv lockfile formats Keep hosted and vendored Python patches installed across native uv, PEP 751, requirements, and script lockfiles. Preserve artifact hashes, paired metadata, and conditional versions; refuse ambiguous rewrites. Assisted-by: Codex:gpt-6-astra --- .../src/commands/repair_vendor.rs | 65 +- .../src/commands/scan/hosted.rs | 77 +- .../src/crawlers/python_crawler.rs | 3 +- .../src/patch/redirect/mod.rs | 588 ++++++------ .../src/patch/redirect/requirements.rs | 572 ++++++++++++ crates/socket-patch-core/src/utils/mod.rs | 2 + .../src/utils/python_lock.rs | 858 ++++++++++++++++++ .../src/utils/python_script.rs | 347 +++++++ .../src/vendor/lock_inventory.rs | 386 ++++++-- crates/socket-patch-core/src/vendor/mod.rs | 1 + crates/socket-patch-core/src/vendor/pypi.rs | 417 ++++++++- .../socket-patch-core/src/vendor/pypi_lock.rs | 821 +++++++++++++++++ .../socket-patch-core/src/vendor/pypi_uv.rs | 19 +- .../pypi/uv/basic/expected-edits.json | 4 +- .../redirect/pypi/uv/basic/expected/uv.lock | 6 +- crates/socket-patch-core/tests/uv_hosted.rs | 211 +++++ scripts/backtest-uv.py | 803 ++++++++++++++++ 17 files changed, 4727 insertions(+), 453 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/requirements.rs create mode 100644 crates/socket-patch-core/src/utils/python_lock.rs create mode 100644 crates/socket-patch-core/src/utils/python_script.rs create mode 100644 crates/socket-patch-core/src/vendor/pypi_lock.rs create mode 100644 crates/socket-patch-core/tests/uv_hosted.rs create mode 100644 scripts/backtest-uv.py 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..c18c1a53 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -566,7 +566,8 @@ 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 0a8e7cd6..b33fe0ff 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,104 +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 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 req_part = line.split(" --").next().unwrap_or(line).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) ───────────────────── @@ -2622,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 ──────────────────────────────────────────────────────────── @@ -5917,116 +5944,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 { @@ -6823,14 +6740,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()); @@ -6845,13 +6756,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 @@ -11416,8 +11327,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(), @@ -11520,8 +11430,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, @@ -11643,7 +11552,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!( @@ -11707,9 +11620,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); @@ -12225,7 +12136,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); } @@ -12328,7 +12243,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..19d4817c 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,13 @@ 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 +1439,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/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() From 4c441ecb444f3be20a5a40be5dbbc3939e8f518c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 14 Sep 2026 15:40:29 -0400 Subject: [PATCH 2/2] Record uv release-family compatibility checks Publish real CLI and uv installation evidence across fourteen releases, including frozen, locked, and ordinary installs in both patch modes. Record unavailable formats and legacy boundaries separately from passes. Assisted-by: Codex:gpt-6-astra --- .../src/crawlers/python_crawler.rs | 3 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 5 +- docs/ecosystems.md | 2 +- docs/testing/uv-compatibility.md | 203 + docs/testing/uv-compatibility/binaries.json | 100 + .../uv-compatibility/conditional-probes.json | 573 +++ docs/testing/uv-compatibility/results.json | 3897 +++++++++++++++++ 7 files changed, 4779 insertions(+), 4 deletions(-) create mode 100644 docs/testing/uv-compatibility.md create mode 100644 docs/testing/uv-compatibility/binaries.json create mode 100644 docs/testing/uv-compatibility/conditional-probes.json create mode 100644 docs/testing/uv-compatibility/results.json diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index c18c1a53..88384486 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -566,8 +566,7 @@ pub async fn is_python_project(cwd: &Path) -> bool { return true; } } - crate::utils::python_lock::python_lock_paths(cwd) - .is_ok_and(|paths| !paths.is_empty()) + crate::utils::python_lock::python_lock_paths(cwd).is_ok_and(|paths| !paths.is_empty()) } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 19d4817c..9cd66557 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1316,7 +1316,10 @@ pub(super) fn wheel_metadata_text(bytes: &[u8]) -> Option { return None; } let mut text = String::new(); - entry.take(MAX_WHEEL_METADATA_BYTES + 1).read_to_string(&mut text).ok()?; + entry + .take(MAX_WHEEL_METADATA_BYTES + 1) + .read_to_string(&mut text) + .ok()?; (text.len() as u64 <= MAX_WHEEL_METADATA_BYTES).then_some(text) } 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 + } + ] + } + ] +}