diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index 640618590..242aec010 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::{FxBuildHasher, FxHashMap, FxHashSet}; +use rustc_hash::{FxHashMap, FxHashSet}; use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value}; use url::Url; @@ -2635,7 +2635,11 @@ pub struct TreeDisplay<'env> { /// /// 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>>, + dependencies: FxHashMap<&'env DistributionId, Vec>>, + optional_dependencies: + FxHashMap<&'env DistributionId, FxHashMap>>>, + dev_dependencies: + FxHashMap<&'env DistributionId, FxHashMap>>>, /// Maximum display depth of the dependency tree depth: usize, /// Prune the given packages from the display of the dependency tree. @@ -2658,8 +2662,12 @@ impl<'env> TreeDisplay<'env> { invert: bool, ) -> Self { let mut non_roots = FxHashSet::default(); - let mut edges: FxHashMap<_, Vec<_>> = - FxHashMap::with_capacity_and_hasher(lock.by_id.len(), FxBuildHasher); + + // Index all the dependencies. We could read these from the `Lock` directly, but we have to + // support `--invert`, so we might as well build them up in either case. + let mut dependencies: FxHashMap<_, Vec<_>> = FxHashMap::default(); + let mut optional_dependencies: FxHashMap<_, FxHashMap<_, Vec<_>>> = FxHashMap::default(); + let mut dev_dependencies: FxHashMap<_, FxHashMap<_, Vec<_>>> = FxHashMap::default(); for distribution in &lock.distributions { for dependency in &distribution.dependencies { @@ -2669,13 +2677,16 @@ impl<'env> TreeDisplay<'env> { &distribution.id }; let child = if invert { - &distribution.id + Cow::Owned(Dependency { + distribution_id: distribution.id.clone(), + extra: dependency.extra.clone(), + marker: dependency.marker.clone(), + }) } else { - &dependency.distribution_id + Cow::Borrowed(dependency) }; - // Mark the dependency as a non-root node. - non_roots.insert(child); + non_roots.insert(child.distribution_id.clone()); // Skip dependencies that don't apply to the current environment. if let Some(environment_markers) = markers { @@ -2686,29 +2697,81 @@ impl<'env> TreeDisplay<'env> { } } - edges.entry(parent).or_default().push(child); + dependencies.entry(parent).or_default().push(child); } - for dependency in distribution.dev_dependencies.values().flatten() { - let child = if invert { - &distribution.id - } else { - &dependency.distribution_id - }; + for (extra, dependencies) in &distribution.optional_dependencies { + for dependency in dependencies { + let parent = if invert { + &dependency.distribution_id + } else { + &distribution.id + }; + let child = if invert { + Cow::Owned(Dependency { + distribution_id: distribution.id.clone(), + extra: dependency.extra.clone(), + marker: dependency.marker.clone(), + }) + } else { + Cow::Borrowed(dependency) + }; - // Mark the dependency as a non-root node. - non_roots.insert(child); + non_roots.insert(child.distribution_id.clone()); + + // Skip dependencies that don't apply to the current environment. + if let Some(environment_markers) = markers { + if let Some(dependency_markers) = dependency.marker.as_ref() { + if !dependency_markers.evaluate(environment_markers, &[]) { + continue; + } + } + } + + optional_dependencies + .entry(parent) + .or_default() + .entry(extra.clone()) + .or_default() + .push(child); + } } - for dependency in distribution.optional_dependencies.values().flatten() { - let child = if invert { - &distribution.id - } else { - &dependency.distribution_id - }; + for (group, dependencies) in &distribution.dev_dependencies { + for dependency in dependencies { + let parent = if invert { + &dependency.distribution_id + } else { + &distribution.id + }; + let child = if invert { + Cow::Owned(Dependency { + distribution_id: distribution.id.clone(), + extra: dependency.extra.clone(), + marker: dependency.marker.clone(), + }) + } else { + Cow::Borrowed(dependency) + }; - // Mark the dependency as a non-root node. - non_roots.insert(child); + non_roots.insert(child.distribution_id.clone()); + + // Skip dependencies that don't apply to the current environment. + if let Some(environment_markers) = markers { + if let Some(dependency_markers) = dependency.marker.as_ref() { + if !dependency_markers.evaluate(environment_markers, &[]) { + continue; + } + } + } + + dev_dependencies + .entry(parent) + .or_default() + .entry(group.clone()) + .or_default() + .push(child); + } } } @@ -2717,12 +2780,14 @@ impl<'env> TreeDisplay<'env> { .distributions .iter() .map(|dist| &dist.id) - .filter(|id| !non_roots.contains(id)) + .filter(|id| !non_roots.contains(*id)) .collect::>(); Self { roots, - edges, + dependencies, + optional_dependencies, + dev_dependencies, depth, prune, package, @@ -2732,8 +2797,8 @@ impl<'env> TreeDisplay<'env> { /// Perform a depth-first traversal of the given distribution and its dependencies. fn visit( - &self, - id: &'env DistributionId, + &'env self, + node: Node<'env>, visited: &mut FxHashMap<&'env DistributionId, Vec<&'env DistributionId>>, path: &mut Vec<&'env DistributionId>, ) -> Vec { @@ -2742,14 +2807,28 @@ impl<'env> TreeDisplay<'env> { return Vec::new(); } - let package_name = &id.name; - let line = format!("{} v{}", package_name, id.version); + let line = { + let mut line = format!("{}", node.distribution_id().name); + + if let Some(extras) = node.extras().filter(|extras| !extras.is_empty()) { + line.push_str(&format!("[{}]", extras.iter().join(","))); + } + + line.push_str(&format!(" v{}", node.distribution_id().version)); + + match node { + Node::Root(_) => line, + Node::Dependency(_) => line, + Node::OptionalDependency(extra, _) => format!("{line} (extra: {extra})"), + Node::DevDependency(group, _) => format!("{line} (group: {group})"), + } + }; // 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) { + if let Some(requirements) = visited.get(node.distribution_id()) { + if !self.no_dedupe || path.contains(&node.distribution_id()) { return if requirements.is_empty() { vec![line] } else { @@ -2758,22 +2837,44 @@ impl<'env> TreeDisplay<'env> { } } - let edges = self - .edges - .get(id) + let dependencies: Vec> = self + .dependencies + .get(node.distribution_id()) .into_iter() .flatten() - .filter(|&id| !self.prune.contains(&id.name)) - .copied() + .map(|dep| Node::Dependency(dep.as_ref())) + .chain( + self.optional_dependencies + .get(node.distribution_id()) + .into_iter() + .flatten() + .flat_map(|(extra, deps)| { + deps.iter() + .map(move |dep| Node::OptionalDependency(extra, dep)) + }), + ) + .chain( + self.dev_dependencies + .get(node.distribution_id()) + .into_iter() + .flatten() + .flat_map(|(group, deps)| { + deps.iter().map(move |dep| Node::DevDependency(group, dep)) + }), + ) + .filter(|dep| !self.prune.contains(&dep.distribution_id().name)) .collect::>(); let mut lines = vec![line]; // Keep track of the dependency path to avoid cycles. - visited.insert(id, edges.clone()); - path.push(id); + visited.insert( + node.distribution_id(), + dependencies.iter().map(Node::distribution_id).collect(), + ); + path.push(node.distribution_id()); - for (index, req) in edges.iter().enumerate() { + for (index, dep) in dependencies.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: @@ -2793,22 +2894,22 @@ impl<'env> TreeDisplay<'env> { // 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 { + let (prefix_top, prefix_rest) = if dependencies.len() - 1 == index { ("└── ", " ") } else { ("├── ", "│ ") }; - - for (visited_index, visited_line) in self.visit(req, visited, path).iter().enumerate() { + for (visited_index, visited_line) in self.visit(*dep, visited, path).iter().enumerate() + { let prefix = if visited_index == 0 { prefix_top } else { prefix_rest }; - lines.push(format!("{prefix}{visited_line}")); } } + path.pop(); lines @@ -2823,7 +2924,7 @@ impl<'env> TreeDisplay<'env> { if self.package.is_empty() { for id in &self.roots { path.clear(); - lines.extend(self.visit(id, &mut visited, &mut path)); + lines.extend(self.visit(Node::Root(id), &mut visited, &mut path)); } } else { let by_package: FxHashMap<_, _> = self.roots.iter().map(|id| (&id.name, id)).collect(); @@ -2834,7 +2935,7 @@ impl<'env> TreeDisplay<'env> { } if let Some(id) = by_package.get(package) { path.clear(); - lines.extend(self.visit(id, &mut visited, &mut path)); + lines.extend(self.visit(Node::Root(id), &mut visited, &mut path)); } } } @@ -2843,6 +2944,34 @@ impl<'env> TreeDisplay<'env> { } } +#[derive(Debug, Copy, Clone)] +enum Node<'env> { + Root(&'env DistributionId), + Dependency(&'env Dependency), + OptionalDependency(&'env ExtraName, &'env Dependency), + DevDependency(&'env GroupName, &'env Dependency), +} + +impl<'env> Node<'env> { + fn distribution_id(&self) -> &'env DistributionId { + match self { + Self::Root(id) => id, + Self::Dependency(dep) => &dep.distribution_id, + Self::OptionalDependency(_, dep) => &dep.distribution_id, + Self::DevDependency(_, dep) => &dep.distribution_id, + } + } + + fn extras(&self) -> Option<&BTreeSet> { + match self { + Self::Root(_) => None, + Self::Dependency(dep) => Some(&dep.extra), + Self::OptionalDependency(_, dep) => Some(&dep.extra), + Self::DevDependency(_, dep) => Some(&dep.extra), + } + } +} + impl std::fmt::Display for TreeDisplay<'_> { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { use owo_colors::OwoColorize; diff --git a/crates/uv/tests/tree.rs b/crates/uv/tests/tree.rs index 258894594..e2b440ec5 100644 --- a/crates/uv/tests/tree.rs +++ b/crates/uv/tests/tree.rs @@ -406,13 +406,15 @@ fn dev_dependencies() -> Result<()> { "#, )?; - // Dev dependencies should be omitted. uv_snapshot!(context.filters(), context.tree().arg("--universal"), @r###" success: true exit_code: 0 ----- stdout ----- project v0.1.0 - └── iniconfig v2.0.0 + ├── iniconfig v2.0.0 + └── anyio v4.3.0 (group: dev) + ├── idna v3.6 + └── sniffio v1.3.1 ----- stderr ----- warning: `uv tree` is experimental and may change without warning @@ -426,3 +428,159 @@ fn dev_dependencies() -> Result<()> { Ok(()) } + +#[test] +fn dev_dependencies_inverted() -> 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 = ["iniconfig"] + [tool.uv] + dev-dependencies = ["anyio"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--invert"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + idna v3.6 + └── anyio v4.3.0 + └── project v0.1.0 (group: dev) + iniconfig v2.0.0 + └── project v0.1.0 + sniffio v1.3.1 + └── anyio v4.3.0 (*) + (*) Package tree already displayed + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 5 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 optional_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 = ["iniconfig", "flask[dotenv]"] + + [project.optional-dependencies] + async = ["anyio"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + ├── flask[dotenv] v3.0.2 + │ ├── blinker v1.7.0 + │ ├── click v8.1.7 + │ │ └── colorama v0.4.6 + │ ├── itsdangerous v2.1.2 + │ ├── jinja2 v3.1.3 + │ │ └── markupsafe v2.1.5 + │ ├── werkzeug v3.0.1 + │ │ └── markupsafe v2.1.5 + │ └── python-dotenv v1.0.1 (extra: dotenv) + ├── iniconfig v2.0.0 + └── anyio v4.3.0 (extra: async) + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 14 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 optional_dependencies_inverted() -> 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 = ["iniconfig", "flask[dotenv]"] + + [project.optional-dependencies] + async = ["anyio"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--invert"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + blinker v1.7.0 + └── flask v3.0.2 + └── project[dotenv] v0.1.0 + colorama v0.4.6 + └── click v8.1.7 + └── flask v3.0.2 (*) + idna v3.6 + └── anyio v4.3.0 + └── project v0.1.0 (extra: async) + iniconfig v2.0.0 + └── project v0.1.0 + itsdangerous v2.1.2 + └── flask v3.0.2 (*) + markupsafe v2.1.5 + ├── jinja2 v3.1.3 + │ └── flask v3.0.2 (*) + └── werkzeug v3.0.1 + └── flask v3.0.2 (*) + python-dotenv v1.0.1 + └── flask v3.0.2 (extra: dotenv) (*) + sniffio v1.3.1 + └── anyio v4.3.0 (*) + (*) Package tree already displayed + + ----- stderr ----- + warning: `uv tree` is experimental and may change without warning + Resolved 14 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(()) +}