From 5715def24b7eb060754288e9ea976f5ca32b98bd Mon Sep 17 00:00:00 2001 From: Chan Kang Date: Mon, 1 Jul 2024 12:58:28 -0400 Subject: [PATCH] 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`. --- crates/uv-cli/src/lib.rs | 4 + crates/uv/src/commands/pip/tree.rs | 67 ++++--- crates/uv/src/main.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/pip_tree.rs | 294 +++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 27 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index fd0db6a8a..dbc30b54a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1436,6 +1436,10 @@ pub struct PipTreeArgs { #[arg(long)] 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 /// issues. #[arg(long, overrides_with("no_strict"))] diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index 363deec9e..69a7412fa 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -21,10 +21,12 @@ use crate::commands::ExitStatus; use crate::printer::Printer; /// Display the installed packages in the current environment as a dependency tree. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) fn pip_tree( depth: u8, prune: Vec, no_dedupe: bool, + invert: bool, strict: bool, python: Option<&str>, system: bool, @@ -52,6 +54,7 @@ pub(crate) fn pip_tree( depth.into(), prune, no_dedupe, + invert, environment.interpreter().markers(), )? .render()? @@ -112,18 +115,14 @@ struct DisplayDependencyGraph<'a> { site_packages: &'a SitePackages, /// Map from package name to the installed distribution. 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, /// Maximum display depth of the dependency tree depth: usize, /// Prune the given package from the display of the dependency tree. prune: Vec, /// Whether to de-duplicate the displayed dependencies. no_dedupe: bool, - /// The marker environment for the current interpreter. - markers: &'a MarkerEnvironment, + /// Map from package name to the list of required (reversed if --invert is given) packages. + requires_map: HashMap>, } impl<'a> DisplayDependencyGraph<'a> { @@ -133,27 +132,37 @@ impl<'a> DisplayDependencyGraph<'a> { depth: usize, prune: Vec, no_dedupe: bool, + invert: bool, markers: &'a MarkerEnvironment, ) -> Result> { 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() { dist_by_package_name.insert(site_package.name(), site_package); } for site_package in site_packages.iter() { 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 { site_packages, dist_by_package_name, - required_packages, depth, prune, no_dedupe, - markers, + requires_map, }) } @@ -161,7 +170,7 @@ impl<'a> DisplayDependencyGraph<'a> { fn visit( &self, installed_dist: &InstalledDist, - visited: &mut FxHashMap>>, + visited: &mut FxHashMap>, path: &mut Vec, ) -> Result> { // 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)? - .into_iter() - .filter(|req| !self.prune.contains(&req.name)) - .collect::>(); - + let requirements_before_filtering = self.requires_map.get(installed_dist.name()); + let requirements = match requirements_before_filtering { + Some(requirements) => requirements + .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]; - visited.insert(package_name.clone(), requirements.clone()); path.push(package_name.clone()); 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. // The key observation here is you can group the tree as follows when you're at the // root of the tree: @@ -227,7 +237,7 @@ impl<'a> DisplayDependencyGraph<'a> { let mut prefixed_lines = Vec::new(); 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() .enumerate() { @@ -250,16 +260,19 @@ impl<'a> DisplayDependencyGraph<'a> { /// Depth-first traverse the nodes to render the tree. fn render(&self) -> Result> { - let mut visited: FxHashMap>> = - FxHashMap::default(); + let mut visited: FxHashMap> = FxHashMap::default(); let mut path: Vec = Vec::new(); let mut lines: Vec = 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() { // If the current package is not required by any other package, start the traversal // 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)?); } } diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index d4fdd0d85..5fc76256f 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -551,6 +551,7 @@ async fn run() -> Result { args.depth, args.prune, args.no_dedupe, + args.invert, args.shared.strict, args.shared.python.as_deref(), args.shared.system, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 6b42a51b5..aa957ce48 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1067,6 +1067,7 @@ pub(crate) struct PipTreeSettings { pub(crate) depth: u8, pub(crate) prune: Vec, pub(crate) no_dedupe: bool, + pub(crate) invert: bool, // CLI-only settings. pub(crate) shared: PipSettings, } @@ -1078,6 +1079,7 @@ impl PipTreeSettings { depth, prune, no_dedupe, + invert, strict, no_strict, python, @@ -1090,6 +1092,7 @@ impl PipTreeSettings { depth, prune, no_dedupe, + invert, // Shared settings. shared: PipSettings::combine( PipOptions { diff --git a/crates/uv/tests/pip_tree.rs b/crates/uv/tests/pip_tree.rs index a16ab3171..71c798725 100644 --- a/crates/uv/tests/pip_tree.rs +++ b/crates/uv/tests/pip_tree.rs @@ -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] fn depth() { 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] #[cfg(target_os = "macos")] 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 // in the presence of dependency cycles. #[test]