diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e2bc9b3d9..cc4f44a58 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -485,7 +485,7 @@ Contract details: * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release; for a pypi package of a project whose governing pdm.lock was written by PDM < 2.11 it names `pdm sync --reinstall`, or removing the installed copy first on PDM 1.x); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) @@ -1638,6 +1638,7 @@ command's integration tests check its output. | `pypi_poetry_changed` / `pypi_pdm_changed` / `pypi_pipenv_changed` / `pypi_uv_changed` | `failed` | vendor (pypi, v5.0): the lock / project file changed between the read that planned the edit and the first write — refused before any write (worded like `pypi_lock_changed`: " changed during vendoring; re-run"). | | `pypi_pipenv_stale_install` | `skipped` (warning) | vendor (pipenv): the vendored twin of `redirect_pypi_stale_install` — the project's venv still holds the upstream release Pipenv will not reinstall over; the detail names the `pipenv run pip uninstall -y && pipenv sync` remedy, with the same lock-category `sync` arguments as the hosted warning. | | `pypi_hatch_stale_install` | `skipped` (warning) | vendor (hatch): an existing Hatch environment of the project (found under Hatch's data dir, `dirs.env.virtual` or an explicit env `path`) still holds the upstream release; Hatch keeps it on the next `hatch run`, so the detail names the env and the `hatch env remove ` / `hatch env prune` remedy. | +| `pypi_pdm_stale_install` | `skipped` (warning) | vendor (pdm): the project's PDM env (its venv, or PEP 582 `__pypackages__`) still holds the upstream release, and pdm.lock was written by PDM < 2.11 (lock_version `2` / `4.3` / `4.4`), which keeps a same-version install on `pdm sync` / `pdm install`; the detail names `pdm sync --reinstall`, or for PDM 1.x (lock_version `2`, no `--reinstall`) removing the installed copy before `pdm sync`. Emitted on a fresh vendor and on an in-sync re-run while the env stays stale. | | `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. | | `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. | | `vendor_lock_entry_pruned_kept` | revert `warnings[]` | vendor `--revert` / rollback (yarn berry, #737): vendoring dropped a lock entry only the patched package's implicit `node-gyp` dependency reached (or one descriptor of a shared entry), and the lock has re-added or changed that entry since; it is left as it is (not drift: the artifact is still removed). Run `yarn install` if the lock is incomplete. | diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index f3c399bff..39fe23464 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1059,7 +1059,19 @@ async fn generate_vex( ), } }; + // PDM < 2.11 keeps an installed release on `pdm sync` (#641): a + // package its pdm.lock wires gets the remedy that resyncs it. + let pdm_lock = if outcome.vendored_out_of_sync.is_empty() || common.is_global() { + None + } else { + socket_patch_core::vendor::pypi_pdm::stale_install_lock(&common.cwd).await + }; for purl in &outcome.vendored_out_of_sync { + let pdm_note = pdm_lock + .as_ref() + .and_then(|lock| socket_patch_core::vendor::pypi_pdm::stale_install_note(lock, purl)) + .map(|note| format!(" {note}")) + .unwrap_or_default(); note_warning( warnings, common, @@ -1068,7 +1080,7 @@ async fn generate_vex( "{purl}: the installed tree does not match its vendored artifact; the \ attestation is based on the committed .socket/vendor artifact (the lockfile \ consumes it), but the live tree carries different bytes — re-run your \ - package manager's install to resync it.{hatch_note}" + package manager's install to resync it.{hatch_note}{pdm_note}" ), ); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs index c25f09af9..7331301bd 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs @@ -417,6 +417,14 @@ fn flow(mode: Mode) { .unwrap(); assert_attested(&doc, PURL, mode.uuid(), mode.marker(), VULNS); std::fs::remove_file(&vex_out).unwrap(); + // The vendored scan ran over a venv that already holds upstream six. + let stale_warnings: Vec = env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["errorCode"] == "pypi_pdm_stale_install") + .map(|e| e.to_string()) + .collect(); let wired_lock = std::fs::read_to_string(project.join("pdm.lock")).unwrap(); assert!( !project @@ -533,6 +541,86 @@ fn flow(mode: Mode) { matrix.run(&fresh, project, &|dir: &Path| { std::fs::write(dir.join("pdm.lock"), &pristine_lock).unwrap(); }); + + if mode == Mode::Vendored { + stale_install_flow(&pdm, tmp.path(), &locked, &wired_lock, &stale_warnings); + } +} + +/// #641: PDM before 2.11 (lock_version 2 / 4.3 / 4.4) keeps the upstream six +/// the project venv already held when the vendored scan wired pdm.lock, so +/// the scan must warn `pypi_pdm_stale_install`, and the remedy it names must +/// install the patched wheel. A 2.11+ lock reinstalls on `pdm sync`: silent. +fn stale_install_flow( + pdm: &Pdm, + case: &Path, + locked: &Locked, + wired_lock: &str, + stale_warnings: &[String], +) { + let what = format!( + "pdm {} lock {} stale install", + pdm.version, locked.lock_version + ); + let project = &locked.project; + let keeps = matches!(locked.lock_version.as_str(), "2" | "4.3" | "4.4"); + if !keeps { + assert!(stale_warnings.is_empty(), "{what}: {stale_warnings:?}"); + return; + } + assert_eq!(stale_warnings.len(), 1, "{what}: {stale_warnings:?}"); + let python = venv_bin(&project.join(".venv"), "python"); + let (_, _, marked) = six_oracle(&python, project).unwrap(); + assert!( + !marked, + "{what}: the scan never touches the installed files" + ); + assert_eq!( + std::fs::read_to_string(project.join("pdm.lock")).unwrap(), + wired_lock, + "{what}: the project keeps the wired lock" + ); + + // The next `pdm sync` keeps the upstream bytes, as the warning says. + let envs = pdm.case_env(&case.join("stale-case"), project); + let out = pdm.sync(project, &envs); + let (_, _, marked) = six_oracle(&python, project).unwrap(); + assert!(!marked, "{what}: PDM < 2.11 keeps six: {}", out_text(&out)); + + // The named remedy installs the patched wheel. + let out = if locked.lock_version == "2" { + assert!( + stale_warnings[0].contains("no `pdm sync --reinstall`"), + "{what}: {}", + stale_warnings[0] + ); + let (module, _, _) = six_oracle(&python, project).unwrap(); + let site = module.parent().unwrap(); + std::fs::remove_file(&module).unwrap(); + std::fs::remove_dir_all(site.join("six-1.16.0.dist-info")).unwrap(); + pdm.sync(project, &envs) + } else { + assert!( + stale_warnings[0].contains("`pdm sync --reinstall`"), + "{what}: {}", + stale_warnings[0] + ); + pdm.run(project, &envs, &["sync", "--reinstall", "--no-self"]) + }; + let (_, _, marked) = six_oracle(&python, project) + .unwrap_or_else(|| panic!("{what}: six missing after the remedy: {}", out_text(&out))); + assert!( + marked, + "{what}: the remedy installs the patch: {}", + out_text(&out) + ); + record( + "pdm", + &pdm.version, + &format!("lock {}/vendored", locked.lock_version), + "stale-install-warned", + "pass", + ); } #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index 28ce88a05..36b21b38c 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -64,7 +64,9 @@ pub(super) fn rewrite( // warning: warn proactively once per lock, mirroring poetry's // `redirect_poetry_stale_install_risk`. if !stale_warned - && matches!(lock_ver.as_deref(), Some("2") | Some("4.3") | Some("4.4")) + && lock_ver + .as_deref() + .is_some_and(crate::utils::pdm_lock::keeps_installed_release) { stale_warned = true; result.warnings.push(RewriteWarning { diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index 5149cd61f..5ff2ffc65 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -23,6 +23,14 @@ pub fn lock_version(lock: &Table) -> Result<&str, String> { } } +/// Whether the PDM release that writes `lock_version` keeps a release +/// already installed at the same version on `pdm sync` / `pdm install`: +/// PDM before 2.11 (lock_version `2` / `4.3` / `4.4`; measured on 1.4.5, +/// 2.8.2, 2.9.3 and 2.10.4, while 2.11+ reinstall). +pub fn keeps_installed_release(lock_version: &str) -> bool { + matches!(lock_version, "2" | "4.3" | "4.4") +} + pub fn validate_strategy(lock: &Table) -> Result, String> { let mut result = Vec::new(); let Some(metadata) = lock.get("metadata") else { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index e7d72eacc..4f2b97564 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -901,6 +901,65 @@ async fn hatch_stale_install_warning( .collect() } +/// The PDM twin of [`pipenv_stale_install_warning`] (#641): PDM before +/// 2.11 (lock_version `2` / `4.3` / `4.4`) does not replace a release that +/// is already installed at the same version, so `pdm sync` / `pdm install` +/// keep the upstream bytes in the env PDM installs into (its venv, or PEP +/// 582 `__pypackages__`). Each such env that still holds them is named with +/// the remedy that release line supports. A 2.11+ lock reinstalls: silent. +async fn pdm_stale_install_warning( + project_root: &Path, + purl: &str, + record: &PatchRecord, + listings: &InstalledSiteListings, + lock_version: &str, +) -> Option { + let name = parse_pypi_purl(strip_purl_qualifiers(purl)) + .map_or_else(|| purl.to_string(), |(name, _)| name.to_string()); + let remedy = super::pypi_pdm::stale_install_remedy(lock_version, &name)?; + // The env PDM resolves for the project (`.pdm-python`, an activated or + // in-project venv, else `__pypackages__`); Hatch's envs are not PDM's. + let sites = + crate::crawlers::python_crawler::non_hatch_local_venv_site_packages(project_root).await; + let stale_dirs = stale_install_sites(&sites, purl, record, listings).await; + if stale_dirs.is_empty() { + return None; + } + let join = |dirs: &[&std::path::PathBuf]| { + dirs.iter() + .map(|d| d.display().to_string()) + .collect::>() + .join(", ") + }; + // A base interpreter's copy a `--system-site-packages` venv inherits is + // not PDM's to reinstall (#409, #1450): name it apart. + let inherited = crate::crawlers::python_crawler::inherited_base_site_dirs(&sites).await; + let (base, own): (Vec<_>, Vec<_>) = stale_dirs.iter().partition(|d| inherited.contains(*d)); + let mut detail = format!( + "{purl}: the UNPATCHED upstream release is still installed in {}.", + join(&stale_dirs.iter().collect::>()) + ); + if !own.is_empty() { + detail.push_str(&format!( + " pdm.lock (lock_version {lock_version}) was written by PDM < 2.11, which does not \ + replace an already-installed package at the same version (`pdm sync` and `pdm \ + install` keep those bytes), so the wired pdm.lock only protects fresh installs. \ + For {}: {remedy}.", + join(&own) + )); + } + if !base.is_empty() { + detail.push_str(&format!( + " {} belongs to the base interpreter the environment inherits \ + (`--system-site-packages`), which PDM does not manage: uninstall `{name}` there, or \ + recreate the environment without system site packages, then `pdm sync`.", + join(&base) + )); + } + detail.push_str(" Then `socket-patch vex` re-verifies the installed files."); + Some(VendorWarning::new("pypi_pdm_stale_install", detail)) +} + /// The `sites` that hold the package of `purl` with positive evidence of /// unpatched bytes (a readable file at the upstream or another hash), and no /// copy that verifies as patched. @@ -1164,8 +1223,13 @@ async fn pypi_prelude<'p>( Ok(p) => p, Err((code, detail)) => return Err(refused(code, detail)), }; - match super::pypi_pdm::check_target_guards(&project, &canon_name, version, &record.uuid) - { + let lock_version = project.lock_version.clone(); + let plan = match super::pypi_pdm::check_target_guards( + &project, + &canon_name, + version, + &record.uuid, + ) { Ok(PdmTarget::InSync) => { wired_pin = splice_lock_wired_pin(&project.lock_text, &uuid_dir_rel); WiringPlan::InSync @@ -1178,7 +1242,21 @@ async fn pypi_prelude<'p>( supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) .await? } - } + }; + // A fresh vendor, a superseding re-wire and a re-run over an + // already-wired lock all keep warning while PDM < 2.11 would + // keep the upstream release (#641). A refusal returned above. + warnings.extend( + pdm_stale_install_warning( + project_root, + purl, + record, + installed_sites, + &lock_version, + ) + .await, + ); + plan } PypiFlavor::Pipenv => { let project = match super::pypi_pipenv::load_pipenv_project(project_root).await { @@ -8987,6 +9065,188 @@ wheels = [ assert!(stale(&warnings).is_empty(), "{warnings:?}"); } + /// The [`PDM_LOCK_REGISTRY`] project written by another PDM release. + fn pdm_lock_at(lock_version: &str) -> String { + let lock = PDM_LOCK_REGISTRY.replace( + "lock_version = \"4.5.0\"", + &format!("lock_version = \"{lock_version}\""), + ); + assert!(lock.contains(&format!("lock_version = \"{lock_version}\""))); + lock + } + + /// The `pypi_pdm_stale_install` details among `warnings`. + fn pdm_stale(warnings: &[VendorWarning]) -> Vec { + warnings + .iter() + .filter(|w| w.code == "pypi_pdm_stale_install") + .map(|w| w.detail.clone()) + .collect() + } + + /// #641: PDM before 2.11 (lock_version 4.3 / 4.4) keeps a release that + /// is already installed at the same version, so `pdm sync` after a + /// vendor leaves the upstream bytes live. Vendoring over such an env — + /// fresh and re-run in sync — must name it with `pdm sync --reinstall`. + /// A 2.11+ lock (4.4.1, 4.5.0) reinstalls and a patched env is silent. + #[tokio::test] + async fn pdm_vendor_warns_about_a_stale_install_before_pdm_2_11() { + for (lock_version, warns) in [ + ("4.3", true), + ("4.4", true), + ("4.4.1", false), + ("4.5.0", false), + ] { + let fx = e2e_fixture().await; + if cfg!(windows) { + // The venv probe reads `.venv\Lib\site-packages` on Windows. + let sp = fx.root.join(".venv").join("Lib").join("site-packages"); + let di = sp.join("six-1.16.0.dist-info"); + std::fs::create_dir_all(&di).unwrap(); + std::fs::copy(fx.site_packages.join("six.py"), sp.join("six.py")).unwrap(); + for leaf in ["METADATA", "WHEEL", "RECORD"] { + std::fs::copy( + fx.site_packages.join("six-1.16.0.dist-info").join(leaf), + di.join(leaf), + ) + .unwrap(); + } + } + swap_to_lock_flavor(&fx, &[("pdm.lock", &pdm_lock_at(lock_version))]).await; + + let VendorOutcome::Done { + result, + entry, + warnings, + } = vendor_six(&fx, &PatchSources::blobs_only(&fx.blobs), None).await + else { + panic!("{lock_version}: vendor must be Done"); + }; + assert!(result.success, "{lock_version}: {:?}", result.error); + crate::vendor::test_support::persist(&fx.root, "pkg:pypi/six@1.16.0", entry.unwrap()) + .await; + let details = pdm_stale(&warnings); + if !warns { + assert!(details.is_empty(), "{lock_version}: {warnings:?}"); + continue; + } + assert_eq!(details.len(), 1, "{lock_version}: {warnings:?}"); + assert!( + details[0].contains("pkg:pypi/six@1.16.0") + && details[0].contains("site-packages") + && details[0].contains(&format!("lock_version {lock_version}")) + && details[0].contains("`pdm sync --reinstall`"), + "{lock_version}: {}", + details[0] + ); + + // The in-sync re-run keeps warning while the env is stale. + let VendorOutcome::Done { warnings, .. } = + vendor_six(&fx, &PatchSources::blobs_only(&fx.blobs), None).await + else { + panic!("{lock_version}: re-run must be Done"); + }; + assert_eq!( + pdm_stale(&warnings).len(), + 1, + "{lock_version}: {warnings:?}" + ); + + // Once the env holds the patched bytes there is nothing to say. + for sp in [ + fx.site_packages.clone(), + fx.root.join(".venv").join("Lib").join("site-packages"), + ] { + if sp.is_dir() { + touch(&sp, "six.py", std::str::from_utf8(PATCHED).unwrap()).await; + } + } + let VendorOutcome::Done { warnings, .. } = + vendor_six(&fx, &PatchSources::blobs_only(&fx.blobs), None).await + else { + panic!("{lock_version}: re-run must be Done"); + }; + assert!( + pdm_stale(&warnings).is_empty(), + "{lock_version}: {warnings:?}" + ); + } + } + + /// #641: a PDM 1.x project (lock_version 2) installing into PEP 582 + /// `__pypackages__` keeps the upstream release too, and PDM 1.x has no + /// `pdm sync --reinstall`: the remedy removes the installed copy first. + #[tokio::test] + async fn pdm_1x_vendor_warns_about_a_stale_pep582_install() { + const PDM_LOCK_V2: &str = r#"[[package]] +name = "six" +sections = ["default"] +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" + +[metadata] +lock_version = "2" +content_hash = "sha256:d49d286986c5de41ec9879b6d710389b0be11cd096d883c069123b489ac6e6ea" + +[metadata.files] +"six 1.16.0" = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +"#; + let fx = e2e_fixture().await; + swap_to_lock_flavor( + &fx, + &[ + ("pdm.lock", PDM_LOCK_V2), + ( + "pyproject.toml", + "[project]\nname = \"proj\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n[tool.pdm]\n", + ), + ], + ) + .await; + // No venv: PDM installs into `__pypackages__//lib`. + let lib = fx.root.join("__pypackages__").join("3.8").join("lib"); + tokio::fs::create_dir_all(lib.parent().unwrap()) + .await + .unwrap(); + tokio::fs::rename(&fx.site_packages, &lib).await.unwrap(); + tokio::fs::remove_dir_all(fx.root.join(".venv")) + .await + .unwrap(); + + let VendorOutcome::Done { + result, warnings, .. + } = crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &lib, + &fx.root, + &fx.record, + &PatchSources::blobs_only(&fx.blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + else { + panic!("vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let details = pdm_stale(&warnings); + assert_eq!(details.len(), 1, "{warnings:?}"); + assert!( + details[0].contains("__pypackages__") + && details[0].contains("lock_version 2") + && details[0].contains("no `pdm sync --reinstall`") + && details[0].contains("then `pdm sync`"), + "{}", + details[0] + ); + } + /// One full vendor → revert cycle through `vendor_pypi` for a lock-splice /// flavor: plan arm, wire arm, `entry.flavor` tag (PypiFlavor::as_str), /// the matching MetaSlot, and the byte-identical lock restore. diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index 752fd17a0..76e90a797 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -114,6 +114,67 @@ pub async fn load_pdm_project(root: &Path) -> Result Option { + if !crate::utils::pdm_lock::keeps_installed_release(lock_version) { + return None; + } + Some(if lock_version == "2" { + format!( + "PDM before 2.0 has no `pdm sync --reinstall`: remove the installed `{name}` from \ + the environment PDM installs into (its package files and `.dist-info`, or \ + recreate the environment or `__pypackages__`), then `pdm sync`" + ) + } else { + "Run `pdm sync --reinstall` (or recreate the environment, then `pdm sync`)".to_string() + }) +} + +/// The governing pdm.lock of `root` when its PDM keeps an installed release +/// ([`stale_install_remedy`]), for [`stale_install_note`]. Loaded once per +/// `vex` run; `None` when another tool lock drives installs, the lock is +/// unreadable, or its PDM reinstalls. +pub async fn stale_install_lock(root: &Path) -> Option { + if !crate::formats::governing_locks::pypi_tool_lock_governs(LOCK_FILE, |lock| { + root.join(lock).is_file() + }) { + return None; + } + let project = load_pdm_project(root).await.ok()?; + crate::utils::pdm_lock::keeps_installed_release(&project.lock_version).then_some(project) +} + +/// The addendum to `vex`'s `vendored_tree_out_of_sync` hint for `purl` +/// (#641): re-running `pdm sync` does not resync a tree PDM < 2.11 keeps, +/// so name the remedy that does. `None` unless `p` wires `purl`'s package +/// to a vendored artifact, so a package another flavor wired gets none. +pub fn stale_install_note(p: &PdmProject, purl: &str) -> Option { + let base = crate::utils::purl::strip_purl_qualifiers(purl); + let (name, _) = crate::utils::purl::parse_pypi_purl(base)?; + let wired = lock_units_named(&p.lock, &canonicalize_pypi_name(&name)) + .into_iter() + .any(|unit| { + unit.get("path") + .and_then(Item::as_str) + .and_then(parse_vendor_path) + .is_some_and(|parts| parts.eco == "pypi") + }); + if !wired { + return None; + } + let remedy = stale_install_remedy(&p.lock_version, &name)?; + Some(format!( + "pdm.lock (lock_version {}) was written by PDM < 2.11, which keeps an \ + already-installed release on `pdm sync`. {remedy}.", + p.lock_version + )) +} + /// `"direct"` iff the package is declared in the pyproject — PEP 621 /// `[project] dependencies` / `optional-dependencies`, /// `[tool.pdm.dev-dependencies]` groups, or PEP 735 `[dependency-groups]` — @@ -498,6 +559,60 @@ mod tests { use super::*; use crate::vendor::state::VendorArtifact; + /// #641: `vex`'s out-of-sync hint names the PDM remedy for a package a + /// PDM < 2.11 pdm.lock wires to a vendored artifact, and nothing for a + /// registry entry, a 2.11+ lock, or a pdm.lock another lock shadows. + #[tokio::test] + async fn stale_install_note_follows_the_lock_and_its_wiring() { + let lock = |version: &str, source: &str| { + format!( + "[metadata]\ngroups = [\"default\"]\nlock_version = \"{version}\"\n\ + content_hash = \"sha256:00\"\n\n[[package]]\nname = \"Six\"\n\ + version = \"1.16.0\"\n{source}" + ) + }; + let wired = "path = \".socket/vendor/pypi/0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a/six-1.16.0-py2.py3-none-any.whl\"\n"; + let purl = "pkg:pypi/six@1.16.0?artifact_id=x"; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + assert!(stale_install_lock(root).await.is_none(), "no pdm.lock"); + + let note = |root: std::path::PathBuf, purl: &'static str| async move { + stale_install_lock(&root) + .await + .and_then(|p| stale_install_note(&p, purl)) + }; + std::fs::write(root.join(LOCK_FILE), lock("4.4", wired)).unwrap(); + let text = note(root.to_path_buf(), purl).await.expect("4.4 wired"); + assert!( + text.contains("lock_version 4.4") && text.contains("`pdm sync --reinstall`"), + "{text}" + ); + assert!(note(root.to_path_buf(), "pkg:pypi/other@1.0.0") + .await + .is_none()); + + std::fs::write(root.join(LOCK_FILE), lock("2", wired)).unwrap(); + let text = note(root.to_path_buf(), purl).await.expect("2 wired"); + assert!( + text.contains("no `pdm sync --reinstall`") && text.contains("`six`"), + "{text}" + ); + + // A registry entry is not ours to advise on. + std::fs::write(root.join(LOCK_FILE), lock("4.4", "")).unwrap(); + assert!(note(root.to_path_buf(), purl).await.is_none()); + + // PDM 2.11+ reinstalls. + std::fs::write(root.join(LOCK_FILE), lock("4.5.0", wired)).unwrap(); + assert!(note(root.to_path_buf(), purl).await.is_none()); + + // A uv.lock beside it drives installs instead. + std::fs::write(root.join(LOCK_FILE), lock("4.4", wired)).unwrap(); + std::fs::write(root.join("uv.lock"), "version = 1\n").unwrap(); + assert!(note(root.to_path_buf(), purl).await.is_none()); + } + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 31a87f722..6db236f34 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -48,6 +48,14 @@ patched build installed, because the restored lock pins the same version: `pdm sync` and `pdm install` report nothing to do. The unwind warns `vendor_pypi_reinstall_required` / `redirect_pypi_reinstall_required` and names `pdm sync --reinstall` (#477). +The forward direction has the same gap before PDM 2.11 (lock_version `2`, +`4.3`, `4.4`): an env that already holds the upstream release keeps it on +`pdm sync`. Hosted mode warns `redirect_pdm_stale_install_risk`. Vendored mode +probes the env PDM installs into (its venv, or PEP 582 `__pypackages__`) and +warns `pypi_pdm_stale_install` with `pdm sync --reinstall`, or on PDM 1.x +(lock_version `2`, no `--reinstall`) with removing the installed copy before +`pdm sync`. `vex` names the same remedy in its `vendored_tree_out_of_sync` +hint (#641). Refused before any write: a `[[package]]` locked at several versions (a marker fork), a user-authored `url`/`path`/VCS/`editable` source, an unsupported `lock_version` or `strategy`, hash-less `files`, malformed hashes, and a wheel @@ -95,7 +103,7 @@ Measured details: activated or in-project venv is used, with `__pypackages__` as the last resort. `PDM_IGNORE_SAVED_PYTHON` and `PDM_IGNORE_ACTIVE_VENV` are honored, parsed as PDM parses booleans. Agent mode patches the env found, - and the hosted stale-install warning and `vex` check it. Global mode (`-g`) + and the hosted and vendored stale-install warnings and `vex` check it. Global mode (`-g`) reads `global_project.path`, `venv.location` and `python.install_root` from the user and site configs alike. - **Implicit macOS site settings require a recognized PDM launcher.** The