Show extras and dev dependencies in uv tree (#5768)

## Summary

Closes https://github.com/astral-sh/uv/issues/5759.
This commit is contained in:
Charlie Marsh
2024-08-05 15:15:02 -04:00
committed by GitHub
parent 6b45baf7be
commit 92a2996e8e
2 changed files with 336 additions and 49 deletions
+176 -47
View File
@@ -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<Cow<'env, Dependency>>>,
optional_dependencies:
FxHashMap<&'env DistributionId, FxHashMap<ExtraName, Vec<Cow<'env, Dependency>>>>,
dev_dependencies:
FxHashMap<&'env DistributionId, FxHashMap<GroupName, Vec<Cow<'env, Dependency>>>>,
/// 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::<Vec<_>>();
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<String> {
@@ -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<Node<'env>> = 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::<Vec<_>>();
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<ExtraName>> {
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;
+160 -2
View File
@@ -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(())
}