diff --git a/src/backend/pipx.rs b/src/backend/pipx.rs index 0e5763a3a5..8f21e4cbd5 100644 --- a/src/backend/pipx.rs +++ b/src/backend/pipx.rs @@ -21,6 +21,8 @@ use indexmap::IndexMap; use itertools::Itertools; use jiff::Timestamp; use regex::Regex; +use serde::Deserialize; +use serde_json::Value; use std::collections::BTreeMap; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -84,24 +86,7 @@ impl Backend for PIPXBackend { let url = registry_url.replace("{}", &package); let data: PypiPackage = HTTP_FETCH.json(url).await?; - // Get versions sorted and attach timestamps from the first file in each release - data.releases - .into_iter() - .sorted_by_cached_key(|(v, _)| Versioning::new(v)) - .map(|(version, files)| { - // Get the earliest upload_time from the release files - let created_at = files - .iter() - .filter_map(|f| f.upload_time.as_ref()) - .min() - .cloned(); - VersionInfo { - version, - created_at, - ..Default::default() - } - }) - .collect() + Self::versions_from_pypi_package(data) } else { debug!("Fetching HTML for {}", package); let url = registry_url.replace("{}", &package); @@ -165,7 +150,7 @@ impl Backend for PIPXBackend { debug!("Fetching JSON for {}", package); let url = registry_url.replace("{}", &package); let pkg: PypiPackage = HTTP_FETCH.json(url).await?; - Ok(Some(pkg.info.version)) + Ok(Self::latest_stable_from_pypi_package(pkg)) } else { debug!("Fetching HTML for {}", package); let url = registry_url.replace("{}", &package); @@ -313,6 +298,37 @@ pub fn install_time_option_keys() -> Vec { } impl PIPXBackend { + fn versions_from_pypi_package(data: PypiPackage) -> Vec { + // Releases with only yanked files are ignored so fuzzy/latest + // resolution mirrors pip's default yanked-file behavior. + data.releases + .into_iter() + .filter(|(_, files)| files.iter().any(|f| !f.yanked)) + .sorted_by_cached_key(|(v, _)| Versioning::new(v)) + .map(|(version, files)| { + let created_at = files + .iter() + .filter(|f| !f.yanked) + .filter_map(|f| f.upload_time.as_ref()) + .min() + .cloned(); + VersionInfo { + version, + created_at, + ..Default::default() + } + }) + .collect() + } + + fn latest_stable_from_pypi_package(data: PypiPackage) -> Option { + Self::versions_from_pypi_package(data) + .into_iter() + .rev() + .find(|v| !PEP440_PRERELEASE_REGEX.is_match(&v.version)) + .map(|v| v.version) + } + fn versions_from_github_releases(releases: Vec) -> Vec { releases .into_iter() @@ -524,17 +540,27 @@ impl PipxRequest { #[derive(serde::Deserialize)] struct PypiPackage { releases: IndexMap>, - info: PypiInfo, -} - -#[derive(serde::Deserialize)] -struct PypiInfo { - version: String, } #[derive(serde::Deserialize)] struct PypiRelease { upload_time: Option, + #[serde(default, deserialize_with = "deserialize_pypi_yanked")] + yanked: bool, +} + +fn deserialize_pypi_yanked<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + match Option::::deserialize(deserializer)? { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(yanked)) => Ok(yanked), + Some(Value::String(_)) => Ok(true), + Some(value) => Err(serde::de::Error::custom(format!( + "expected bool or string for yanked, got {value}" + ))), + } } impl FromStr for PipxRequest { @@ -698,11 +724,98 @@ fn fix_venv_python_symlink(_install_path: &Path, _pkg_name: &str) -> Result<()> #[cfg(test)] mod tests { - use super::PIPXBackend; + use super::{PIPXBackend, PypiPackage, PypiRelease}; use crate::github::GithubRelease; + use indexmap::IndexMap; use pretty_assertions::assert_eq; use std::ffi::OsString; + #[test] + fn test_versions_from_pypi_package_skips_yanked_releases() { + let versions = PIPXBackend::versions_from_pypi_package(pypi_package(vec![ + ( + "1.0.0", + vec![pypi_release(Some("2024-01-01T00:00:00Z"), false)], + ), + ( + "1.1.0", + vec![pypi_release(Some("2024-02-01T00:00:00Z"), true)], + ), + ( + "1.2.0", + vec![ + pypi_release(Some("2024-03-01T00:00:00Z"), true), + pypi_release(Some("2024-03-01T00:01:00Z"), false), + ], + ), + ])); + + assert_eq!( + versions + .iter() + .map(|v| (v.version.as_str(), v.created_at.as_deref())) + .collect::>(), + vec![ + ("1.0.0", Some("2024-01-01T00:00:00Z")), + ("1.2.0", Some("2024-03-01T00:01:00Z")), + ] + ); + } + + #[test] + fn test_latest_stable_from_pypi_package_skips_yanked_and_prerelease() { + let version = PIPXBackend::latest_stable_from_pypi_package(pypi_package(vec![ + ( + "1.0.0", + vec![pypi_release(Some("2024-01-01T00:00:00Z"), false)], + ), + ( + "1.1.0", + vec![pypi_release(Some("2024-02-01T00:00:00Z"), false)], + ), + ( + "1.2.0", + vec![pypi_release(Some("2024-03-01T00:00:00Z"), true)], + ), + ( + "2.0.0a1", + vec![pypi_release(Some("2024-04-01T00:00:00Z"), false)], + ), + ])); + + assert_eq!(version.as_deref(), Some("1.1.0")); + } + + #[test] + fn test_pypi_release_deserializes_string_yanked_reason() { + let release: PypiRelease = serde_json::from_value(serde_json::json!({ + "upload_time": "2024-01-01T00:00:00Z", + "yanked": "broken release" + })) + .unwrap(); + + assert!(release.yanked); + } + + #[test] + fn test_versions_from_pypi_package_skips_empty_releases() { + let versions = PIPXBackend::versions_from_pypi_package(pypi_package(vec![ + ("1.0.0", vec![]), + ( + "1.1.0", + vec![pypi_release(Some("2024-02-01T00:00:00Z"), false)], + ), + ])); + + assert_eq!( + versions + .iter() + .map(|v| v.version.as_str()) + .collect::>(), + vec!["1.1.0"] + ); + } + #[test] fn test_versions_from_empty_github_releases_stays_empty() { let versions = PIPXBackend::versions_from_github_releases(vec![]); @@ -782,4 +895,20 @@ mod tests { assets: vec![], } } + + fn pypi_package(releases: Vec<(&str, Vec)>) -> PypiPackage { + PypiPackage { + releases: releases + .into_iter() + .map(|(version, files)| (version.to_string(), files)) + .collect::>(), + } + } + + fn pypi_release(upload_time: Option<&str>, yanked: bool) -> PypiRelease { + PypiRelease { + upload_time: upload_time.map(str::to_string), + yanked, + } + } }