diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9918e66f8b6a..402d9869ca8b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1385,6 +1385,14 @@ pub struct PipShowArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct PipTreeArgs { + /// Do not de-duplicate repeated dependencies. + /// Usually, when a package has already displayed its dependencies, + /// further occurrences will not re-display its dependencies, + /// and will include a (*) to indicate it has already been shown. + /// This flag will cause those duplicates to be repeated. + #[arg(long)] + pub no_dedupe: 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 39193b940226..77c39c50f940 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -20,6 +20,7 @@ use pypi_types::VerbatimParsedUrl; /// Display the installed packages in the current environment as a dependency tree. pub(crate) fn pip_tree( + no_dedupe: bool, strict: bool, python: Option<&str>, system: bool, @@ -43,7 +44,7 @@ pub(crate) fn pip_tree( // Build the installed index. let site_packages = SitePackages::from_executable(&environment)?; - let rendered_tree = DisplayDependencyGraph::new(&site_packages) + let rendered_tree = DisplayDependencyGraph::new(&site_packages, no_dedupe) .render() .join("\n"); writeln!(printer.stdout(), "{rendered_tree}").unwrap(); @@ -54,6 +55,9 @@ pub(crate) fn pip_tree( "(*) Package tree already displayed".italic() )?; } + if rendered_tree.contains('#') { + writeln!(printer.stdout(), "{}", "(#) Dependency cycle".italic())?; + } // Validate that the environment is consistent. if strict { @@ -91,22 +95,6 @@ fn required_with_no_extra(dist: &InstalledDist) -> Vec>(); } -// Render the line for the given installed distribution in the dependency tree. -fn render_line(installed_dist: &InstalledDist, is_visited: bool) -> String { - let mut line = String::new(); - write!( - &mut line, - "{} v{}", - installed_dist.name(), - installed_dist.version() - ) - .unwrap(); - - if is_visited { - line.push_str(" (*)"); - } - line -} #[derive(Debug)] struct DisplayDependencyGraph<'a> { site_packages: &'a SitePackages, @@ -116,11 +104,14 @@ struct DisplayDependencyGraph<'a> { // It is used to determine the starting nodes when recursing the // dependency graph. required_packages: HashSet, + + // Whether to de-duplicate the displayed dependencies. + no_dedupe: bool, } impl<'a> DisplayDependencyGraph<'a> { /// Create a new [`DisplayDependencyGraph`] for the set of installed distributions. - fn new(site_packages: &'a SitePackages) -> DisplayDependencyGraph<'a> { + fn new(site_packages: &'a SitePackages, no_dedupe: bool) -> DisplayDependencyGraph<'a> { let mut dist_by_package_name = HashMap::new(); let mut required_packages = HashSet::new(); for site_package in site_packages.iter() { @@ -136,6 +127,7 @@ impl<'a> DisplayDependencyGraph<'a> { site_packages, dist_by_package_name, required_packages, + no_dedupe, } } @@ -146,14 +138,22 @@ impl<'a> DisplayDependencyGraph<'a> { visited: &mut HashSet, path: &mut Vec, ) -> Vec { - let mut lines = Vec::new(); let package_name = installed_dist.name().to_string(); let is_visited = visited.contains(&package_name); - lines.push(render_line(installed_dist, is_visited)); - if is_visited { - return lines; + let line = format!("{} v{}", package_name, installed_dist.version()); + + if path.contains(&package_name) { + return vec![format!("{} (#)", line)]; + } + + // If the package has been visited and de-duplication is enabled (default), + // skip the traversal. + if is_visited && !self.no_dedupe { + return vec![format!("{} (*)", line)]; } + let mut lines = vec![line]; + path.push(package_name.clone()); visited.insert(package_name.clone()); let required_packages = required_with_no_extra(installed_dist); diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index f358926e788b..ae27d98fb248 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -546,6 +546,7 @@ async fn run() -> Result { let cache = cache.init()?; commands::pip_tree( + args.no_dedupe, 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 e8a56eb09182..4abff65be064 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -954,6 +954,7 @@ impl PipShowSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct PipTreeSettings { + pub(crate) no_dedupe: bool, // CLI-only settings. pub(crate) shared: PipSettings, } @@ -962,6 +963,7 @@ impl PipTreeSettings { /// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration. pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option) -> Self { let PipTreeArgs { + no_dedupe, strict, no_strict, python, @@ -970,6 +972,7 @@ impl PipTreeSettings { } = args; Self { + no_dedupe, // Shared settings. shared: PipSettings::combine( PipOptions { diff --git a/crates/uv/tests/pip_tree.rs b/crates/uv/tests/pip_tree.rs index a8d076f8f554..fba2eb5719c8 100644 --- a/crates/uv/tests/pip_tree.rs +++ b/crates/uv/tests/pip_tree.rs @@ -328,8 +328,8 @@ fn cyclic_dependency() { uv-cyclic-dependencies-c v0.1.0 └── uv-cyclic-dependencies-a v0.1.0 └── uv-cyclic-dependencies-b v0.1.0 - └── uv-cyclic-dependencies-a v0.1.0 (*) - (*) Package tree already displayed + └── uv-cyclic-dependencies-a v0.1.0 (#) + (#) Dependency cycle ----- stderr ----- "### @@ -541,6 +541,202 @@ fn multiple_packages_shared_descendant() { ); } +// Ensure that --no-dedupe behaves as expected +// in the presence of dependency cycles. +#[test] +#[cfg(not(windows))] +fn no_dedupe_and_cycle() { + 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!(install_command(&context) + .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 + + "### + ); + + let mut command = Command::new(get_bin()); + command + .arg("pip") + .arg("install") + .arg("uv-cyclic-dependencies-c==0.1.0") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .current_dir(&context.temp_dir); + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (2 * 1024 * 1024).to_string()); + } + + uv_snapshot!(context.filters(), command, @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + uv-cyclic-dependencies-a==0.1.0 + + uv-cyclic-dependencies-b==0.1.0 + + uv-cyclic-dependencies-c==0.1.0 + "### + ); + + uv_snapshot!(context.filters(), Command::new(get_bin()) + .arg("pip") + .arg("tree") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .arg("--no-dedupe") + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .current_dir(&context.temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + boto3 v1.34.69 + ├── botocore v1.34.69 + │ ├── jmespath v1.0.1 + │ └── python-dateutil v2.9.0.post0 + │ └── six v1.16.0 + ├── jmespath v1.0.1 + └── s3transfer v0.10.1 + └── botocore v1.34.69 + ├── jmespath v1.0.1 + └── python-dateutil v2.9.0.post0 + └── six v1.16.0 + pendulum v3.0.0 + ├── python-dateutil v2.9.0.post0 + │ └── six v1.16.0 + └── tzdata v2024.1 + time-machine v2.14.1 + └── python-dateutil v2.9.0.post0 + └── six v1.16.0 + urllib3 v2.2.1 + uv-cyclic-dependencies-c v0.1.0 + └── uv-cyclic-dependencies-a v0.1.0 + └── uv-cyclic-dependencies-b v0.1.0 + └── uv-cyclic-dependencies-a v0.1.0 (#) + (#) Dependency cycle + + ----- stderr ----- + "### + ); +} + +#[test] +#[cfg(not(windows))] +fn no_dedupe() { + 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!(install_command(&context) + .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(), Command::new(get_bin()) + .arg("pip") + .arg("tree") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .arg("--no-dedupe") + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .current_dir(&context.temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + boto3 v1.34.69 + ├── botocore v1.34.69 + │ ├── jmespath v1.0.1 + │ └── python-dateutil v2.9.0.post0 + │ └── six v1.16.0 + ├── jmespath v1.0.1 + └── s3transfer v0.10.1 + └── botocore v1.34.69 + ├── jmespath v1.0.1 + └── python-dateutil v2.9.0.post0 + └── six v1.16.0 + pendulum v3.0.0 + ├── python-dateutil v2.9.0.post0 + │ └── six v1.16.0 + └── tzdata v2024.1 + time-machine v2.14.1 + └── python-dateutil v2.9.0.post0 + └── six v1.16.0 + urllib3 v2.2.1 + + ----- stderr ----- + "### + ); +} + #[test] fn with_editable() { let context = TestContext::new("3.12");