diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 934280dc4f79..a111db52248e 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -3,7 +3,7 @@ pub use error::{NoSolutionError, ResolveError}; pub use exclude_newer::ExcludeNewer; pub use exclusions::Exclusions; pub use flat_index::FlatIndex; -pub use lock::{Lock, LockError}; +pub use lock::{Lock, LockError, TreeDisplay}; pub use manifest::Manifest; pub use options::{Options, OptionsBuilder}; pub use preferences::{Preference, PreferenceError, Preferences}; diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index 8ce936846113..11bd3a835336 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use either::Either; use itertools::Itertools; use petgraph::visit::EdgeRef; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value}; use url::Url; @@ -2627,6 +2627,204 @@ fn each_element_on_its_line_array(elements: impl Iterator { + /// The underlying [`Lock`] to display. + lock: &'env Lock, + /// The edges in the [`Lock`]. + /// + /// While the dependencies exist on the [`Lock`] directly, if `--invert` is enabled, the + /// direction must be inverted when constructing the tree. + edges: FxHashMap<&'env DistributionId, Vec<&'env DistributionId>>, + /// Maximum display depth of the dependency tree + depth: usize, + /// Prune the given packages from the display of the dependency tree. + prune: Vec, + /// Display only the specified packages. + package: Vec, + /// Whether to de-duplicate the displayed dependencies. + no_dedupe: bool, +} + +impl<'env> TreeDisplay<'env> { + /// Create a new [`DisplayDependencyGraph`] for the set of installed distributions. + pub fn new( + lock: &'env Lock, + depth: usize, + prune: Vec, + package: Vec, + no_dedupe: bool, + invert: bool, + ) -> Self { + let mut edges: FxHashMap<_, Vec<_>> = + FxHashMap::with_capacity_and_hasher(lock.by_id.len(), FxBuildHasher); + for distribution in &lock.distributions { + for dependency in &distribution.dependencies { + let parent = if invert { + &dependency.distribution_id + } else { + &distribution.id + }; + let child = if invert { + &distribution.id + } else { + &dependency.distribution_id + }; + edges.entry(parent).or_default().push(child); + } + } + Self { + lock, + edges, + depth, + prune, + package, + no_dedupe, + } + } + + /// Perform a depth-first traversal of the given distribution and its dependencies. + fn visit( + &self, + id: &'env DistributionId, + visited: &mut FxHashMap<&'env DistributionId, Vec<&'env DistributionId>>, + path: &mut Vec<&'env DistributionId>, + ) -> Vec { + // Short-circuit if the current path is longer than the provided depth. + if path.len() > self.depth { + return Vec::new(); + } + + let package_name = &id.name; + let line = format!("{} v{}", package_name, id.version); + + // Skip the traversal if: + // 1. The package is in the current traversal path (i.e., a dependency cycle). + // 2. The package has been visited and de-duplication is enabled (default). + if let Some(requirements) = visited.get(id) { + if !self.no_dedupe || path.contains(&id) { + return if requirements.is_empty() { + vec![line] + } else { + vec![format!("{} (*)", line)] + }; + } + } + + let edges = self + .edges + .get(id) + .into_iter() + .flatten() + .filter(|&id| !self.prune.contains(&id.name)) + .copied() + .collect::>(); + + let mut lines = vec![line]; + + // Keep track of the dependency path to avoid cycles. + visited.insert(id, edges.clone()); + path.push(id); + + for (index, req) in edges.iter().enumerate() { + // 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: + // root_package + // ├── level_1_0 // Group 1 + // │ ├── level_2_0 ... + // │ │ ├── level_3_0 ... + // │ │ └── level_3_1 ... + // │ └── level_2_1 ... + // ├── level_1_1 // Group 2 + // │ ├── level_2_2 ... + // │ └── level_2_3 ... + // └── level_1_2 // Group 3 + // └── level_2_4 ... + // + // The lines in Group 1 and 2 have `├── ` at the top and `| ` at the rest while + // those in Group 3 have `└── ` at the top and ` ` at the rest. + // This observation is true recursively even when looking at the subtree rooted + // at `level_1_0`. + let (prefix_top, prefix_rest) = if edges.len() - 1 == index { + ("└── ", " ") + } else { + ("├── ", "│ ") + }; + + for (visited_index, visited_line) in self.visit(req, visited, path).iter().enumerate() { + let prefix = if visited_index == 0 { + prefix_top + } else { + prefix_rest + }; + + lines.push(format!("{prefix}{visited_line}")); + } + } + path.pop(); + + lines + } + + /// Depth-first traverse the nodes to render the tree. + fn render(&self) -> Vec { + let mut visited: FxHashMap<&DistributionId, Vec<&DistributionId>> = FxHashMap::default(); + let mut path: Vec<&DistributionId> = Vec::new(); + let mut lines: Vec = Vec::new(); + + if self.package.is_empty() { + // Identify all the root nodes by identifying all the distribution IDs that appear as + // dependencies. + let children: FxHashSet<_> = self.edges.values().flatten().collect(); + for id in self.lock.by_id.keys() { + if !children.contains(&id) { + path.clear(); + lines.extend(self.visit(id, &mut visited, &mut path)); + } + } + } else { + // Index all the IDs by package. + let by_package: FxHashMap<_, _> = + self.lock.by_id.keys().map(|id| (&id.name, id)).collect(); + for (index, package) in self.package.iter().enumerate() { + if index != 0 { + lines.push(String::new()); + } + if let Some(id) = by_package.get(package) { + path.clear(); + lines.extend(self.visit(id, &mut visited, &mut path)); + } + } + } + + lines + } +} + +impl std::fmt::Display for TreeDisplay<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + use owo_colors::OwoColorize; + + let mut deduped = false; + for line in self.render() { + deduped |= line.contains('*'); + writeln!(f, "{line}")?; + } + + if deduped { + let message = if self.no_dedupe { + "(*) Package tree is a cycle and cannot be shown".italic() + } else { + "(*) Package tree already displayed".italic() + }; + writeln!(f, "{message}")?; + } + + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 0a410e3e81d3..46a37a0cf6a1 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -1,8 +1,6 @@ use std::fmt::Write; use anyhow::Result; -use indexmap::IndexMap; -use owo_colors::OwoColorize; use pep508_rs::PackageName; use uv_cache::Cache; @@ -10,10 +8,10 @@ use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode}; use uv_fs::CWD; use uv_python::{PythonFetch, PythonPreference, PythonRequest}; +use uv_resolver::TreeDisplay; use uv_warnings::warn_user_once; use uv_workspace::{DiscoveryOptions, Workspace}; -use crate::commands::pip::tree::DisplayDependencyGraph; use crate::commands::project::FoundInterpreter; use crate::commands::{project, ExitStatus}; use crate::printer::Printer; @@ -31,7 +29,6 @@ pub(crate) async fn tree( package: Vec, no_dedupe: bool, invert: bool, - show_version_specifiers: bool, python: Option, settings: ResolverSettings, python_preference: PythonPreference, @@ -81,38 +78,10 @@ pub(crate) async fn tree( ) .await?; - // Read packages from the lockfile. - let mut packages: IndexMap<_, Vec<_>> = IndexMap::new(); - for dist in lock.lock.into_distributions() { - let name = dist.name().clone(); - let metadata = dist.to_metadata(workspace.install_path())?; - packages.entry(name).or_default().push(metadata); - } - // Render the tree. - let rendered_tree = DisplayDependencyGraph::new( - depth.into(), - prune, - package, - no_dedupe, - invert, - show_version_specifiers, - interpreter.markers(), - packages, - ) - .render() - .join("\n"); + let tree = TreeDisplay::new(&lock.lock, depth.into(), prune, package, no_dedupe, invert); - writeln!(printer.stdout(), "{rendered_tree}")?; - - if rendered_tree.contains('*') { - let message = if no_dedupe { - "(*) Package tree is a cycle and cannot be shown".italic() - } else { - "(*) Package tree already displayed".italic() - }; - writeln!(printer.stdout(), "{message}")?; - } + write!(printer.stdout(), "{tree}")?; Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e379fa98752a..f9f4c777648e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1122,7 +1122,6 @@ 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 0e2409f00e4d..21477d3db545 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -774,7 +774,6 @@ 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, } @@ -799,7 +798,6 @@ 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), } diff --git a/crates/uv/tests/tree.rs b/crates/uv/tests/tree.rs index ee3da136a2ee..f4bede343f36 100644 --- a/crates/uv/tests/tree.rs +++ b/crates/uv/tests/tree.rs @@ -2,8 +2,9 @@ use anyhow::Result; use assert_fs::prelude::*; - use common::{uv_snapshot, TestContext}; +use indoc::formatdoc; +use url::Url; mod common; @@ -180,3 +181,169 @@ fn frozen() -> Result<()> { Ok(()) } + +#[test] +fn platform_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "black" + ] + "#, + )?; + + // Should include `colorama`, even though it's only included on Windows. + uv_snapshot!(context.filters(), context.tree(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + └── black v24.3.0 + ├── click v8.1.7 + │ └── colorama v0.4.6 + ├── mypy-extensions v1.0.0 + ├── packaging v24.0 + ├── pathspec v0.12.1 + └── platformdirs v4.2.0 + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 8 packages in [TIME] + "### + ); + + // `uv tree` should update the lockfile + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn repeated_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "anyio < 2 ; sys_platform == 'win32'", + "anyio > 2 ; sys_platform == 'linux'", + ] + "#, + )?; + + // Should include both versions of `anyio`, which have different dependencies. + uv_snapshot!(context.filters(), context.tree(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + ├── anyio v1.4.0 + │ ├── async-generator v1.10 + │ ├── idna v3.6 + │ └── sniffio v1.3.1 + └── anyio v4.3.0 + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 6 packages in [TIME] + "### + ); + + // `uv tree` should update the lockfile + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + assert!(!lock.is_empty()); + + Ok(()) +} + +/// In this case, a package is included twice at the same version, but pointing to different direct +/// URLs. +#[test] +fn repeated_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let v1 = context.temp_dir.child("v1"); + fs_err::create_dir_all(&v1)?; + let pyproject_toml = v1.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dependency" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + "#, + )?; + + let v2 = context.temp_dir.child("v2"); + fs_err::create_dir_all(&v2)?; + let pyproject_toml = v2.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dependency" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio==3.0.0"] + "#, + )?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dependency @ {} ; sys_platform == 'darwin'", + "dependency @ {} ; sys_platform != 'darwin'", + ] + "#, + Url::from_file_path(context.temp_dir.join("v1")).unwrap(), + Url::from_file_path(context.temp_dir.join("v2")).unwrap(), + })?; + + uv_snapshot!(context.filters(), context.tree(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + ├── dependency v0.0.1 + │ └── anyio v3.7.0 + │ ├── idna v3.6 + │ └── sniffio v1.3.1 + └── dependency v0.0.1 + └── anyio v3.0.0 + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 7 packages in [TIME] + "### + ); + + // `uv tree` should update the lockfile + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + assert!(!lock.is_empty()); + + Ok(()) +}