Implement --invert for pip tree (#4621)

## Summary

Part of https://github.com/astral-sh/uv/issues/4439.

## Test Plan

Existing tests pass + added a couple of new tests with `--invert`.
This commit is contained in:
Chan Kang 2024-07-01 12:58:28 -04:00 committed by GitHub
parent 0ee4a2cc6e
commit 5715def24b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 342 additions and 27 deletions

View file

@ -1436,6 +1436,10 @@ pub struct PipTreeArgs {
#[arg(long)] #[arg(long)]
pub no_dedupe: bool, pub no_dedupe: bool,
#[arg(long, alias = "reverse")]
/// Show the reverse dependencies for the given package. This flag will invert the tree and display the packages that depend on the given package.
pub invert: bool,
/// Validate the virtual environment, to detect packages with missing dependencies or other /// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues. /// issues.
#[arg(long, overrides_with("no_strict"))] #[arg(long, overrides_with("no_strict"))]

View file

@ -21,10 +21,12 @@ use crate::commands::ExitStatus;
use crate::printer::Printer; use crate::printer::Printer;
/// Display the installed packages in the current environment as a dependency tree. /// Display the installed packages in the current environment as a dependency tree.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) fn pip_tree( pub(crate) fn pip_tree(
depth: u8, depth: u8,
prune: Vec<PackageName>, prune: Vec<PackageName>,
no_dedupe: bool, no_dedupe: bool,
invert: bool,
strict: bool, strict: bool,
python: Option<&str>, python: Option<&str>,
system: bool, system: bool,
@ -52,6 +54,7 @@ pub(crate) fn pip_tree(
depth.into(), depth.into(),
prune, prune,
no_dedupe, no_dedupe,
invert,
environment.interpreter().markers(), environment.interpreter().markers(),
)? )?
.render()? .render()?
@ -112,18 +115,14 @@ struct DisplayDependencyGraph<'a> {
site_packages: &'a SitePackages, site_packages: &'a SitePackages,
/// Map from package name to the installed distribution. /// Map from package name to the installed distribution.
dist_by_package_name: HashMap<&'a PackageName, &'a InstalledDist>, dist_by_package_name: HashMap<&'a PackageName, &'a InstalledDist>,
/// Set of package names that are required by at least one installed distribution.
/// It is used to determine the starting nodes when recursing the
/// dependency graph.
required_packages: HashSet<PackageName>,
/// Maximum display depth of the dependency tree /// Maximum display depth of the dependency tree
depth: usize, depth: usize,
/// Prune the given package from the display of the dependency tree. /// Prune the given package from the display of the dependency tree.
prune: Vec<PackageName>, prune: Vec<PackageName>,
/// Whether to de-duplicate the displayed dependencies. /// Whether to de-duplicate the displayed dependencies.
no_dedupe: bool, no_dedupe: bool,
/// The marker environment for the current interpreter. /// Map from package name to the list of required (reversed if --invert is given) packages.
markers: &'a MarkerEnvironment, requires_map: HashMap<PackageName, Vec<PackageName>>,
} }
impl<'a> DisplayDependencyGraph<'a> { impl<'a> DisplayDependencyGraph<'a> {
@ -133,27 +132,37 @@ impl<'a> DisplayDependencyGraph<'a> {
depth: usize, depth: usize,
prune: Vec<PackageName>, prune: Vec<PackageName>,
no_dedupe: bool, no_dedupe: bool,
invert: bool,
markers: &'a MarkerEnvironment, markers: &'a MarkerEnvironment,
) -> Result<DisplayDependencyGraph<'a>> { ) -> Result<DisplayDependencyGraph<'a>> {
let mut dist_by_package_name = HashMap::new(); let mut dist_by_package_name = HashMap::new();
let mut required_packages = HashSet::new(); let mut requires_map = HashMap::new();
for site_package in site_packages.iter() { for site_package in site_packages.iter() {
dist_by_package_name.insert(site_package.name(), site_package); dist_by_package_name.insert(site_package.name(), site_package);
} }
for site_package in site_packages.iter() { for site_package in site_packages.iter() {
for required in filtered_requirements(site_package, markers)? { for required in filtered_requirements(site_package, markers)? {
required_packages.insert(required.name.clone()); if invert {
requires_map
.entry(required.name.clone())
.or_insert_with(Vec::new)
.push(site_package.name().clone());
} else {
requires_map
.entry(site_package.name().clone())
.or_insert_with(Vec::new)
.push(required.name.clone());
}
} }
} }
Ok(Self { Ok(Self {
site_packages, site_packages,
dist_by_package_name, dist_by_package_name,
required_packages,
depth, depth,
prune, prune,
no_dedupe, no_dedupe,
markers, requires_map,
}) })
} }
@ -161,7 +170,7 @@ impl<'a> DisplayDependencyGraph<'a> {
fn visit( fn visit(
&self, &self,
installed_dist: &InstalledDist, installed_dist: &InstalledDist,
visited: &mut FxHashMap<PackageName, Vec<Requirement<VerbatimParsedUrl>>>, visited: &mut FxHashMap<PackageName, Vec<PackageName>>,
path: &mut Vec<PackageName>, path: &mut Vec<PackageName>,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
// Short-circuit if the current path is longer than the provided depth. // Short-circuit if the current path is longer than the provided depth.
@ -185,21 +194,22 @@ impl<'a> DisplayDependencyGraph<'a> {
} }
} }
let requirements = filtered_requirements(installed_dist, self.markers)? let requirements_before_filtering = self.requires_map.get(installed_dist.name());
.into_iter() let requirements = match requirements_before_filtering {
.filter(|req| !self.prune.contains(&req.name)) Some(requirements) => requirements
.collect::<Vec<_>>(); .iter()
.filter(|req| {
// Skip if the current package is not one of the installed distributions.
!self.prune.contains(req) && self.dist_by_package_name.contains_key(req)
})
.cloned()
.collect(),
None => Vec::new(),
};
let mut lines = vec![line]; let mut lines = vec![line];
visited.insert(package_name.clone(), requirements.clone()); visited.insert(package_name.clone(), requirements.clone());
path.push(package_name.clone()); path.push(package_name.clone());
for (index, req) in requirements.iter().enumerate() { for (index, req) in requirements.iter().enumerate() {
// Skip if the current package is not one of the installed distributions.
if !self.dist_by_package_name.contains_key(&req.name) {
continue;
}
// For sub-visited packages, add the prefix to make the tree display user-friendly. // For sub-visited packages, add the prefix to make the tree display user-friendly.
// The key observation here is you can group the tree as follows when you're at the // The key observation here is you can group the tree as follows when you're at the
// root of the tree: // root of the tree:
@ -227,7 +237,7 @@ impl<'a> DisplayDependencyGraph<'a> {
let mut prefixed_lines = Vec::new(); let mut prefixed_lines = Vec::new();
for (visited_index, visited_line) in self for (visited_index, visited_line) in self
.visit(self.dist_by_package_name[&req.name], visited, path)? .visit(self.dist_by_package_name[req], visited, path)?
.iter() .iter()
.enumerate() .enumerate()
{ {
@ -250,16 +260,19 @@ impl<'a> DisplayDependencyGraph<'a> {
/// Depth-first traverse the nodes to render the tree. /// Depth-first traverse the nodes to render the tree.
fn render(&self) -> Result<Vec<String>> { fn render(&self) -> Result<Vec<String>> {
let mut visited: FxHashMap<PackageName, Vec<Requirement<VerbatimParsedUrl>>> = let mut visited: FxHashMap<PackageName, Vec<PackageName>> = FxHashMap::default();
FxHashMap::default();
let mut path: Vec<PackageName> = Vec::new(); let mut path: Vec<PackageName> = Vec::new();
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
// The starting nodes are the ones without incoming edges. // The starting nodes are those that are not required by any other package.
let mut non_starting_nodes = HashSet::new();
for children in self.requires_map.values() {
non_starting_nodes.extend(children);
}
for site_package in self.site_packages.iter() { for site_package in self.site_packages.iter() {
// 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.
if !self.required_packages.contains(site_package.name()) { if !non_starting_nodes.contains(site_package.name()) {
lines.extend(self.visit(site_package, &mut visited, &mut path)?); lines.extend(self.visit(site_package, &mut visited, &mut path)?);
} }
} }

View file

@ -551,6 +551,7 @@ async fn run() -> Result<ExitStatus> {
args.depth, args.depth,
args.prune, args.prune,
args.no_dedupe, args.no_dedupe,
args.invert,
args.shared.strict, args.shared.strict,
args.shared.python.as_deref(), args.shared.python.as_deref(),
args.shared.system, args.shared.system,

View file

@ -1067,6 +1067,7 @@ pub(crate) struct PipTreeSettings {
pub(crate) depth: u8, pub(crate) depth: u8,
pub(crate) prune: Vec<PackageName>, pub(crate) prune: Vec<PackageName>,
pub(crate) no_dedupe: bool, pub(crate) no_dedupe: bool,
pub(crate) invert: bool,
// CLI-only settings. // CLI-only settings.
pub(crate) shared: PipSettings, pub(crate) shared: PipSettings,
} }
@ -1078,6 +1079,7 @@ impl PipTreeSettings {
depth, depth,
prune, prune,
no_dedupe, no_dedupe,
invert,
strict, strict,
no_strict, no_strict,
python, python,
@ -1090,6 +1092,7 @@ impl PipTreeSettings {
depth, depth,
prune, prune,
no_dedupe, no_dedupe,
invert,
// Shared settings. // Shared settings.
shared: PipSettings::combine( shared: PipSettings::combine(
PipOptions { PipOptions {

View file

@ -212,6 +212,103 @@ fn nested_dependencies() {
); );
} }
// identical test as `--invert` since `--reverse` is simply an alias for `--invert`.
#[test]
fn reverse() {
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(), tree_command(&context).arg("--reverse"), @r###"
success: true
exit_code: 0
----- stdout -----
joblib v1.3.2
scikit-learn v1.4.1.post1
numpy v1.26.4
scikit-learn v1.4.1.post1
scipy v1.12.0
scikit-learn v1.4.1.post1
threadpoolctl v3.4.0
scikit-learn v1.4.1.post1
----- stderr -----
"###
);
}
#[test]
fn 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(), tree_command(&context).arg("--invert"), @r###"
success: true
exit_code: 0
----- stdout -----
joblib v1.3.2
scikit-learn v1.4.1.post1
numpy v1.26.4
scikit-learn v1.4.1.post1
scipy v1.12.0
scikit-learn v1.4.1.post1
threadpoolctl v3.4.0
scikit-learn v1.4.1.post1
----- stderr -----
"###
);
}
#[test] #[test]
fn depth() { fn depth() {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
@ -410,6 +507,128 @@ fn prune() {
); );
} }
#[test]
#[cfg(target_os = "macos")]
fn nested_dependencies_more_complex_inverted() {
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(), tree_command(&context).arg("--invert"), @r###"
success: true
exit_code: 0
----- stdout -----
certifi v2024.2.2
requests v2.31.0
requests-toolbelt v1.0.0
twine v4.0.2
packse v0.3.12
twine v4.0.2 (*)
charset-normalizer v3.3.2
requests v2.31.0 (*)
chevron-blue v0.2.1
packse v0.3.12
docutils v0.20.1
readme-renderer v43.0
twine v4.0.2 (*)
idna v3.6
requests v2.31.0 (*)
jaraco-context v4.3.0
keyring v25.0.0
twine v4.0.2 (*)
mdurl v0.1.2
markdown-it-py v3.0.0
rich v13.7.1
twine v4.0.2 (*)
more-itertools v10.2.0
jaraco-classes v3.3.1
keyring v25.0.0 (*)
jaraco-functools v4.0.0
keyring v25.0.0 (*)
msgspec v0.18.6
packse v0.3.12
nh3 v0.2.15
readme-renderer v43.0 (*)
packaging v24.0
hatchling v1.22.4
packse v0.3.12
pathspec v0.12.1
hatchling v1.22.4 (*)
pkginfo v1.10.0
twine v4.0.2 (*)
pluggy v1.4.0
hatchling v1.22.4 (*)
pygments v2.17.2
readme-renderer v43.0 (*)
rich v13.7.1 (*)
rfc3986 v2.0.0
twine v4.0.2 (*)
setuptools v69.2.0
packse v0.3.12
trove-classifiers v2024.3.3
hatchling v1.22.4 (*)
urllib3 v2.2.1
requests v2.31.0 (*)
twine v4.0.2 (*)
zipp v3.18.1
importlib-metadata v7.1.0
twine v4.0.2 (*)
(*) Package tree already displayed
----- stderr -----
"###
);
}
#[test] #[test]
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn nested_dependencies_more_complex() { fn nested_dependencies_more_complex() {
@ -852,6 +1071,81 @@ fn multiple_packages_shared_descendant() {
); );
} }
// Test the interaction between `--no-dedupe` and `--invert`.
#[test]
#[cfg(not(windows))]
fn no_dedupe_and_invert() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str(
r"
pendulum==3.0.0
boto3==1.34.69
",
)
.unwrap();
uv_snapshot!(context
.pip_install()
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 10 packages in [TIME]
Prepared 10 packages in [TIME]
Installed 10 packages in [TIME]
+ boto3==1.34.69
+ botocore==1.34.69
+ jmespath==1.0.1
+ pendulum==3.0.0
+ python-dateutil==2.9.0.post0
+ s3transfer==0.10.1
+ six==1.16.0
+ time-machine==2.14.1
+ tzdata==2024.1
+ urllib3==2.2.1
"###
);
uv_snapshot!(context.filters(), tree_command(&context).arg("--no-dedupe").arg("--invert"), @r###"
success: true
exit_code: 0
----- stdout -----
jmespath v1.0.1
boto3 v1.34.69
botocore v1.34.69
boto3 v1.34.69
s3transfer v0.10.1
boto3 v1.34.69
six v1.16.0
python-dateutil v2.9.0.post0
botocore v1.34.69
boto3 v1.34.69
s3transfer v0.10.1
boto3 v1.34.69
pendulum v3.0.0
time-machine v2.14.1
pendulum v3.0.0
tzdata v2024.1
pendulum v3.0.0
urllib3 v2.2.1
botocore v1.34.69
boto3 v1.34.69
s3transfer v0.10.1
boto3 v1.34.69
----- stderr -----
"###
);
}
// Ensure that --no-dedupe behaves as expected // Ensure that --no-dedupe behaves as expected
// in the presence of dependency cycles. // in the presence of dependency cycles.
#[test] #[test]