diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b02fa44fa..6b3df242f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1313,7 +1313,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). | | `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | | `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | -| `redirect_pypi_platform_wheel` | `redirect.warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform- or ABI-tagged wheel (any tag triple other than `-none-any`, e.g. `cp311-cp311-manylinux…`). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. | +| `redirect_pypi_platform_wheel` | `redirect.warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform-, ABI- or interpreter-tagged wheel (any tag triple other than `-none-any` whose python tag set holds a generic Python 3 tag, `py3` or `py3`; e.g. `cp311-cp311-manylinux…`, or `cp311-none-any`, which pip installs on CPython 3.11 only). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. | | `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. | | `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) | | `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. | diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index ceeb2f111..b8b8e5aa7 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -528,12 +528,23 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { #[tokio::test] #[serial] async fn platform_wheel_is_not_pinned_into_the_lock() { + assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64") + .await; +} + +/// #1048: a pure wheel bound to one interpreter (`cp311-none-any`) fails +/// `pipenv sync` on every other CPython minor, so it is refused the same +/// way as a platform-tagged one. +#[tokio::test] +#[serial] +async fn interpreter_bound_wheel_is_not_pinned_into_the_lock() { + assert_wheel_tag_is_not_pinned("cp311-none-any").await; +} + +async fn assert_wheel_tag_is_not_pinned(tag: &str) { let _major = MajorGuard::set("2026"); let server = MockServer::start().await; - let platform_url = HOSTED_URL.replace( - "py2.py3-none-any", - "cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64", - ); + let platform_url = HOSTED_URL.replace("py2.py3-none-any", tag); mock_api_serving(&server, &platform_url).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 74892e9a6..5c0c2d90d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -551,11 +551,12 @@ pub fn rewrite_registry_redirect_with_pipenv_version( } /// #701 / #932: the patch service can grant a pypi patch as a platform- or -/// ABI-tagged wheel (`…-cp311-cp311-manylinux…whl`). Every hosted PyPI lock -/// (uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, -/// pdm.lock, requirements.txt, Hatch's pyproject) is meant to install on -/// any platform its markers allow, and a hosted pin narrows the entry to -/// that one wheel: installs on any other interpreter, OS or architecture +/// ABI-tagged wheel (`…-cp311-cp311-manylinux…whl`), or as a pure wheel +/// bound to one interpreter (`…-cp311-none-any.whl`, #1048). Every hosted +/// PyPI lock (uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, +/// poetry.lock, pdm.lock, requirements.txt, Hatch's pyproject) is meant to +/// install on any platform its markers allow, and a hosted pin narrows the +/// entry to that one wheel: installs on any other interpreter, OS or architecture /// then fail, and hosted rollback cannot derive which upstream wheels to /// put back. Fail closed like hosted gem (`redirect_gem_platform_unsupported`). /// The tags are read the way vendored mode reads them for @@ -581,9 +582,10 @@ pub fn pypi_platform_wheel_refusal(dep: &DepOverride) -> Option platform_locked.then(|| RewriteWarning { code: "redirect_pypi_platform_wheel".into(), detail: format!( - "the patched wheel for {}=={} is platform-specific ({tags}); pinning it \ - would make the project's Python lockfiles install it on this platform \ - only, so the redirect is skipped and nothing was written for it", + "the patched wheel for {}=={} is interpreter- or platform-specific \ + ({tags}); pinning it would make the project's Python lockfiles install \ + it on this interpreter or platform only, so the redirect is skipped and \ + nothing was written for it", dep.name, dep.version ), }) diff --git a/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs b/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs index e18694bf8..cbc5e5068 100644 --- a/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs @@ -1,13 +1,21 @@ //! #701 / #932: a pypi patch granted as a platform- or ABI-tagged wheel is //! never pinned into a cross-platform Python lock. Each lane first proves //! its fixture redirects a pure wheel (the control), then that the same -//! project with a `cp311-cp311-manylinux` wheel is left untouched, warned -//! about once, and confirms nothing. +//! project with a `cp311-cp311-manylinux` wheel, or an interpreter-bound +//! `cp311-none-any` one (#1048), is left untouched, warned about once, and +//! confirms nothing. use super::*; const PURE: &str = "urllib3-1.26.18-py2.py3-none-any.whl"; const PLATFORM: &str = "urllib3-1.26.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"; +/// #1048: pip installs a `cp311-none-any` wheel on CPython 3.11 only. +const INTERPRETER: &str = "urllib3-1.26.18-cp311-none-any.whl"; +/// Every wheel each lane must withhold, with the tag the warning names. +const REFUSED: [(&str, &str); 2] = [ + (PLATFORM, "cp311-cp311-manylinux"), + (INTERPRETER, "cp311-none-any"), +]; const UUID: &str = "aaaaaaaa-0000-4000-8000-000000000701"; const HEX: &str = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; @@ -69,24 +77,26 @@ fn assert_lane(lane: &str, files: &[(&str, &str)], lock: &str) { assert!(confirmed(&control), "{lane}: control not confirmed"); assert_eq!(platform_warnings(&control), 0, "{lane}"); - let result = rewrite_registry_redirect(&files, &[dep(PLATFORM)]); - assert!( - result.files.is_empty() && result.edits.is_empty(), - "{lane}: platform wheel was pinned: {:?}", - result.files.keys().collect::>() - ); - assert!(!confirmed(&result), "{lane}: platform wheel confirmed"); - assert_eq!( - platform_warnings(&result), - 1, - "{lane}: {:?}", - result.warnings - ); - let detail = &result.warnings[0].detail; - assert!( - detail.contains("urllib3==1.26.18") && detail.contains("cp311-cp311-manylinux"), - "{lane}: {detail}" - ); + for (wheel, tag) in REFUSED { + let result = rewrite_registry_redirect(&files, &[dep(wheel)]); + assert!( + result.files.is_empty() && result.edits.is_empty(), + "{lane}: {wheel} was pinned: {:?}", + result.files.keys().collect::>() + ); + assert!(!confirmed(&result), "{lane}: {wheel} confirmed"); + assert_eq!( + platform_warnings(&result), + 1, + "{lane}: {wheel}: {:?}", + result.warnings + ); + let detail = &result.warnings[0].detail; + assert!( + detail.contains("urllib3==1.26.18") && detail.contains(tag), + "{lane}: {detail}" + ); + } } /// #701: a uv project's `uv.lock` (and its `[tool.uv.sources]`). @@ -156,20 +166,22 @@ fn pipfile_lock_refuses_a_platform_wheel() { control.warnings ); assert!(confirmed(&control)); - let result = rewrite_registry_redirect_with_pipenv_version( - &files, - &[dep(PLATFORM)], - &BTreeMap::new(), - major, - false, - ); - assert!( - result.files.is_empty(), - "pipenv {major:?}: {:?}", - result.files - ); - assert!(!confirmed(&result)); - assert_eq!(platform_warnings(&result), 1, "{:?}", result.warnings); + for (wheel, _) in REFUSED { + let result = rewrite_registry_redirect_with_pipenv_version( + &files, + &[dep(wheel)], + &BTreeMap::new(), + major, + false, + ); + assert!( + result.files.is_empty(), + "pipenv {major:?}: {wheel}: {:?}", + result.files + ); + assert!(!confirmed(&result)); + assert_eq!(platform_warnings(&result), 1, "{:?}", result.warnings); + } } assert_lane( "Pipfile.lock", @@ -234,9 +246,11 @@ fn hatch_refuses_a_platform_wheel() { assert_lane("hatch", &[("pyproject.toml", pyproject)], "pyproject.toml"); } -/// The tag rule matches vendored mode's: a version-bound `cp311-none-any` -/// wheel and an sdist stay redirectable, an `abi3` or platform-only tag -/// does not, and a query or fragment on the serve URL is ignored. +/// The tag rule matches vendored mode's: a wheel any Python 3 accepts +/// (`py3`, `py2.py3`, `py311`, which later 3.x accept too) and an sdist +/// stay redirectable; an interpreter-bound python tag (`cp311`, `pp310`, +/// #1048), a Python-2-only one, an `abi3` or a platform-only tag does +/// not; and a query or fragment on the serve URL is ignored. #[test] fn only_platform_or_abi_tagged_wheels_are_withheld() { let files: BTreeMap = [( @@ -247,7 +261,13 @@ fn only_platform_or_abi_tagged_wheels_are_withheld() { .collect(); for (artifact, refused) in [ (PURE.to_string(), false), - ("urllib3-1.26.18-cp311-none-any.whl".to_string(), false), + ("urllib3-1.26.18-py3-none-any.whl".to_string(), false), + ("urllib3-1.26.18-py311-none-any.whl".to_string(), false), + ("urllib3-1.26.18-cp311.py3-none-any.whl".to_string(), false), + (INTERPRETER.to_string(), true), + ("urllib3-1.26.18-pp310-none-any.whl".to_string(), true), + ("urllib3-1.26.18-py2-none-any.whl".to_string(), true), + ("urllib3-1.26.18-cp311.cp312-none-any.whl".to_string(), true), ("urllib3-1.26.18.tar.gz".to_string(), false), (format!("{PURE}?token=x#sha256={HEX}"), false), (PLATFORM.to_string(), true), diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 73a5dc2cf..76e86ea1a 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1256,8 +1256,8 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( warnings.push(VendorWarning::new( "vendor_platform_locked", format!( - "the vendored wheel for {canon_name}=={version} is platform-specific \ - ({platform_tags_display}); {per_flavor}" + "the vendored wheel for {canon_name}=={version} is interpreter- or \ + platform-specific ({platform_tags_display}); {per_flavor}" ), )); } @@ -3168,10 +3168,65 @@ wheels = [ ); } + /// #1048: a pure wheel whose python tag binds one interpreter + /// (`cp311-none-any`) installs on CPython 3.11 only, so it gets the same + /// `vendor_platform_locked` advisory as an ABI- or platform-tagged one. + #[tokio::test] + async fn interpreter_bound_tag_sets_platform_locked_and_warns() { + let fx = e2e_fixture().await; + tokio::fs::write( + fx.site_packages.join("six-1.16.0.dist-info/WHEEL"), + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: cp311-none-any\n", + ) + .await + .unwrap(); + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert!(entry + .artifact + .path + .ends_with("six-1.16.0-cp311-none-any.whl")); + assert_eq!(entry.artifact.platform_locked, Some(true)); + let warning = warnings + .iter() + .find(|w| w.code == "vendor_platform_locked") + .unwrap_or_else(|| panic!("{warnings:?}")); + assert!(warning.detail.contains("cp311-none-any"), "{warning:?}"); + } + #[test] fn platform_specific_tag_detection() { assert!(!tag_is_platform_specific("py3-none-any")); - assert!(!tag_is_platform_specific("cp311-none-any")); + assert!(!tag_is_platform_specific("py2.py3-none-any")); + assert!(!tag_is_platform_specific("py311-none-any")); + assert!(!tag_is_platform_specific("cp311.py3-none-any")); + // #1048: pip installs these on one interpreter (or Python 2) only. + assert!(tag_is_platform_specific("cp311-none-any")); + assert!(tag_is_platform_specific("pp310-none-any")); + assert!(tag_is_platform_specific("py2-none-any")); + assert!(tag_is_platform_specific("py-none-any")); + assert!(tag_is_platform_specific("py3x-none-any")); assert!(tag_is_platform_specific( "cp311-cp311-manylinux_2_17_x86_64" )); @@ -6552,6 +6607,10 @@ wheels = [ wheel_platform_from_filename("x-1.0-cp312-cp312-manylinux_2_17_x86_64.whl"), (true, "cp312-cp312-manylinux_2_17_x86_64".to_string()) ); + assert_eq!( + wheel_platform_from_filename("six-1.16.0-cp311-none-any.whl"), + (true, "cp311-none-any".to_string()) + ); // Short stems fall back closed and surface the stem verbatim. assert_eq!( wheel_platform_from_filename("six.whl"), diff --git a/crates/socket-patch-core/src/vendor/pypi_distribution.rs b/crates/socket-patch-core/src/vendor/pypi_distribution.rs index 022aac028..12248196e 100644 --- a/crates/socket-patch-core/src/vendor/pypi_distribution.rs +++ b/crates/socket-patch-core/src/vendor/pypi_distribution.rs @@ -91,10 +91,11 @@ pub(crate) fn verify_members( Ok(()) } -/// Whether a wheel filename binds an ABI or platform, and its tag triple -/// for messages. Shared by vendored mode (`vendor_platform_locked`) and the -/// hosted redirect (`redirect_pypi_platform_wheel`), so both modes call the -/// same wheels portable. +/// Whether a wheel filename binds an interpreter, ABI or platform, and its +/// tag triple for messages. Shared by vendored mode +/// (`vendor_platform_locked`) and the hosted redirect +/// (`redirect_pypi_platform_wheel`), so both modes call the same wheels +/// portable. pub(crate) fn wheel_platform_from_filename(wheel_name: &str) -> (bool, String) { let stem = wheel_name.strip_suffix(".whl").unwrap_or(wheel_name); let parts: Vec<&str> = stem.split('-').collect(); @@ -107,18 +108,30 @@ pub(crate) fn wheel_platform_from_filename(wheel_name: &str) -> (bool, String) { } } -/// Platform-specific iff the tag triple binds an ABI or platform — `cp311- -/// none-any` is merely version-bound, `*-cp311-*` / `*-manylinux*` lock the -/// artifact to this machine's platform. +/// Platform-specific unless every Python 3 interpreter on every platform +/// installs the wheel: the ABI must be `none`, the platform `any`, and the +/// python tag set must hold a generic Python 3 tag. pip accepts `py3` and +/// `pyXY` (major 3) on any later 3.x, but an interpreter tag (`cp311`, +/// `pp310`) only on that interpreter version, and `py2` never on Python 3 +/// (#1048). `*-cp311-*` / `*-manylinux*` lock the artifact to this +/// machine's platform. pub(crate) fn tag_is_platform_specific(tag: &str) -> bool { let parts: Vec<&str> = tag.split('-').collect(); match parts.as_slice() { - [_py, abi, plat] => *abi != "none" || *plat != "any", + [py, abi, plat] => { + *abi != "none" || *plat != "any" || !py.split('.').any(is_generic_py3_tag) + } // Malformed tags can't prove portability — claim platform-locked. _ => true, } } +/// `py3` or `py3`: a python tag every later Python 3 accepts. +fn is_generic_py3_tag(tag: &str) -> bool { + tag.strip_prefix("py3") + .is_some_and(|minor| minor.bytes().all(|b| b.is_ascii_digit())) +} + #[cfg(test)] mod tests { use super::*;