Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 155 additions & 26 deletions src/backend/pipx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)
Comment thread
risu729 marked this conversation as resolved.
} else {
debug!("Fetching HTML for {}", package);
let url = registry_url.replace("{}", &package);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -313,6 +298,37 @@ pub fn install_time_option_keys() -> Vec<String> {
}

impl PIPXBackend {
fn versions_from_pypi_package(data: PypiPackage) -> Vec<VersionInfo> {
// 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())
Comment thread
risu729 marked this conversation as resolved.
.min()
.cloned();
VersionInfo {
version,
created_at,
..Default::default()
}
})
.collect()
}

fn latest_stable_from_pypi_package(data: PypiPackage) -> Option<String> {
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<GithubRelease>) -> Vec<VersionInfo> {
releases
.into_iter()
Expand Down Expand Up @@ -524,17 +540,27 @@ impl PipxRequest {
#[derive(serde::Deserialize)]
struct PypiPackage {
releases: IndexMap<String, Vec<PypiRelease>>,
info: PypiInfo,
}

#[derive(serde::Deserialize)]
struct PypiInfo {
version: String,
}

#[derive(serde::Deserialize)]
struct PypiRelease {
upload_time: Option<String>,
#[serde(default, deserialize_with = "deserialize_pypi_yanked")]
yanked: bool,
Comment thread
risu729 marked this conversation as resolved.
}

fn deserialize_pypi_yanked<'de, D>(deserializer: D) -> std::result::Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<Value>::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 {
Expand Down Expand Up @@ -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<_>>(),
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<_>>(),
vec!["1.1.0"]
);
}

#[test]
fn test_versions_from_empty_github_releases_stays_empty() {
let versions = PIPXBackend::versions_from_github_releases(vec![]);
Expand Down Expand Up @@ -782,4 +895,20 @@ mod tests {
assets: vec![],
}
}

fn pypi_package(releases: Vec<(&str, Vec<PypiRelease>)>) -> PypiPackage {
PypiPackage {
releases: releases
.into_iter()
.map(|(version, files)| (version.to_string(), files))
.collect::<IndexMap<_, _>>(),
}
}

fn pypi_release(upload_time: Option<&str>, yanked: bool) -> PypiRelease {
PypiRelease {
upload_time: upload_time.map(str::to_string),
yanked,
}
}
}
Loading