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..ff991145e 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,109 @@ 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. 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"); + + 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" + ); +} diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 5bf9251cc..b2811ed00 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(); @@ -609,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" @@ -695,22 +742,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!(