From 12518a01a4a436ba4c8b8cfc51646a253bcc8c6c Mon Sep 17 00:00:00 2001 From: Chan Kang Date: Sat, 20 Jul 2024 14:31:16 -0400 Subject: [PATCH] Implement `--show-version-specifiers` for `tree` (#5240) ## Summary resolves https://github.com/astral-sh/uv/issues/5217 ## Test Plan existing tests pass (should be perfectly backwards compatible) + added a few tests to cover the new functionality. in particular, in addition to the simple use of `--show-version-specifiers`, its interaction with `--invert` and `--package` flags are tested. --- crates/pep508-rs/src/lib.rs | 18 ++ crates/pypi-types/src/requirement.rs | 18 ++ crates/uv-cli/src/lib.rs | 4 + crates/uv/src/commands/pip/tree.rs | 112 +++++++++-- crates/uv/src/commands/project/tree.rs | 2 + crates/uv/src/lib.rs | 2 + crates/uv/src/settings.rs | 4 + crates/uv/tests/pip_tree.rs | 246 ++++++++++++++++++++++++- 8 files changed, 389 insertions(+), 17 deletions(-) diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index 8e92ee07aafb..8ec32d04e200 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -567,6 +567,15 @@ pub enum VersionOrUrl { Url(T), } +impl Display for VersionOrUrl { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::VersionSpecifier(version_specifier) => Display::fmt(version_specifier, f), + Self::Url(url) => Display::fmt(url, f), + } + } +} + /// Unowned version specifier or URL to install. #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> { @@ -576,6 +585,15 @@ pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> { Url(&'a T), } +impl Display for VersionOrUrlRef<'_, T> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::VersionSpecifier(version_specifier) => Display::fmt(version_specifier, f), + Self::Url(url) => Display::fmt(url, f), + } + } +} + impl<'a> From<&'a VersionOrUrl> for VersionOrUrlRef<'a> { fn from(value: &'a VersionOrUrl) -> Self { match value { diff --git a/crates/pypi-types/src/requirement.rs b/crates/pypi-types/src/requirement.rs index 0ed3abcebdeb..8f1c5dc95a85 100644 --- a/crates/pypi-types/src/requirement.rs +++ b/crates/pypi-types/src/requirement.rs @@ -392,6 +392,24 @@ impl RequirementSource { } } + /// Convert the source to a version specifier or URL. + /// + /// If the source is a registry and the specifier is empty, it returns `None`. + pub fn version_or_url(&self) -> Option> { + match self { + Self::Registry { specifier, .. } => { + if specifier.len() == 0 { + None + } else { + Some(VersionOrUrl::VersionSpecifier(specifier.clone())) + } + } + Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { + Some(VersionOrUrl::Url(self.to_verbatim_parsed_url()?)) + } + } + } + /// Returns `true` if the source is editable. pub fn is_editable(&self) -> bool { matches!(self, Self::Directory { editable: true, .. }) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 710a54deb2e0..74b969511b2b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2764,4 +2764,8 @@ pub struct DisplayTreeArgs { /// Show the reverse dependencies for the given package. This flag will invert the tree and display the packages that depend on the given package. #[arg(long, alias = "reverse")] pub invert: bool, + + /// Show the version constraint(s) imposed on each package. + #[arg(long)] + pub show_version_specifiers: bool, } diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index 5d52aaac94cb..84fb57f8dbc6 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -1,14 +1,14 @@ -use std::collections::{HashMap, HashSet}; use std::fmt::Write; use anyhow::Result; use indexmap::IndexMap; use owo_colors::OwoColorize; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use tracing::debug; use distribution_types::{Diagnostic, Name}; use pep508_rs::MarkerEnvironment; +use pypi_types::RequirementSource; use uv_cache::Cache; use uv_distribution::Metadata; use uv_fs::Simplified; @@ -29,6 +29,7 @@ pub(crate) fn pip_tree( package: Vec, no_dedupe: bool, invert: bool, + show_version_specifiers: bool, strict: bool, python: Option<&str>, system: bool, @@ -66,6 +67,7 @@ pub(crate) fn pip_tree( package, no_dedupe, invert, + show_version_specifiers, environment.interpreter().markers(), packages, ) @@ -74,7 +76,7 @@ pub(crate) fn pip_tree( writeln!(printer.stdout(), "{rendered_tree}")?; - if rendered_tree.contains('*') { + if rendered_tree.contains("(*)") { let message = if no_dedupe { "(*) Package tree is a cycle and cannot be shown".italic() } else { @@ -113,7 +115,9 @@ pub(crate) struct DisplayDependencyGraph { /// Map from package name to its requirements. /// /// If `--invert` is given the map is inverted. - requirements: HashMap>, + requirements: FxHashMap>, + /// Map from requirement package name-to-parent-to-dependency metadata. + dependencies: FxHashMap>, } impl DisplayDependencyGraph { @@ -124,10 +128,13 @@ impl DisplayDependencyGraph { package: Vec, no_dedupe: bool, invert: bool, + show_version_specifiers: bool, markers: &MarkerEnvironment, packages: IndexMap>, ) -> Self { - let mut requirements: HashMap<_, Vec<_>> = HashMap::new(); + let mut requirements: FxHashMap<_, Vec<_>> = FxHashMap::default(); + let mut dependencies: FxHashMap> = + FxHashMap::default(); // Add all transitive requirements. for metadata in packages.values().flatten() { @@ -138,20 +145,33 @@ impl DisplayDependencyGraph { .as_ref() .map_or(true, |m| m.evaluate(markers, &[])) }) { - if invert { - requirements - .entry(required.name.clone()) - .or_default() - .push(metadata.name.clone()); + let dependency = if invert { + Dependency::Inverted( + required.name.clone(), + metadata.name.clone(), + required.source.clone(), + ) } else { - requirements - .entry(metadata.name.clone()) + Dependency::Normal( + metadata.name.clone(), + required.name.clone(), + required.source.clone(), + ) + }; + + requirements + .entry(dependency.parent().clone()) + .or_default() + .push(dependency.child().clone()); + + if show_version_specifiers { + dependencies + .entry(dependency.parent().clone()) .or_default() - .push(required.name.clone()); + .insert(dependency.child().clone(), dependency); } } } - Self { packages, depth, @@ -159,6 +179,7 @@ impl DisplayDependencyGraph { package, no_dedupe, requirements, + dependencies, } } @@ -175,7 +196,19 @@ impl DisplayDependencyGraph { } let package_name = &metadata.name; - let line = format!("{} v{}", package_name, metadata.version); + let mut line = format!("{} v{}", package_name, metadata.version); + + // If the current package is not top-level (i.e., it has a parent), include the specifiers. + if let Some(last) = path.last().copied() { + if let Some(dependency) = self + .dependencies + .get(last) + .and_then(|deps| deps.get(package_name)) + { + line.push(' '); + line.push_str(&format!("[{dependency}]")); + } + } // Skip the traversal if: // 1. The package is in the current traversal path (i.e., a dependency cycle). @@ -261,7 +294,7 @@ impl DisplayDependencyGraph { if self.package.is_empty() { // The root nodes are those that are not required by any other package. - let children: HashSet<_> = self.requirements.values().flatten().collect(); + let children: FxHashSet<_> = self.requirements.values().flatten().collect(); for package in self.packages.values().flatten() { // If the current package is not required by any other package, start the traversal // with the current package as the root. @@ -286,3 +319,50 @@ impl DisplayDependencyGraph { lines } } + +#[derive(Debug)] +enum Dependency { + /// Show dependencies from parent to the child package that it requires. + Normal(PackageName, PackageName, RequirementSource), + /// Show dependencies from the child package to the parent that requires it. + Inverted(PackageName, PackageName, RequirementSource), +} + +impl Dependency { + /// Return the parent in the tree. + fn parent(&self) -> &PackageName { + match self { + Self::Normal(parent, _, _) => parent, + Self::Inverted(parent, _, _) => parent, + } + } + + /// Return the child in the tree. + fn child(&self) -> &PackageName { + match self { + Self::Normal(_, child, _) => child, + Self::Inverted(_, child, _) => child, + } + } +} + +impl std::fmt::Display for Dependency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Normal(_, _, source) => { + let version = match source.version_or_url() { + None => "*".to_string(), + Some(version) => version.to_string(), + }; + write!(f, "required: {version}") + } + Self::Inverted(parent, _, source) => { + let version = match source.version_or_url() { + None => "*".to_string(), + Some(version) => version.to_string(), + }; + write!(f, "requires: {parent} {version}") + } + } + } +} diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index e543c4353974..081f56518490 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -30,6 +30,7 @@ pub(crate) async fn tree( package: Vec, no_dedupe: bool, invert: bool, + show_version_specifiers: bool, python: Option, settings: ResolverSettings, python_preference: PythonPreference, @@ -94,6 +95,7 @@ pub(crate) async fn tree( package, no_dedupe, invert, + show_version_specifiers, interpreter.markers(), packages, ) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ca679fdaea9f..17be8d745a33 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -501,6 +501,7 @@ async fn run(cli: Cli) -> Result { args.package, args.no_dedupe, args.invert, + args.show_version_specifiers, args.shared.strict, args.shared.python.as_deref(), args.shared.system, @@ -999,6 +1000,7 @@ async fn run_project( args.package, args.no_dedupe, args.invert, + args.show_version_specifiers, args.python, args.resolver, globals.python_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 286c90a73b93..ebea6247a08e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -725,6 +725,7 @@ pub(crate) struct TreeSettings { pub(crate) package: Vec, pub(crate) no_dedupe: bool, pub(crate) invert: bool, + pub(crate) show_version_specifiers: bool, pub(crate) python: Option, pub(crate) resolver: ResolverSettings, } @@ -749,6 +750,7 @@ impl TreeSettings { package: tree.package, no_dedupe: tree.no_dedupe, invert: tree.invert, + show_version_specifiers: tree.show_version_specifiers, python, resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem), } @@ -1265,6 +1267,7 @@ pub(crate) struct PipTreeSettings { pub(crate) package: Vec, pub(crate) no_dedupe: bool, pub(crate) invert: bool, + pub(crate) show_version_specifiers: bool, // CLI-only settings. pub(crate) shared: PipSettings, } @@ -1287,6 +1290,7 @@ impl PipTreeSettings { prune: tree.prune, no_dedupe: tree.no_dedupe, invert: tree.invert, + show_version_specifiers: tree.show_version_specifiers, package: tree.package, // Shared settings. shared: PipSettings::combine( diff --git a/crates/uv/tests/pip_tree.rs b/crates/uv/tests/pip_tree.rs index d78f51563aa7..1f4edc018119 100644 --- a/crates/uv/tests/pip_tree.rs +++ b/crates/uv/tests/pip_tree.rs @@ -110,7 +110,6 @@ fn single_package() { "### ); } - // `pandas` requires `numpy` with markers on Python version. #[test] #[cfg(not(windows))] @@ -1500,3 +1499,248 @@ fn package_flag() { "### ); } + +#[test] +fn show_version_specifiers_simple() { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("requests==2.31.0").unwrap(); + + uv_snapshot!(context + .pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + idna==3.6 + + requests==2.31.0 + + urllib3==2.2.1 + "### + ); + + uv_snapshot!(context.filters(), context.pip_tree().arg("--show-version-specifiers"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + requests v2.31.0 + ├── charset-normalizer v3.3.2 [required: <4, >=2] + ├── idna v3.6 [required: <4, >=2.5] + ├── urllib3 v2.2.1 [required: <3, >=1.21.1] + └── certifi v2024.2.2 [required: >=2017.4.17] + + ----- stderr ----- + "### + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn show_version_specifiers_complex() { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("packse").unwrap(); + + uv_snapshot!(context + .pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 32 packages in [TIME] + Prepared 32 packages in [TIME] + Installed 32 packages in [TIME] + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + chevron-blue==0.2.1 + + docutils==0.20.1 + + hatchling==1.22.4 + + idna==3.6 + + importlib-metadata==7.1.0 + + jaraco-classes==3.3.1 + + jaraco-context==4.3.0 + + jaraco-functools==4.0.0 + + keyring==25.0.0 + + markdown-it-py==3.0.0 + + mdurl==0.1.2 + + more-itertools==10.2.0 + + msgspec==0.18.6 + + nh3==0.2.15 + + packaging==24.0 + + packse==0.3.12 + + pathspec==0.12.1 + + pkginfo==1.10.0 + + pluggy==1.4.0 + + pygments==2.17.2 + + readme-renderer==43.0 + + requests==2.31.0 + + requests-toolbelt==1.0.0 + + rfc3986==2.0.0 + + rich==13.7.1 + + setuptools==69.2.0 + + trove-classifiers==2024.3.3 + + twine==4.0.2 + + urllib3==2.2.1 + + zipp==3.18.1 + "### + ); + + uv_snapshot!(context.filters(), context.pip_tree().arg("--show-version-specifiers"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + packse v0.3.12 + ├── chevron-blue v0.2.1 [required: >=0.2.1, <0.3.0] + ├── hatchling v1.22.4 [required: >=1.20.0, <2.0.0] + │ ├── packaging v24.0 [required: >=21.3] + │ ├── pathspec v0.12.1 [required: >=0.10.1] + │ ├── pluggy v1.4.0 [required: >=1.0.0] + │ └── trove-classifiers v2024.3.3 [required: *] + ├── msgspec v0.18.6 [required: >=0.18.4, <0.19.0] + ├── setuptools v69.2.0 [required: >=69.1.1, <70.0.0] + └── twine v4.0.2 [required: >=4.0.2, <5.0.0] + ├── pkginfo v1.10.0 [required: >=1.8.1] + ├── readme-renderer v43.0 [required: >=35.0] + │ ├── nh3 v0.2.15 [required: >=0.2.14] + │ ├── docutils v0.20.1 [required: >=0.13.1] + │ └── pygments v2.17.2 [required: >=2.5.1] + ├── requests v2.31.0 [required: >=2.20] + │ ├── charset-normalizer v3.3.2 [required: <4, >=2] + │ ├── idna v3.6 [required: <4, >=2.5] + │ ├── urllib3 v2.2.1 [required: <3, >=1.21.1] + │ └── certifi v2024.2.2 [required: >=2017.4.17] + ├── requests-toolbelt v1.0.0 [required: !=0.9.0, >=0.8.0] + │ └── requests v2.31.0 [required: <3.0.0, >=2.0.1] (*) + ├── urllib3 v2.2.1 [required: >=1.26.0] + ├── importlib-metadata v7.1.0 [required: >=3.6] + │ └── zipp v3.18.1 [required: >=0.5] + ├── keyring v25.0.0 [required: >=15.1] + │ ├── jaraco-classes v3.3.1 [required: *] + │ │ └── more-itertools v10.2.0 [required: *] + │ ├── jaraco-functools v4.0.0 [required: *] + │ │ └── more-itertools v10.2.0 [required: *] + │ └── jaraco-context v4.3.0 [required: *] + ├── rfc3986 v2.0.0 [required: >=1.4.0] + └── rich v13.7.1 [required: >=12.0.0] + ├── markdown-it-py v3.0.0 [required: >=2.2.0] + │ └── mdurl v0.1.2 [required: ~=0.1] + └── pygments v2.17.2 [required: >=2.13.0, <3.0.0] + (*) Package tree already displayed + + ----- stderr ----- + "### + ); +} + +#[test] +fn show_version_specifiers_with_invert() { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt + .write_str("scikit-learn==1.4.1.post1") + .unwrap(); + + uv_snapshot!(context + .pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + joblib==1.3.2 + + numpy==1.26.4 + + scikit-learn==1.4.1.post1 + + scipy==1.12.0 + + threadpoolctl==3.4.0 + "### + ); + + uv_snapshot!( + context.filters(), + context.pip_tree() + .arg("--show-version-specifiers") + .arg("--invert"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + joblib v1.3.2 + └── scikit-learn v1.4.1.post1 [requires: joblib >=1.2.0] + numpy v1.26.4 + ├── scikit-learn v1.4.1.post1 [requires: numpy <2.0, >=1.19.5] + └── scipy v1.12.0 [requires: numpy <1.29.0, >=1.22.4] + └── scikit-learn v1.4.1.post1 [requires: scipy >=1.6.0] + threadpoolctl v3.4.0 + └── scikit-learn v1.4.1.post1 [requires: threadpoolctl >=2.0.0] + + ----- stderr ----- + "### + ); +} + +#[test] +fn show_version_specifiers_with_package() { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt + .write_str("scikit-learn==1.4.1.post1") + .unwrap(); + + uv_snapshot!(context + .pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + joblib==1.3.2 + + numpy==1.26.4 + + scikit-learn==1.4.1.post1 + + scipy==1.12.0 + + threadpoolctl==3.4.0 + "### + ); + + uv_snapshot!( + context.filters(), + context.pip_tree() + .arg("--show-version-specifiers") + .arg("--package") + .arg("scipy"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + scipy v1.12.0 + └── numpy v1.26.4 [required: <1.29.0, >=1.22.4] + + ----- stderr ----- + "### + ); +}