Skip to content

Commit

Permalink
Add support for pip list --outdated
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Nov 7, 2024
1 parent 273f453 commit 4d72632
Show file tree
Hide file tree
Showing 9 changed files with 377 additions and 25 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 1 addition & 8 deletions crates/uv-cli/src/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,26 +167,19 @@ impl CompatArgs for PipCompileCompatArgs {
pub struct PipListCompatArgs {
#[clap(long, hide = true)]
disable_pip_version_check: bool,

#[clap(long, hide = true)]
outdated: bool,
}

impl CompatArgs for PipListCompatArgs {
/// Validate the arguments passed for `pip list` compatibility.
///
/// This method will warn when an argument is passed that has no effect but matches uv's
/// behavior. If an argument is passed that does _not_ match uv's behavior (e.g.,
/// `--outdated`), this method will return an error.
/// `--disable-pip-version-check`), this method will return an error.
fn validate(&self) -> Result<()> {
if self.disable_pip_version_check {
warn_user!("pip's `--disable-pip-version-check` has no effect");
}

if self.outdated {
return Err(anyhow!("pip's `--outdated` is unsupported"));
}

Ok(())
}
}
Expand Down
10 changes: 10 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,16 @@ pub struct PipListArgs {
#[arg(long, value_enum, default_value_t = ListFormat::default())]
pub format: ListFormat,

/// List outdated packages.
///
/// The latest version of each package will be shown alongside the installed version. Up-to-date
/// packages will be omitted from the output.
#[arg(long, overrides_with("no_outdated"))]
pub outdated: bool,

#[arg(long, overrides_with("outdated"), hide = true)]
pub no_outdated: bool,

/// Validate the Python environment, to detect packages with missing dependencies and other
/// issues.
#[arg(long, overrides_with("no_strict"))]
Expand Down
1 change: 1 addition & 0 deletions crates/uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ url = { workspace = true }
walkdir = { workspace = true }
which = { workspace = true }
zip = { workspace = true }
rkyv = { workspace = true }

[dev-dependencies]
assert_cmd = { version = "2.0.16" }
Expand Down
269 changes: 252 additions & 17 deletions crates/uv/src/commands/pip/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,60 @@ use std::fmt::Write;

use anstream::println;
use anyhow::Result;
use futures::stream::FuturesUnordered;
use futures::TryStreamExt;
use itertools::Itertools;
use owo_colors::OwoColorize;
use rustc_hash::FxHashMap;
use serde::Serialize;
use unicode_width::UnicodeWidthStr;

use uv_cache::Cache;
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_cli::ListFormat;
use uv_distribution_types::{Diagnostic, InstalledDist, Name};
use uv_client::{Connectivity, RegistryClient, RegistryClientBuilder, VersionFiles};
use uv_configuration::{IndexStrategy, KeyringProviderType, TrustedHost};
use uv_distribution_filename::DistFilename;
use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, InstalledDist, Name};
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
use uv_python::PythonRequest;
use uv_platform_tags::Tags;
use uv_python::{EnvironmentPreference, PythonEnvironment};
use uv_python::{Interpreter, PythonRequest};
use uv_resolver::{ExcludeNewer, PrereleaseMode};
use uv_warnings::warn_user_once;

use crate::commands::pip::operations::report_target_environment;
use crate::commands::ExitStatus;
use crate::printer::Printer;

/// Enumerate the installed packages in the current environment.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) fn pip_list(
pub(crate) async fn pip_list(
editable: Option<bool>,
exclude: &[PackageName],
format: &ListFormat,
outdated: bool,
prerelease: PrereleaseMode,
index_locations: IndexLocations,
index_strategy: IndexStrategy,
keyring_provider: KeyringProviderType,
allow_insecure_host: Vec<TrustedHost>,
connectivity: Connectivity,
strict: bool,
exclude_newer: Option<ExcludeNewer>,
python: Option<&str>,
system: bool,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
// Disallow `--outdated` with `--format freeze`.
if outdated && matches!(format, ListFormat::Freeze) {
anyhow::bail!("`--outdated` cannot be used with `--format freeze`");
}

// Detect the current Python interpreter.
let environment = PythonEnvironment::find(
&python.map(PythonRequest::parse).unwrap_or_default(),
Expand All @@ -53,9 +77,90 @@ pub(crate) fn pip_list(
.sorted_unstable_by(|a, b| a.name().cmp(b.name()).then(a.version().cmp(b.version())))
.collect_vec();

// Determine the latest version for each package.
let latest = if outdated {
let capabilities = IndexCapabilities::default();

// Initialize the registry client.
let client =
RegistryClientBuilder::new(cache.clone().with_refresh(Refresh::All(Timestamp::now())))
.native_tls(native_tls)
.connectivity(connectivity)
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(allow_insecure_host.clone())
.markers(environment.interpreter().markers())
.platform(environment.interpreter().platform())
.build();

// Determine the platform tags.
let interpreter = environment.interpreter();
let tags = interpreter.tags()?;

// Initialize the client to fetch the latest version of each package.
let client = LatestClient {
client: &client,
capabilities: &capabilities,
prerelease,
exclude_newer,
tags,
interpreter,
};

// Fetch the latest version for each package.
results
.iter()
.map(|dist| async {
let latest = client.find_latest(dist.name()).await?;
Ok::<(&PackageName, Option<DistFilename>), uv_client::Error>((dist.name(), latest))
})
.collect::<FuturesUnordered<_>>()
.try_collect::<FxHashMap<_, _>>()
.await?
} else {
FxHashMap::default()
};

// Remove any up-to-date packages from the results.
let results = if outdated {
results
.into_iter()
.filter(|dist| {
latest[dist.name()]
.as_ref()
.is_some_and(|filename| filename.version() > dist.version())
})
.collect_vec()
} else {
results
};

match format {
ListFormat::Json => {
let rows = results.iter().copied().map(Entry::from).collect_vec();
let rows = results
.iter()
.copied()
.map(|dist| Entry {
name: dist.name().to_string(),
version: dist.version().to_string(),
latest_version: latest
.get(dist.name())
.and_then(|filename| filename.as_ref())
.map(uv_distribution_filename::DistFilename::version)
.map(ToString::to_string),
latest_filetype: latest
.get(dist.name())
.and_then(|filename| filename.as_ref())
.map(|filename| match filename {
DistFilename::WheelFilename(_) => "wheel".to_string(),
DistFilename::SourceDistFilename(_) => "sdist".to_string(),
}),
editable_project_location: dist
.as_editable()
.map(|url| url.to_file_path().unwrap().simplified_display().to_string()),
})
.collect_vec();
let output = serde_json::to_string(&rows)?;
println!("{output}");
}
Expand All @@ -80,6 +185,40 @@ pub(crate) fn pip_list(
},
];

// The latest version and type are only displayed if outdated.
if outdated {
columns.push(Column {
header: String::from("Latest"),
rows: results
.iter()
.map(|dist| {
latest
.get(dist.name())
.and_then(|filename| filename.as_ref())
.map(uv_distribution_filename::DistFilename::version)
.map(ToString::to_string)
.unwrap_or_default()
})
.collect_vec(),
});
columns.push(Column {
header: String::from("Type"),
rows: results
.iter()
.map(|dist| {
latest
.get(dist.name())
.and_then(|filename| filename.as_ref())
.map(|filename| match filename {
DistFilename::WheelFilename(_) => "wheel".to_string(),
DistFilename::SourceDistFilename(_) => "sdist".to_string(),
})
.unwrap_or_default()
})
.collect_vec(),
});
}

// Editable column is only displayed if at least one editable package is found.
if results.iter().copied().any(InstalledDist::is_editable) {
columns.push(Column {
Expand Down Expand Up @@ -134,21 +273,13 @@ struct Entry {
name: String,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_filetype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
editable_project_location: Option<String>,
}

impl From<&InstalledDist> for Entry {
fn from(dist: &InstalledDist) -> Self {
Self {
name: dist.name().to_string(),
version: dist.version().to_string(),
editable_project_location: dist
.as_editable()
.map(|url| url.to_file_path().unwrap().simplified_display().to_string()),
}
}
}

#[derive(Debug)]
struct Column {
/// The header of the column.
Expand Down Expand Up @@ -195,3 +326,107 @@ where
self.0.iter_mut().map(Iterator::next).collect()
}
}

/// A client to fetch the latest version of a package from an index.
///
/// The returned distribution is guaranteed to be compatible with the current interpreter.
#[derive(Debug)]
struct LatestClient<'env> {
client: &'env RegistryClient,
capabilities: &'env IndexCapabilities,
prerelease: PrereleaseMode,
exclude_newer: Option<ExcludeNewer>,
tags: &'env Tags,
interpreter: &'env Interpreter,
}

impl<'env> LatestClient<'env> {
/// Find the latest version of a package from an index.
async fn find_latest(
&self,
package: &PackageName,
) -> Result<Option<DistFilename>, uv_client::Error> {
let mut latest: Option<DistFilename> = None;
for (_, archive) in self.client.simple(package, None, self.capabilities).await? {
for datum in archive.iter().rev() {
// Find the first compatible distribution.
let files = rkyv::deserialize::<VersionFiles, rkyv::rancor::Error>(&datum.files)
.expect("archived version files always deserializes");

// Determine whether there's a compatible wheel and/or source distribution.
let mut best = None;

for (filename, file) in files.all() {
// Skip distributions uploaded after the cutoff.
if let Some(exclude_newer) = self.exclude_newer {
match file.upload_time_utc_ms.as_ref() {
Some(&upload_time)
if upload_time >= exclude_newer.timestamp_millis() =>
{
continue;
}
None => {
warn_user_once!(
"{} is missing an upload date, but user provided: {exclude_newer}",
file.filename,
);
}
_ => {}
}
}

// Skip pre-release distributions.
if !filename.version().is_stable() {
if !matches!(self.prerelease, PrereleaseMode::Allow) {
continue;
}
}

// Skip distributions that are yanked.
if file.yanked.is_some_and(|yanked| yanked.is_yanked()) {
continue;
}

// Skip distributions that are incompatible with the current interpreter.
if file.requires_python.is_some_and(|requires_python| {
!requires_python.contains(self.interpreter.python_full_version())
}) {
continue;
}

// Skip distributions that are incompatible with the current platform.
if let DistFilename::WheelFilename(filename) = &filename {
if !filename.compatibility(self.tags).is_compatible() {
continue;
}
}

match filename {
DistFilename::WheelFilename(_) => {
best = Some(filename);
break;
}
DistFilename::SourceDistFilename(_) => {
if best.is_none() {
best = Some(filename);
}
}
}
}

match (latest.as_ref(), best) {
(Some(current), Some(best)) => {
if best.version() > current.version() {
latest = Some(best);
}
}
(None, Some(best)) => {
latest = Some(best);
}
_ => {}
}
}
}
Ok(latest)
}
}
Loading

0 comments on commit 4d72632

Please sign in to comment.