From d2d61d3715d9bd3d39daf0bad0f688ddeb7949a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:25:49 +0000 Subject: [PATCH 1/5] Start fix for #1136 Assisted-by: Claude Code:claude-opus-5-5 From 81657285f8bd918d6230e8eb0bedb0aed5cb3f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:42:01 +0000 Subject: [PATCH 2/5] Re-vendor Poetry and PDM to a superseding patch When the patch API replaced a vendored Poetry or PDM package's patch with a newer one, `scan --mode vendored` and `vendor` failed with pypi_poetry_source_already_exists (or the PDM code) and exit 1, while the dry run promised a re-vendor. The lock stayed on the old patch, so every install kept getting it. The Poetry and PDM arms of the PyPI vendored orchestrator now route their "already wired by an older socket-patch vendor" refusal through the same supersede path uv, script locks and Hatch use: replay the old ledger entry's revert, then wire the new patch over the restored lock. A user-authored source, or old wiring with no ledger entry, still refuses as before. Fixes #1136 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 21 ++++-- crates/socket-patch-core/src/vendor/pypi.rs | 69 +++++++++++++++---- .../socket-patch-core/src/vendor/pypi_pdm.rs | 3 +- .../src/vendor/pypi_poetry.rs | 3 +- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 5bf9251cc..20aee4097 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -479,6 +479,16 @@ fn stage_poetry(root: &Path) -> &'static [&'static str] { &["poetry.lock", "pyproject.toml"] } +/// [`stage_poetry`] with CRLF line endings. +fn stage_poetry_crlf(root: &Path) -> &'static [&'static str] { + let files = stage_poetry(root); + for f in files { + let text = std::fs::read_to_string(root.join(f)).unwrap(); + std::fs::write(root.join(f), text.replace('\n', "\r\n")).unwrap(); + } + files +} + #[tokio::test] async fn poetry_vendored_to_hosted() { let (_tmp, root) = project(); @@ -695,22 +705,25 @@ fn stage_script_lock(root: &Path) -> &'static [&'static str] { &["job.py", "job.py.lock"] } -/// #742 / #650: a vendored uv project, uv script lock and Hatch project pick -/// up a superseding patch. The manifest moves `six` from patch A to patch B +/// #742 / #650 / #1136: a vendored uv project, uv script lock, Hatch +/// project and Poetry project (LF and CRLF) pick up a superseding patch. The manifest moves `six` from patch A to patch B /// (different patched bytes); the next `vendor` must wire B's wheel, remove /// A's uuid dir (`vendor_stale_artifact_removed`) and exit 0. Before the fix /// it failed `pypi_uv_source_already_exists`, -/// `pypi_lock_source_already_exists` or `pypi_hatch_unsupported` (exit 1) +/// `pypi_lock_source_already_exists`, `pypi_hatch_unsupported` or +/// `pypi_poetry_source_already_exists` (exit 1) /// and the project kept installing patch A. `vendor --revert` afterwards /// restores the user's original files byte for byte. #[tokio::test] async fn pyproject_flavors_vendored_revendor_superseding_patch() { const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; - let stages: [(&str, StageFn); 3] = [ + let stages: [(&str, StageFn); 5] = [ ("uv", stage_uv), ("script lock", stage_script_lock), ("hatch", stage_hatch), + ("poetry", stage_poetry), + ("poetry crlf", stage_poetry_crlf), ]; for (flavor, stage) in stages { let (_tmp, root) = project(); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 1e79a8dbb..189c034ac 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -460,10 +460,10 @@ enum WiringPlan { /// The ledger entry of an OLDER patch uuid whose Pipfile.lock wiring the /// guards admitted for an in-place re-wire (#769), if any. Pipenv(Box, Option>), - /// The uv, script-lock or Hatch wiring routes this package through an - /// OLDER patch uuid's vendored wheel that the ledger still records - /// (#742, #650): replay that entry's revert, then wire this uuid fresh - /// ([`unwire_superseded`]). + /// The uv, script-lock, Hatch, Poetry or PDM wiring routes this package + /// through an OLDER patch uuid's vendored wheel that the ledger still + /// records (#742, #650, #1136): replay that entry's revert, then wire + /// this uuid fresh ([`unwire_superseded`]). Supersede(Box), /// The lock already routes this package through THIS patch uuid's /// vendored wheel: no wiring — verify (or rebuild) the artifact only. @@ -942,7 +942,10 @@ async fn pypi_prelude<'p>( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Poetry(Box::new(project)) } - Err((code, detail)) => return Err(refused(code, detail)), + Err(refusal) => { + supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) + .await? + } } } PypiFlavor::Pdm => { @@ -960,7 +963,10 @@ async fn pypi_prelude<'p>( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Pdm(Box::new(project)) } - Err((code, detail)) => return Err(refused(code, detail)), + Err(refusal) => { + supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) + .await? + } } } PypiFlavor::Pipenv => { @@ -1572,16 +1578,18 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( /// patch uuid of the release (alongside user sources the same codes cover): /// uv's `[tool.uv.sources]` path, a script lock / pylock `path` source, and /// Hatch's `{root:uri}` direct reference. -const SUPERSEDABLE_REFUSALS: [&str; 3] = [ +const SUPERSEDABLE_REFUSALS: [&str; 5] = [ "pypi_uv_source_already_exists", "pypi_lock_source_already_exists", "pypi_hatch_unsupported", + "pypi_poetry_source_already_exists", + "pypi_pdm_source_already_exists", ]; /// A pyproject-family flavor guard refused the wiring it found. When that /// wiring is socket-patch's own, for an OLDER patch uuid of this release /// that the ledger still records, it is a superseding patch to re-vendor -/// (#742, #650) — the same promise the requirements flavor keeps (#765). +/// (#742, #650, #1136) — the same promise the requirements flavor keeps (#765). /// Otherwise the refusal stands. async fn supersede_or_refuse( project_root: &Path, @@ -1668,6 +1676,8 @@ fn superseded_files(prev: &VendorEntry, flavor: PypiFlavor) -> Option &["pyproject.toml", "uv.lock"], PypiFlavor::Hatch => &["pyproject.toml", "hatch.toml"], + PypiFlavor::Poetry => &["poetry.lock"], + PypiFlavor::Pdm => &["pdm.lock"], _ => &[], }; let mut files: Vec = fixed.iter().map(|f| f.to_string()).collect(); @@ -1873,6 +1883,26 @@ async fn fresh_pyproject_plan( } Ok((WiringPlan::Hatch(project), Vec::new())) } + PypiFlavor::Poetry => { + let project = super::pypi_poetry::load_poetry_project(project_root).await?; + match super::pypi_poetry::check_target_guards(&project, canon_name, version, uuid)? { + PoetryTarget::Fresh => { + let warnings = project.warnings.clone(); + Ok((WiringPlan::Poetry(Box::new(project)), warnings)) + } + PoetryTarget::InSync => not_fresh("pypi_poetry_source_already_exists"), + } + } + PypiFlavor::Pdm => { + let project = super::pypi_pdm::load_pdm_project(project_root).await?; + match super::pypi_pdm::check_target_guards(&project, canon_name, version, uuid)? { + PdmTarget::Fresh => { + let warnings = project.warnings.clone(); + Ok((WiringPlan::Pdm(Box::new(project)), warnings)) + } + PdmTarget::InSync => not_fresh("pypi_pdm_source_already_exists"), + } + } other => Err(( "pypi_vendor_flavor_mismatch", format!("{} wiring cannot supersede a patch", other.as_str()), @@ -6406,8 +6436,9 @@ wheels = [ const SCRIPT_LOCK: &str = "version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[manifest]\nrequirements = [{name = \"six\", specifier = \"==1.16.0\"}]\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {registry = \"https://pypi.org/simple\"}\nwheels = [{url = \"https://files.pythonhosted.org/six.whl\", hash = \"sha256:upstream\"}]\n"; const HATCH_PROJECT: &str = "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"proj\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n"; - /// The pyproject-family flavors #742 (uv project, PEP 723 script lock) - /// and #650 (Hatch) cover, each with the files it wires. + /// The pyproject-family flavors #742 (uv project, PEP 723 script lock), + /// #650 (Hatch) and #1136 (Poetry, PDM) cover, each with the files it + /// wires. fn superseding_flavors() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { vec![ ( @@ -6422,9 +6453,19 @@ wheels = [ vec![("example.py", SCRIPT_PY), ("example.py.lock", SCRIPT_LOCK)], ), ("hatch", vec![("pyproject.toml", HATCH_PROJECT)]), + // #1136: Poetry and PDM, LF and CRLF (a CRLF poetry.lock takes + // the line-preserving edit path). + ("poetry", vec![("poetry.lock", POETRY_LOCK_REGISTRY)]), + ("poetry", vec![("poetry.lock", crlf(POETRY_LOCK_REGISTRY))]), + ("pdm", vec![("pdm.lock", PDM_LOCK_REGISTRY)]), + ("pdm", vec![("pdm.lock", crlf(PDM_LOCK_REGISTRY))]), ] } + fn crlf(text: &str) -> &'static str { + Box::leak(text.replace('\n', "\r\n").into_boxed_str()) + } + async fn vendor_six_as( fx: &E2eFixture, sources: &PatchSources<'_>, @@ -7455,9 +7496,11 @@ wheels = [ /// The splice-flavor mirror of /// `uv_stale_uuid_vendor_refuses_through_orchestrator`: a lock already - /// wired to an EARLIER patch uuid refuses through the orchestrator (the - /// poetry/pdm/pipenv guard-Err plan arms), before any new uuid dir is - /// created, naming the stale uuid and the revert remediation. + /// wired to an EARLIER patch uuid that NO ledger entry records refuses + /// through the orchestrator (the poetry/pdm/pipenv guard-Err plan arms), + /// before any new uuid dir is created, naming the stale uuid and the + /// revert remediation. With the ledger entry it re-vendors instead + /// (`pyproject_flavors_revendor_to_a_superseding_uuid`). #[tokio::test] async fn splice_flavor_stale_uuid_vendor_refuses_through_orchestrator() { const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index c3acaa64b..a94ca75cf 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -279,7 +279,8 @@ fn check_target_unit( } // Ours, but a STALE patch generation: wiring over it would lose // the only recorded registry original — refuse with the repair - // path (mirrors gem's stale-checksum refusal). + // path. The orchestrator turns this refusal into a re-vendor + // when the ledger still records that older uuid (#1136). Some(parts) if parts.eco == "pypi" => Err(( "pypi_pdm_source_already_exists", format!( diff --git a/crates/socket-patch-core/src/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/vendor/pypi_poetry.rs index e0c573044..da846cf30 100644 --- a/crates/socket-patch-core/src/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/vendor/pypi_poetry.rs @@ -245,7 +245,8 @@ pub(super) fn check_target_guards( } // Ours, but a STALE patch generation: wiring over it would lose // the only recorded registry original — refuse with the repair - // path (mirrors gem's stale-checksum refusal). + // path. The orchestrator turns this refusal into a re-vendor + // when the ledger still records that older uuid (#1136). Some(parts) if parts.eco == "pypi" => Err(( "pypi_poetry_source_already_exists", format!( From 6816d03283324a74161b34dcaf740dcd295896fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:03:46 +0000 Subject: [PATCH 3/5] Test Poetry re-vendor over a patched venv A Poetry project whose venv was installed from the old patch's vendored wheel must still move to the superseding patch, and reverting afterwards must restore the original lock. Refs #1136 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 20aee4097..b2811ed00 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -619,6 +619,43 @@ async fn pipenv_revendors_to_a_superseding_patch() { } } +/// #1136: a Poetry project whose venv was installed from patch A's +/// vendored wheel (`poetry install` after the first vendor) still moves to +/// the superseding patch B: poetry.lock is rewired to B and A's artifact is +/// swept, instead of `pypi_poetry_source_already_exists` (exit 1). +#[tokio::test] +async fn poetry_revendors_to_a_superseding_patch_over_a_patched_venv() { + let (_tmp, root) = project(); + let files = stage_poetry(&root); + let original = std::fs::read_to_string(root.join("poetry.lock")).unwrap(); + vendor_project(&root, files); + + let venv = root.join("../patched-venv"); + venv_installed_from_patch_a(&venv); + let extra = [("VIRTUAL_ENV", venv.to_str().unwrap())]; + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli(&root, &["vendor"], &extra); + assert_eq!(code, 0, "re-vendor to B: {env:#}"); + assert!( + env.to_string().contains("vendor_stale_artifact_removed"), + "A's artifact is swept: {env:#}" + ); + let lock = std::fs::read_to_string(root.join("poetry.lock")).unwrap(); + assert!( + lock.contains(&format!(".socket/vendor/pypi/{UUID_B}/")) && !lock.contains(UUID), + "poetry.lock is rewired to B:\n{lock}" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &extra); + assert_eq!(code, 0, "revert B: {env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("poetry.lock")).unwrap(), + original, + "revert restores the registry lock" + ); +} + const UV_LOCK: &str = r#"version = 1 revision = 2 requires-python = ">=3.9" From 8e0762ecd217f27b28e7655ff4d3477c2cb31f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:07:44 +0000 Subject: [PATCH 4/5] E2e: real Poetry re-vendors to a superseding patch Runs the #1136 report against a real Poetry: vendor patch A, install it, then let the patch service offer only patch B. The re-vendor must exit 0, rewire poetry.lock to B and sweep A, a fresh `poetry install` must get B's bytes, and reverting must restore the pristine lock. Refs #1136 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vex_build/poetry.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs index 158e14fa1..c47eb30bb 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs @@ -1051,3 +1051,106 @@ fn poetry_vendored_fresh_install_then_manifestless_vex() { .join(VENDORED_UUID) .exists()); } + +/// #1136: a vendored Poetry project moves to a superseding patch. Patch A +/// is vendored and installed into the project venv; the patch service then +/// offers only patch B for the same release. The dry run previews the +/// re-vendor and the wet run performs it (it used to exit 1 with +/// `pypi_poetry_source_already_exists`, leaving the lock on A), so a fresh +/// `poetry install` gets B's bytes. Reverting B restores the pristine lock. +#[test] +#[ignore = "real Poetry + PyPI; run by the CI e2e matrix per Poetry release"] +fn poetry_vendored_revendors_to_a_superseding_patch() { + const UUID_B: &str = "5e7a9c1d-3f5b-4d7f-8b9c-1d3f5b7d9f1a"; + const MARKER_B: &[u8] = b"\n# SOCKET-PATCHED\nSOCKET_PATCHED = 2\n"; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let Some(poetry) = Poetry::find(&home) else { + return; + }; + let project = tmp.path().join("proj"); + let Some((pristine, pristine_lock)) = + locked_project(&poetry, &project, &tmp.path().join("probe")) + else { + return; + }; + let vendor = |service: &PatchService, dry_run: bool| { + let mut args = vec!["scan", "--mode", "vendored", "--vendor-source", "service"]; + if dry_run { + args.push("--dry-run"); + } + socket_patch(&project, &poetry, service, &args) + }; + + let mut patched_a = pristine.clone(); + patched_a.extend_from_slice(MARKER); + let service_a = PatchService::start( + VENDORED_UUID, + &pristine, + &patched_a, + &build_wheel(&patched_a), + ); + let out = poetry.install(&project); + assert!(out.status.success(), "pristine install: {}", text(&out)); + let (code, env) = vendor(&service_a, false); + assert_eq!(code, Some(0), "vendor patch A: {env}"); + // The project venv now holds patch A, as after any `poetry install`. + let out = poetry.install(&project); + assert!(out.status.success(), "install patch A: {}", text(&out)); + assert_eq!(python_oracle(&project), "1"); + + let mut patched_b = pristine.clone(); + patched_b.extend_from_slice(MARKER_B); + let service_b = PatchService::start(UUID_B, &pristine, &patched_b, &build_wheel(&patched_b)); + let wired_a = std::fs::read_to_string(project.join("poetry.lock")).unwrap(); + let (code, env) = vendor(&service_b, true); + assert_eq!(code, Some(0), "dry-run re-vendor: {env}"); + assert!(env.to_string().contains("would_revendor"), "{env}"); + assert_eq!( + std::fs::read_to_string(project.join("poetry.lock")).unwrap(), + wired_a, + "the dry run writes nothing" + ); + + let (code, env) = vendor(&service_b, false); + assert_eq!(code, Some(0), "re-vendor to patch B: {env}"); + let lock = std::fs::read_to_string(project.join("poetry.lock")).unwrap(); + let rel_b = format!(".socket/vendor/pypi/{UUID_B}/{WHEEL}"); + assert!( + lock.contains(&rel_b) && !lock.contains(VENDORED_UUID), + "poetry.lock is rewired to B:\n{lock}" + ); + assert!(project.join(&rel_b).is_file(), "{env}"); + assert!( + !project + .join(".socket/vendor/pypi") + .join(VENDORED_UUID) + .exists(), + "patch A's artifact is swept: {env}" + ); + if poetry.major_minor() >= (1, 6) { + let out = poetry.run(&project, &["check", "--lock"]); + assert!(out.status.success(), "poetry check --lock: {}", text(&out)); + } + + // A fresh checkout installs patch B. + let fresh = tmp.path().join("fresh"); + fresh_checkout(&project, &fresh); + let out = poetry.install(&fresh); + assert!(out.status.success(), "poetry install (B): {}", text(&out)); + assert_eq!( + std::fs::read(installed_six(&fresh).expect("six installed")).unwrap(), + patched_b, + "patch B's bytes installed" + ); + assert_eq!(python_oracle(&fresh), "2"); + + let (code, env) = socket_patch(&project, &poetry, &service_b, &["vendor", "--revert"]); + assert_eq!(code, Some(0), "vendor --revert: {env}"); + assert_eq!( + std::fs::read_to_string(project.join("poetry.lock")).unwrap(), + pristine_lock, + "revert restores the pristine lock" + ); +} From 876923a1e1a885f21a8ee8f762ebed44ac37981b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:23:28 +0000 Subject: [PATCH 5/5] Recreate the venv before installing patch A Poetry before 1.2 keeps an installed package whose version is unchanged, even after its lock source moves to the vendored wheel, so the e2e never got patch A into the project venv on Poetry 1.0 and 1.1. Recreate the venv first, as the #1136 report does. Refs #1136 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs index c47eb30bb..ff991145e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs @@ -1095,7 +1095,10 @@ fn poetry_vendored_revendors_to_a_superseding_patch() { assert!(out.status.success(), "pristine install: {}", text(&out)); let (code, env) = vendor(&service_a, false); assert_eq!(code, Some(0), "vendor patch A: {env}"); - // The project venv now holds patch A, as after any `poetry install`. + // The project venv now holds patch A. Recreate it: Poetry before 1.2 + // keeps an installed six whose version is unchanged, even when its + // source moved to the vendored wheel. + std::fs::remove_dir_all(project.join(".venv")).unwrap(); let out = poetry.install(&project); assert!(out.status.success(), "install patch A: {}", text(&out)); assert_eq!(python_oracle(&project), "1");