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.
This commit is contained in:
Chan Kang 2024-07-20 14:31:16 -04:00 committed by GitHub
parent 1b09cb26f5
commit 12518a01a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 389 additions and 17 deletions

View file

@ -567,6 +567,15 @@ pub enum VersionOrUrl<T: Pep508Url = VerbatimUrl> {
Url(T), Url(T),
} }
impl<T: Pep508Url> Display for VersionOrUrl<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),
}
}
}
/// Unowned version specifier or URL to install. /// Unowned version specifier or URL to install.
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> { pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> {
@ -576,6 +585,15 @@ pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> {
Url(&'a T), Url(&'a T),
} }
impl<T: Pep508Url> 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> { impl<'a> From<&'a VersionOrUrl> for VersionOrUrlRef<'a> {
fn from(value: &'a VersionOrUrl) -> Self { fn from(value: &'a VersionOrUrl) -> Self {
match value { match value {

View file

@ -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<VersionOrUrl<VerbatimParsedUrl>> {
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. /// Returns `true` if the source is editable.
pub fn is_editable(&self) -> bool { pub fn is_editable(&self) -> bool {
matches!(self, Self::Directory { editable: true, .. }) matches!(self, Self::Directory { editable: true, .. })

View file

@ -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. /// 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")] #[arg(long, alias = "reverse")]
pub invert: bool, pub invert: bool,
/// Show the version constraint(s) imposed on each package.
#[arg(long)]
pub show_version_specifiers: bool,
} }

View file

@ -1,14 +1,14 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Write; use std::fmt::Write;
use anyhow::Result; use anyhow::Result;
use indexmap::IndexMap; use indexmap::IndexMap;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use rustc_hash::FxHashMap; use rustc_hash::{FxHashMap, FxHashSet};
use tracing::debug; use tracing::debug;
use distribution_types::{Diagnostic, Name}; use distribution_types::{Diagnostic, Name};
use pep508_rs::MarkerEnvironment; use pep508_rs::MarkerEnvironment;
use pypi_types::RequirementSource;
use uv_cache::Cache; use uv_cache::Cache;
use uv_distribution::Metadata; use uv_distribution::Metadata;
use uv_fs::Simplified; use uv_fs::Simplified;
@ -29,6 +29,7 @@ pub(crate) fn pip_tree(
package: Vec<PackageName>, package: Vec<PackageName>,
no_dedupe: bool, no_dedupe: bool,
invert: bool, invert: bool,
show_version_specifiers: bool,
strict: bool, strict: bool,
python: Option<&str>, python: Option<&str>,
system: bool, system: bool,
@ -66,6 +67,7 @@ pub(crate) fn pip_tree(
package, package,
no_dedupe, no_dedupe,
invert, invert,
show_version_specifiers,
environment.interpreter().markers(), environment.interpreter().markers(),
packages, packages,
) )
@ -74,7 +76,7 @@ pub(crate) fn pip_tree(
writeln!(printer.stdout(), "{rendered_tree}")?; writeln!(printer.stdout(), "{rendered_tree}")?;
if rendered_tree.contains('*') { if rendered_tree.contains("(*)") {
let message = if no_dedupe { let message = if no_dedupe {
"(*) Package tree is a cycle and cannot be shown".italic() "(*) Package tree is a cycle and cannot be shown".italic()
} else { } else {
@ -113,7 +115,9 @@ pub(crate) struct DisplayDependencyGraph {
/// Map from package name to its requirements. /// Map from package name to its requirements.
/// ///
/// If `--invert` is given the map is inverted. /// If `--invert` is given the map is inverted.
requirements: HashMap<PackageName, Vec<PackageName>>, requirements: FxHashMap<PackageName, Vec<PackageName>>,
/// Map from requirement package name-to-parent-to-dependency metadata.
dependencies: FxHashMap<PackageName, FxHashMap<PackageName, Dependency>>,
} }
impl DisplayDependencyGraph { impl DisplayDependencyGraph {
@ -124,10 +128,13 @@ impl DisplayDependencyGraph {
package: Vec<PackageName>, package: Vec<PackageName>,
no_dedupe: bool, no_dedupe: bool,
invert: bool, invert: bool,
show_version_specifiers: bool,
markers: &MarkerEnvironment, markers: &MarkerEnvironment,
packages: IndexMap<PackageName, Vec<Metadata>>, packages: IndexMap<PackageName, Vec<Metadata>>,
) -> Self { ) -> Self {
let mut requirements: HashMap<_, Vec<_>> = HashMap::new(); let mut requirements: FxHashMap<_, Vec<_>> = FxHashMap::default();
let mut dependencies: FxHashMap<PackageName, FxHashMap<PackageName, Dependency>> =
FxHashMap::default();
// Add all transitive requirements. // Add all transitive requirements.
for metadata in packages.values().flatten() { for metadata in packages.values().flatten() {
@ -138,20 +145,33 @@ impl DisplayDependencyGraph {
.as_ref() .as_ref()
.map_or(true, |m| m.evaluate(markers, &[])) .map_or(true, |m| m.evaluate(markers, &[]))
}) { }) {
if invert { let dependency = if invert {
requirements Dependency::Inverted(
.entry(required.name.clone()) required.name.clone(),
.or_default() metadata.name.clone(),
.push(metadata.name.clone()); required.source.clone(),
)
} else { } else {
requirements Dependency::Normal(
.entry(metadata.name.clone()) 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() .or_default()
.push(required.name.clone()); .insert(dependency.child().clone(), dependency);
} }
} }
} }
Self { Self {
packages, packages,
depth, depth,
@ -159,6 +179,7 @@ impl DisplayDependencyGraph {
package, package,
no_dedupe, no_dedupe,
requirements, requirements,
dependencies,
} }
} }
@ -175,7 +196,19 @@ impl DisplayDependencyGraph {
} }
let package_name = &metadata.name; 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: // Skip the traversal if:
// 1. The package is in the current traversal path (i.e., a dependency cycle). // 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() { if self.package.is_empty() {
// The root nodes are those that are not required by any other package. // 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() { for package in self.packages.values().flatten() {
// If the current package is not required by any other package, start the traversal // If the current package is not required by any other package, start the traversal
// with the current package as the root. // with the current package as the root.
@ -286,3 +319,50 @@ impl DisplayDependencyGraph {
lines 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}")
}
}
}
}

View file

@ -30,6 +30,7 @@ pub(crate) async fn tree(
package: Vec<PackageName>, package: Vec<PackageName>,
no_dedupe: bool, no_dedupe: bool,
invert: bool, invert: bool,
show_version_specifiers: bool,
python: Option<String>, python: Option<String>,
settings: ResolverSettings, settings: ResolverSettings,
python_preference: PythonPreference, python_preference: PythonPreference,
@ -94,6 +95,7 @@ pub(crate) async fn tree(
package, package,
no_dedupe, no_dedupe,
invert, invert,
show_version_specifiers,
interpreter.markers(), interpreter.markers(),
packages, packages,
) )

View file

@ -501,6 +501,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
args.package, args.package,
args.no_dedupe, args.no_dedupe,
args.invert, args.invert,
args.show_version_specifiers,
args.shared.strict, args.shared.strict,
args.shared.python.as_deref(), args.shared.python.as_deref(),
args.shared.system, args.shared.system,
@ -999,6 +1000,7 @@ async fn run_project(
args.package, args.package,
args.no_dedupe, args.no_dedupe,
args.invert, args.invert,
args.show_version_specifiers,
args.python, args.python,
args.resolver, args.resolver,
globals.python_preference, globals.python_preference,

View file

@ -725,6 +725,7 @@ pub(crate) struct TreeSettings {
pub(crate) package: Vec<PackageName>, pub(crate) package: Vec<PackageName>,
pub(crate) no_dedupe: bool, pub(crate) no_dedupe: bool,
pub(crate) invert: bool, pub(crate) invert: bool,
pub(crate) show_version_specifiers: bool,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) resolver: ResolverSettings, pub(crate) resolver: ResolverSettings,
} }
@ -749,6 +750,7 @@ impl TreeSettings {
package: tree.package, package: tree.package,
no_dedupe: tree.no_dedupe, no_dedupe: tree.no_dedupe,
invert: tree.invert, invert: tree.invert,
show_version_specifiers: tree.show_version_specifiers,
python, python,
resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem), resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
} }
@ -1265,6 +1267,7 @@ pub(crate) struct PipTreeSettings {
pub(crate) package: Vec<PackageName>, pub(crate) package: Vec<PackageName>,
pub(crate) no_dedupe: bool, pub(crate) no_dedupe: bool,
pub(crate) invert: bool, pub(crate) invert: bool,
pub(crate) show_version_specifiers: bool,
// CLI-only settings. // CLI-only settings.
pub(crate) shared: PipSettings, pub(crate) shared: PipSettings,
} }
@ -1287,6 +1290,7 @@ impl PipTreeSettings {
prune: tree.prune, prune: tree.prune,
no_dedupe: tree.no_dedupe, no_dedupe: tree.no_dedupe,
invert: tree.invert, invert: tree.invert,
show_version_specifiers: tree.show_version_specifiers,
package: tree.package, package: tree.package,
// Shared settings. // Shared settings.
shared: PipSettings::combine( shared: PipSettings::combine(

View file

@ -110,7 +110,6 @@ fn single_package() {
"### "###
); );
} }
// `pandas` requires `numpy` with markers on Python version. // `pandas` requires `numpy` with markers on Python version.
#[test] #[test]
#[cfg(not(windows))] #[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 -----
"###
);
}