From 7c88abd1423fd9f136941c2a3d046e3ca330e098 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 7 Nov 2024 12:47:42 -0500 Subject: [PATCH] Add uv tree --outdated --- crates/uv-cli/src/lib.rs | 4 + crates/uv-distribution-filename/src/lib.rs | 7 ++ crates/uv-resolver/src/lib.rs | 4 +- crates/uv-resolver/src/lock/map.rs | 37 ++++++ crates/uv-resolver/src/lock/mod.rs | 20 ++++ crates/uv-resolver/src/lock/tree.rs | 15 ++- crates/uv/src/commands/pip/latest.rs | 124 +++++++++++++++++++++ crates/uv/src/commands/pip/list.rs | 121 ++------------------ crates/uv/src/commands/pip/mod.rs | 1 + crates/uv/src/commands/project/tree.rs | 74 +++++++++++- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 2 + crates/uv/tests/it/tree.rs | 32 ++++++ docs/reference/cli.md | 4 + 14 files changed, 328 insertions(+), 118 deletions(-) create mode 100644 crates/uv-resolver/src/lock/map.rs create mode 100644 crates/uv/src/commands/pip/latest.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 83f6d87e44ea..24a5181f317a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4802,6 +4802,10 @@ pub struct DisplayTreeArgs { /// display the packages that depend on the given package. #[arg(long, alias = "reverse")] pub invert: bool, + + /// Show the latest available version of each package in the tree. + #[arg(long)] + pub outdated: bool, } #[derive(Args, Debug)] diff --git a/crates/uv-distribution-filename/src/lib.rs b/crates/uv-distribution-filename/src/lib.rs index 0bdc4feef8b0..99b9164787ad 100644 --- a/crates/uv-distribution-filename/src/lib.rs +++ b/crates/uv-distribution-filename/src/lib.rs @@ -68,6 +68,13 @@ impl DistFilename { } } + pub fn into_version(self) -> Version { + match self { + Self::SourceDistFilename(filename) => filename.version, + Self::WheelFilename(filename) => filename.version, + } + } + /// Whether the file is a `bdist_wheel` or an `sdist`. pub fn filetype(&self) -> &'static str { match self { diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index cbf7dae2a957..b06cc74c9037 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -4,8 +4,8 @@ pub use exclude_newer::ExcludeNewer; pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use lock::{ - InstallTarget, Lock, LockError, LockVersion, RequirementsTxtExport, ResolverManifest, - SatisfiesResult, TreeDisplay, VERSION, + InstallTarget, Lock, LockError, LockVersion, PackageMap, RequirementsTxtExport, + ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, }; pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; diff --git a/crates/uv-resolver/src/lock/map.rs b/crates/uv-resolver/src/lock/map.rs new file mode 100644 index 000000000000..e9cfdd0426e0 --- /dev/null +++ b/crates/uv-resolver/src/lock/map.rs @@ -0,0 +1,37 @@ +use rustc_hash::FxHashMap; + +use crate::lock::{Package, PackageId}; + +/// A map from package to values, indexed by [`PackageId`]. +#[derive(Debug, Clone)] +pub struct PackageMap(FxHashMap); + +impl Default for PackageMap { + fn default() -> Self { + Self(FxHashMap::default()) + } +} + +impl PackageMap { + /// Get a value by [`PackageId`]. + pub(crate) fn get(&self, package_id: &PackageId) -> Option<&T> { + self.0.get(package_id) + } +} + +impl FromIterator<(Package, T)> for PackageMap { + fn from_iter>(iter: I) -> Self { + Self( + iter.into_iter() + .map(|(package, value)| (package.id, value)) + .collect(), + ) + } +} + +impl Extend<(Package, T)> for PackageMap { + fn extend>(&mut self, iter: I) { + self.0 + .extend(iter.into_iter().map(|(package, value)| (package.id, value))); + } +} diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 08bed6149ef8..540a597a62be 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, LazyLock}; use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value}; use url::Url; +pub use crate::lock::map::PackageMap; pub use crate::lock::requirements_txt::RequirementsTxtExport; pub use crate::lock::target::InstallTarget; pub use crate::lock::tree::TreeDisplay; @@ -47,6 +48,7 @@ use uv_types::{BuildContext, HashStrategy}; use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::Workspace; +mod map; mod requirements_txt; mod target; mod tree; @@ -2206,6 +2208,24 @@ impl Package { self.fork_markers.as_slice() } + /// Returns the [`IndexUrl`] for the package, if it is a registry source. + pub fn index(&self, root: &Path) -> Result, LockError> { + match &self.id.source { + Source::Registry(RegistrySource::Url(url)) => { + let index = IndexUrl::from(VerbatimUrl::from_url(url.to_url())); + Ok(Some(index)) + } + Source::Registry(RegistrySource::Path(path)) => { + let index = IndexUrl::from( + VerbatimUrl::from_absolute_path(root.join(path)) + .map_err(LockErrorKind::RegistryVerbatimUrl)?, + ); + Ok(Some(index)) + } + _ => Ok(None), + } + } + /// Returns all the hashes associated with this [`Package`]. fn hashes(&self) -> Vec { let mut hashes = Vec::new(); diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index bce113086db8..5dfc4fed967c 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::collections::VecDeque; use itertools::Itertools; +use owo_colors::OwoColorize; use petgraph::graph::{EdgeIndex, NodeIndex}; use petgraph::prelude::EdgeRef; use petgraph::Direction; @@ -9,10 +10,11 @@ use rustc_hash::{FxHashMap, FxHashSet}; use uv_configuration::DevGroupsManifest; use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_pep440::Version; use uv_pypi_types::ResolverMarkerEnvironment; use crate::lock::{Dependency, PackageId}; -use crate::Lock; +use crate::{Lock, PackageMap}; #[derive(Debug)] pub struct TreeDisplay<'env> { @@ -20,6 +22,8 @@ pub struct TreeDisplay<'env> { graph: petgraph::graph::Graph<&'env PackageId, Edge<'env>, petgraph::Directed>, /// The packages considered as roots of the dependency tree. roots: Vec, + /// The latest known version of each package. + latest: &'env PackageMap, /// Maximum display depth of the dependency tree. depth: usize, /// Whether to de-duplicate the displayed dependencies. @@ -31,6 +35,7 @@ impl<'env> TreeDisplay<'env> { pub fn new( lock: &'env Lock, markers: Option<&'env ResolverMarkerEnvironment>, + latest: &'env PackageMap, depth: usize, prune: &[PackageName], packages: &[PackageName], @@ -242,6 +247,7 @@ impl<'env> TreeDisplay<'env> { Self { graph, roots, + latest, depth, no_dedupe, } @@ -306,6 +312,13 @@ impl<'env> TreeDisplay<'env> { } } + // Incorporate the latest version of the package, if known. + let line = if let Some(version) = self.latest.get(package_id) { + format!("{line} {}", format!("(latest: v{version})").bold().cyan()) + } else { + line + }; + let mut dependencies = self .graph .edges_directed(cursor.node(), Direction::Outgoing) diff --git a/crates/uv/src/commands/pip/latest.rs b/crates/uv/src/commands/pip/latest.rs new file mode 100644 index 000000000000..7b2241712e2d --- /dev/null +++ b/crates/uv/src/commands/pip/latest.rs @@ -0,0 +1,124 @@ +use uv_client::{RegistryClient, VersionFiles}; +use uv_distribution_filename::DistFilename; +use uv_distribution_types::{IndexCapabilities, IndexUrl}; +use uv_normalize::PackageName; +use uv_platform_tags::Tags; +use uv_resolver::{ExcludeNewer, PrereleaseMode, RequiresPython}; +use uv_warnings::warn_user_once; + +/// A client to fetch the latest version of a package from an index. +/// +/// The returned distribution is guaranteed to be compatible with the provided tags and Python +/// requirement. +#[derive(Debug)] +pub(crate) struct LatestClient<'env> { + pub(crate) client: &'env RegistryClient, + pub(crate) capabilities: &'env IndexCapabilities, + pub(crate) prerelease: PrereleaseMode, + pub(crate) exclude_newer: Option, + pub(crate) tags: Option<&'env Tags>, + pub(crate) requires_python: &'env RequiresPython, +} + +impl<'env> LatestClient<'env> { + /// Find the latest version of a package from an index. + pub(crate) async fn find_latest( + &self, + package: &PackageName, + index: Option<&IndexUrl>, + ) -> anyhow::Result, uv_client::Error> { + let mut latest: Option = None; + for (_, archive) in self + .client + .simple(package, index, self.capabilities) + .await? + { + for datum in archive.iter().rev() { + // Find the first compatible distribution. + let files = rkyv::deserialize::(&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 Python requirement. + if file + .requires_python + .as_ref() + .is_some_and(|requires_python| { + !self.requires_python.is_contained_by(requires_python) + }) + { + continue; + } + + // Skip distributions that are incompatible with the current platform. + if let DistFilename::WheelFilename(filename) = &filename { + if self + .tags + .is_some_and(|tags| !filename.compatibility(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) + } +} diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index ad77d440a416..46937be0ea46 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -14,7 +14,7 @@ use unicode_width::UnicodeWidthStr; use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_cli::ListFormat; -use uv_client::{Connectivity, RegistryClient, RegistryClientBuilder, VersionFiles}; +use uv_client::{Connectivity, RegistryClientBuilder}; use uv_configuration::{IndexStrategy, KeyringProviderType, TrustedHost}; use uv_distribution_filename::DistFilename; use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, InstalledDist, Name}; @@ -22,12 +22,11 @@ use uv_fs::Simplified; use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep440::Version; -use uv_platform_tags::Tags; +use uv_python::PythonRequest; use uv_python::{EnvironmentPreference, PythonEnvironment}; -use uv_python::{Interpreter, PythonRequest}; -use uv_resolver::{ExcludeNewer, PrereleaseMode}; -use uv_warnings::warn_user_once; +use uv_resolver::{ExcludeNewer, PrereleaseMode, RequiresPython}; +use crate::commands::pip::latest::LatestClient; use crate::commands::pip::operations::report_target_environment; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -98,6 +97,8 @@ pub(crate) async fn pip_list( // Determine the platform tags. let interpreter = environment.interpreter(); let tags = interpreter.tags()?; + let requires_python = + RequiresPython::greater_than_equal_version(interpreter.python_full_version()); // Initialize the client to fetch the latest version of each package. let client = LatestClient { @@ -105,15 +106,15 @@ pub(crate) async fn pip_list( capabilities: &capabilities, prerelease, exclude_newer, - tags, - interpreter, + tags: Some(tags), + requires_python: &requires_python, }; // Fetch the latest version for each package. results .iter() .map(|dist| async { - let latest = client.find_latest(dist.name()).await?; + let latest = client.find_latest(dist.name(), None).await?; Ok::<(&PackageName, Option), uv_client::Error>((dist.name(), latest)) }) .collect::>() @@ -363,107 +364,3 @@ 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, - 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, uv_client::Error> { - let mut latest: Option = 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::(&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) - } -} diff --git a/crates/uv/src/commands/pip/mod.rs b/crates/uv/src/commands/pip/mod.rs index 528effcad2ea..a9e74a012306 100644 --- a/crates/uv/src/commands/pip/mod.rs +++ b/crates/uv/src/commands/pip/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod check; pub(crate) mod compile; pub(crate) mod freeze; pub(crate) mod install; +pub(crate) mod latest; pub(crate) mod list; pub(crate) mod loggers; pub(crate) mod operations; diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 0a9a22ff8ea4..67cdb35267e0 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -2,15 +2,20 @@ use std::path::Path; use anstream::print; use anyhow::Result; +use futures::{stream, StreamExt}; -use uv_cache::Cache; -use uv_client::Connectivity; +use uv_cache::{Cache, Refresh}; +use uv_cache_info::Timestamp; +use uv_client::{Connectivity, RegistryClientBuilder}; use uv_configuration::{Concurrency, DevGroupsSpecification, LowerBound, TargetTriple}; +use uv_distribution_types::IndexCapabilities; +use uv_pep440::Version; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest, PythonVersion}; -use uv_resolver::TreeDisplay; +use uv_resolver::{PackageMap, TreeDisplay}; use uv_workspace::{DiscoveryOptions, Workspace}; +use crate::commands::pip::latest::LatestClient; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::pip::resolution_markers; use crate::commands::project::lock::LockMode; @@ -34,6 +39,7 @@ pub(crate) async fn tree( package: Vec, no_dedupe: bool, invert: bool, + outdated: bool, python_version: Option, python_platform: Option, python: Option, @@ -116,10 +122,72 @@ pub(crate) async fn tree( ) }); + // If necessary, look up the latest version of each package. + let latest = if outdated { + let ResolverSettings { + index_locations: _, + index_strategy: _, + keyring_provider, + allow_insecure_host, + resolution: _, + prerelease: _, + dependency_metadata: _, + config_setting: _, + no_build_isolation: _, + no_build_isolation_package: _, + exclude_newer: _, + link_mode: _, + upgrade: _, + build_options: _, + sources: _, + } = &settings; + + 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) + .keyring(*keyring_provider) + .allow_insecure_host(allow_insecure_host.clone()) + .build(); + + // Initialize the client to fetch the latest version of each package. + let client = LatestClient { + client: &client, + capabilities: &capabilities, + prerelease: lock.prerelease_mode(), + exclude_newer: lock.exclude_newer(), + requires_python: lock.requires_python(), + tags: None, + }; + + // Fetch the latest version for each package. + stream::iter(lock.packages()) + .filter_map(|package| async { + let index = package.index(workspace.install_path()).ok()??; + let filename = client + .find_latest(package.name(), Some(&index)) + .await + .ok()??; + if filename.version() == package.version() { + None + } else { + Some((package.clone(), filename.into_version())) + } + }) + .collect::>() + .await + } else { + PackageMap::default() + }; + // Render the tree. let tree = TreeDisplay::new( &lock, markers.as_ref(), + &latest, depth.into(), &prune, &package, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index aa28abbbf811..85903e2774ad 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1509,6 +1509,7 @@ async fn run_project( args.package, args.no_dedupe, args.invert, + args.outdated, args.python_version, args.python_platform, args.python, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index e8de6a339cca..19604a406827 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1057,6 +1057,7 @@ pub(crate) struct TreeSettings { pub(crate) package: Vec, pub(crate) no_dedupe: bool, pub(crate) invert: bool, + pub(crate) outdated: bool, pub(crate) python_version: Option, pub(crate) python_platform: Option, pub(crate) python: Option, @@ -1096,6 +1097,7 @@ impl TreeSettings { package: tree.package, no_dedupe: tree.no_dedupe, invert: tree.invert, + outdated: tree.outdated, python_version, python_platform, python: python.and_then(Maybe::into_option), diff --git a/crates/uv/tests/it/tree.rs b/crates/uv/tests/it/tree.rs index c375b11a0cd3..e1d72b5ec78d 100644 --- a/crates/uv/tests/it/tree.rs +++ b/crates/uv/tests/it/tree.rs @@ -238,6 +238,38 @@ fn frozen() -> Result<()> { Ok(()) } +#[test] +fn outdated() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.0.0"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--outdated").arg("--universal"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + └── anyio v3.0.0 (latest: v4.3.0) + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "### + ); + + Ok(()) +} + #[test] fn platform_dependencies() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b9729fc14b59..fd63cb8a56c9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2618,6 +2618,8 @@ uv tree [OPTIONS]

The project itself will also be omitted.

+
--outdated

Show the latest available version of each package in the tree

+
--package package

Display only the specified packages

--prerelease prerelease

The strategy to use when considering pre-release versions.

@@ -6830,6 +6832,8 @@ uv pip tree [OPTIONS]

When disabled, uv will only use locally cached data and locally available files.

+
--outdated

Show the latest available version of each package in the tree

+
--package package

Display only the specified packages

--project project

Run the command within the given project directory.