Implement uv tree (#4708)

## Summary

Implements the `uv tree`, which displays dependencies from the lockfile
as a tree. Resolves https://github.com/astral-sh/uv/issues/4699.
This commit is contained in:
Ibraheem Ahmed 2024-07-08 14:07:48 -04:00 committed by GitHub
parent 4bc36c0cb8
commit dc7ad3abdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 434 additions and 101 deletions

1
Cargo.lock generated
View file

@ -4456,6 +4456,7 @@ dependencies = [
"fs-err",
"futures",
"ignore",
"indexmap",
"indicatif",
"indoc",
"insta",

View file

@ -284,6 +284,9 @@ pub enum ProjectCommand {
/// Remove one or more packages from the project requirements.
#[clap(hide = true)]
Remove(RemoveArgs),
/// Display the dependency tree for the project.
#[clap(hide = true)]
Tree(TreeArgs),
}
/// A re-implementation of `Option`, used to avoid Clap's automatic `Option` flattening in
@ -1426,29 +1429,8 @@ pub struct PipShowArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct PipTreeArgs {
/// Maximum display depth of the dependency tree
#[arg(long, short, default_value_t = 255)]
pub depth: u8,
/// Prune the given package from the display of the dependency tree.
#[arg(long)]
pub prune: Vec<PackageName>,
/// Display only the specified packages.
#[arg(long)]
pub package: Vec<PackageName>,
/// 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,
#[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,
#[command(flatten)]
pub tree: DisplayTreeArgs,
/// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues.
@ -1886,6 +1868,34 @@ pub struct RemoveArgs {
pub python: Option<String>,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct TreeArgs {
#[command(flatten)]
pub tree: DisplayTreeArgs,
#[command(flatten)]
pub build: BuildArgs,
#[command(flatten)]
pub resolver: ResolverArgs,
/// The Python interpreter for which packages should be listed.
///
/// By default, `uv` installs into the virtual environment in the current working directory or
/// any parent directory. The `--python` option allows you to specify a different interpreter,
/// which is intended for use in continuous integration (CI) environments or other automated
/// workflows.
///
/// Supported formats:
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
/// `python3.10` on Linux and macOS.
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
pub python: Option<String>,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolNamespace {
@ -2437,3 +2447,30 @@ pub struct ResolverInstallerArgs {
)]
pub no_compile_bytecode: bool,
}
#[derive(Args)]
pub struct DisplayTreeArgs {
/// Maximum display depth of the dependency tree
#[arg(long, short, default_value_t = 255)]
pub depth: u8,
/// Prune the given package from the display of the dependency tree.
#[arg(long)]
pub prune: Vec<PackageName>,
/// Display only the specified packages.
#[arg(long)]
pub package: Vec<PackageName>,
/// 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,
/// Show the reverse dependencies for the given package. This flag will invert the tree and display the packages that depend on the given package.
#[arg(long, alias = "reverse")]
pub invert: bool,
}

View file

@ -324,6 +324,11 @@ impl Lock {
&self.distributions
}
/// Returns the owned [`Distribution`] entries in this lock.
pub fn into_distributions(self) -> Vec<Distribution> {
self.distributions
}
/// Returns the supported Python version range for the lockfile, if present.
pub fn requires_python(&self) -> Option<&RequiresPython> {
self.requires_python.as_ref()

View file

@ -51,6 +51,7 @@ flate2 = { workspace = true, default-features = false }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
indicatif = { workspace = true }
indexmap = { workspace = true }
itertools = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
owo-colors = { workspace = true }

View file

@ -22,6 +22,7 @@ pub(crate) use project::lock::lock;
pub(crate) use project::remove::remove;
pub(crate) use project::run::run;
pub(crate) use project::sync::sync;
pub(crate) use project::tree::tree;
pub(crate) use python::dir::dir as python_dir;
pub(crate) use python::find::find as python_find;
pub(crate) use python::install::install as python_install;

View file

@ -2,14 +2,15 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use anyhow::Result;
use indexmap::IndexMap;
use owo_colors::OwoColorize;
use rustc_hash::FxHashMap;
use tracing::debug;
use distribution_types::{Diagnostic, InstalledDist, Name};
use pep508_rs::{MarkerEnvironment, Requirement};
use pypi_types::VerbatimParsedUrl;
use distribution_types::{Diagnostic, Name};
use pep508_rs::MarkerEnvironment;
use uv_cache::Cache;
use uv_distribution::Metadata;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
@ -47,19 +48,28 @@ pub(crate) fn pip_tree(
environment.python_executable().user_display().cyan()
);
// Build the installed index.
// Read packages from the virtual environment.
let site_packages = SitePackages::from_environment(&environment)?;
let mut packages: IndexMap<_, Vec<_>> = IndexMap::new();
for package in site_packages.iter() {
let metadata = Metadata::from_metadata23(package.metadata()?);
packages
.entry(package.name().clone())
.or_default()
.push(metadata);
}
// Render the tree.
let rendered_tree = DisplayDependencyGraph::new(
&site_packages,
depth.into(),
prune,
package,
no_dedupe,
invert,
environment.interpreter().markers(),
)?
.render()?
packages,
)
.render()
.join("\n");
writeln!(printer.stdout(), "{rendered_tree}")?;
@ -89,32 +99,9 @@ pub(crate) fn pip_tree(
Ok(ExitStatus::Success)
}
/// Filter out all required packages of the given distribution if they
/// are required by an extra.
///
/// For example, `requests==2.32.3` requires `charset-normalizer`, `idna`, `urllib`, and `certifi` at
/// all times, `PySocks` on `socks` extra and `chardet` on `use_chardet_on_py3` extra.
/// This function will return `["charset-normalizer", "idna", "urllib", "certifi"]` for `requests`.
fn filtered_requirements<'env>(
dist: &'env InstalledDist,
markers: &'env MarkerEnvironment,
) -> Result<impl Iterator<Item = Requirement<VerbatimParsedUrl>> + 'env> {
Ok(dist
.metadata()?
.requires_dist
.into_iter()
.filter(|requirement| {
requirement
.marker
.as_ref()
.map_or(true, |m| m.evaluate(markers, &[]))
}))
}
#[derive(Debug)]
struct DisplayDependencyGraph<'env> {
// Installed packages.
site_packages: &'env SitePackages,
pub(crate) struct DisplayDependencyGraph {
packages: IndexMap<PackageName, Vec<Metadata>>,
/// Maximum display depth of the dependency tree
depth: usize,
/// Prune the given packages from the display of the dependency tree.
@ -129,82 +116,88 @@ struct DisplayDependencyGraph<'env> {
requirements: HashMap<PackageName, Vec<PackageName>>,
}
impl<'env> DisplayDependencyGraph<'env> {
impl DisplayDependencyGraph {
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
fn new(
site_packages: &'env SitePackages,
pub(crate) fn new(
depth: usize,
prune: Vec<PackageName>,
package: Vec<PackageName>,
no_dedupe: bool,
invert: bool,
markers: &'env MarkerEnvironment,
) -> Result<DisplayDependencyGraph<'env>> {
markers: &MarkerEnvironment,
packages: IndexMap<PackageName, Vec<Metadata>>,
) -> Self {
let mut requirements: HashMap<_, Vec<_>> = HashMap::new();
// Add all transitive requirements.
for site_package in site_packages.iter() {
for required in filtered_requirements(site_package, markers)? {
for metadata in packages.values().flatten() {
// Ignore any optional dependencies.
for required in metadata.requires_dist.iter().filter(|requirement| {
requirement
.marker
.as_ref()
.map_or(true, |m| m.evaluate(markers, &[]))
}) {
if invert {
requirements
.entry(required.name.clone())
.or_default()
.push(site_package.name().clone());
.push(metadata.name.clone());
} else {
requirements
.entry(site_package.name().clone())
.entry(metadata.name.clone())
.or_default()
.push(required.name.clone());
}
}
}
Ok(Self {
site_packages,
Self {
packages,
depth,
prune,
package,
no_dedupe,
requirements,
})
}
}
/// Perform a depth-first traversal of the given distribution and its dependencies.
fn visit(
&self,
installed_dist: &'env InstalledDist,
fn visit<'env>(
&'env self,
metadata: &'env Metadata,
visited: &mut FxHashMap<&'env PackageName, Vec<PackageName>>,
path: &mut Vec<&'env PackageName>,
) -> Result<Vec<String>> {
) -> Vec<String> {
// Short-circuit if the current path is longer than the provided depth.
if path.len() > self.depth {
return Ok(Vec::new());
return Vec::new();
}
let package_name = installed_dist.name();
let line = format!("{} v{}", package_name, installed_dist.version());
let package_name = &metadata.name;
let line = format!("{} v{}", package_name, metadata.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(package_name) {
if !self.no_dedupe || path.contains(&package_name) {
return Ok(if requirements.is_empty() {
return if requirements.is_empty() {
vec![line]
} else {
vec![format!("{} (*)", line)]
});
};
}
}
let requirements = self
.requirements
.get(installed_dist.name())
.get(package_name)
.into_iter()
.flatten()
.filter(|req| {
.filter(|&req| {
// Skip if the current package is not one of the installed distributions.
!self.prune.contains(req) && self.site_packages.contains_package(req)
!self.prune.contains(req) && self.packages.contains_key(req)
})
.cloned()
.collect::<Vec<_>>();
@ -241,9 +234,9 @@ impl<'env> DisplayDependencyGraph<'env> {
("├── ", "")
};
for distribution in self.site_packages.get_packages(req) {
for distribution in self.packages.get(req).into_iter().flatten() {
for (visited_index, visited_line) in
self.visit(distribution, visited, path)?.iter().enumerate()
self.visit(distribution, visited, path).iter().enumerate()
{
let prefix = if visited_index == 0 {
prefix_top
@ -257,11 +250,11 @@ impl<'env> DisplayDependencyGraph<'env> {
}
path.pop();
Ok(lines)
lines
}
/// Depth-first traverse the nodes to render the tree.
fn render(&self) -> Result<Vec<String>> {
pub(crate) fn render(&self) -> Vec<String> {
let mut visited: FxHashMap<&PackageName, Vec<PackageName>> = FxHashMap::default();
let mut path: Vec<&PackageName> = Vec::new();
let mut lines: Vec<String> = Vec::new();
@ -269,12 +262,12 @@ impl<'env> DisplayDependencyGraph<'env> {
if self.package.is_empty() {
// The root nodes are those that are not required by any other package.
let children: HashSet<_> = self.requirements.values().flatten().collect();
for site_package in self.site_packages.iter() {
for package in self.packages.values().flatten() {
// If the current package is not required by any other package, start the traversal
// with the current package as the root.
if !children.contains(site_package.name()) {
if !children.contains(&package.name) {
path.clear();
lines.extend(self.visit(site_package, &mut visited, &mut path)?);
lines.extend(self.visit(package, &mut visited, &mut path));
}
}
} else {
@ -282,12 +275,14 @@ impl<'env> DisplayDependencyGraph<'env> {
if index != 0 {
lines.push(String::new());
}
for installed_dist in self.site_packages.get_packages(package) {
for package in self.packages.get(package).into_iter().flatten() {
path.clear();
lines.extend(self.visit(installed_dist, &mut visited, &mut path)?);
lines.extend(self.visit(package, &mut visited, &mut path));
}
}
}
Ok(lines)
lines
}
}

View file

@ -36,6 +36,7 @@ pub(crate) mod lock;
pub(crate) mod remove;
pub(crate) mod run;
pub(crate) mod sync;
pub(crate) mod tree;
#[derive(thiserror::Error, Debug)]
pub(crate) enum ProjectError {

View file

@ -0,0 +1,111 @@
use std::fmt::Write;
use anyhow::Result;
use indexmap::IndexMap;
use owo_colors::OwoColorize;
use pep508_rs::PackageName;
use uv_cache::Cache;
use uv_client::Connectivity;
use uv_configuration::{Concurrency, PreviewMode};
use uv_distribution::Workspace;
use uv_python::{PythonFetch, PythonPreference, PythonRequest};
use uv_warnings::warn_user_once;
use crate::commands::pip::tree::DisplayDependencyGraph;
use crate::commands::project::FoundInterpreter;
use crate::commands::ExitStatus;
use crate::printer::Printer;
use crate::settings::ResolverSettings;
use super::lock::do_lock;
use super::SharedState;
/// Run a command.
pub(crate) async fn tree(
depth: u8,
prune: Vec<PackageName>,
package: Vec<PackageName>,
no_dedupe: bool,
invert: bool,
python: Option<String>,
settings: ResolverSettings,
python_preference: PythonPreference,
python_fetch: PythonFetch,
preview: PreviewMode,
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
if preview.is_disabled() {
warn_user_once!("`uv run` is experimental and may change without warning.");
}
// Find the project requirements.
let workspace = Workspace::discover(&std::env::current_dir()?, None).await?;
// Find an interpreter for the project
let interpreter = FoundInterpreter::discover(
&workspace,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_fetch,
connectivity,
native_tls,
cache,
printer,
)
.await?
.into_interpreter();
// Update the lock file.
let lock = do_lock(
&workspace,
&interpreter,
settings.as_ref(),
&SharedState::default(),
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
// Read packages from the lockfile.
let mut packages: IndexMap<_, Vec<_>> = IndexMap::new();
for dist in lock.into_distributions() {
let name = dist.name().clone();
let metadata = dist.into_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,
interpreter.markers(),
packages,
)
.render()
.join("\n");
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}")?;
}
Ok(ExitStatus::Success)
}

View file

@ -974,6 +974,33 @@ async fn run_project(
)
.await
}
ProjectCommand::Tree(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::TreeSettings::resolve(args, filesystem);
show_settings!(args);
// Initialize the cache.
let cache = cache.init()?;
commands::tree(
args.depth,
args.prune,
args.package,
args.no_dedupe,
args.invert,
args.python,
args.resolver,
globals.python_preference,
globals.python_fetch,
globals.preview,
globals.connectivity,
Concurrency::default(),
globals.native_tls,
&cache,
printer,
)
.await
}
}
}

View file

@ -15,7 +15,7 @@ use uv_cli::{
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs,
ToolUninstallArgs, VenvArgs,
ToolUninstallArgs, TreeArgs, VenvArgs,
};
use uv_client::Connectivity;
use uv_configuration::{
@ -596,6 +596,40 @@ impl RemoveSettings {
}
}
/// The resolved settings to use for a `tree` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct TreeSettings {
pub(crate) depth: u8,
pub(crate) prune: Vec<PackageName>,
pub(crate) package: Vec<PackageName>,
pub(crate) no_dedupe: bool,
pub(crate) invert: bool,
pub(crate) python: Option<String>,
pub(crate) resolver: ResolverSettings,
}
impl TreeSettings {
/// Resolve the [`TreeSettings`] from the CLI and workspace configuration.
pub(crate) fn resolve(args: TreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let TreeArgs {
tree,
build,
resolver,
python,
} = args;
Self {
python,
depth: tree.depth,
prune: tree.prune,
package: tree.package,
no_dedupe: tree.no_dedupe,
invert: tree.invert,
resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
}
}
}
/// The resolved settings to use for a `pip compile` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
@ -1092,7 +1126,7 @@ impl PipShowSettings {
}
}
/// The resolved settings to use for a `pip show` invocation.
/// The resolved settings to use for a `pip tree` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct PipTreeSettings {
@ -1109,11 +1143,7 @@ impl PipTreeSettings {
/// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration.
pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let PipTreeArgs {
depth,
prune,
package,
no_dedupe,
invert,
tree,
strict,
no_strict,
python,
@ -1123,11 +1153,11 @@ impl PipTreeSettings {
} = args;
Self {
depth,
prune,
package,
no_dedupe,
invert,
depth: tree.depth,
prune: tree.prune,
no_dedupe: tree.no_dedupe,
invert: tree.invert,
package: tree.package,
// Shared settings.
shared: PipSettings::combine(
PipOptions {

View file

@ -472,6 +472,14 @@ impl TestContext {
command
}
/// Create a `uv tree` command with options shared across scenarios.
pub fn tree(&self) -> Command {
let mut command = Command::new(get_bin());
command.arg("tree");
self.add_shared_args(&mut command);
command
}
/// Create a `uv clean` command.
pub fn clean(&self) -> Command {
let mut command = Command::new(get_bin());

116
crates/uv/tests/tree.rs Normal file
View file

@ -0,0 +1,116 @@
#![cfg(all(feature = "python", feature = "pypi"))]
use anyhow::Result;
use assert_fs::prelude::*;
use common::{uv_snapshot, TestContext};
mod common;
#[test]
fn nested_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 = [
"scikit-learn==1.4.1.post1"
]
"#,
)?;
uv_snapshot!(context.filters(), context.tree(), @r###"
success: true
exit_code: 0
----- stdout -----
project v0.1.0
scikit-learn v1.4.1.post1
joblib v1.3.2
numpy v1.26.4
scipy v1.12.0
numpy v1.26.4
threadpoolctl v3.4.0
----- stderr -----
warning: `uv run` 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(())
}
#[test]
fn invert() -> 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 = [
"scikit-learn==1.4.1.post1"
]
"#,
)?;
uv_snapshot!(context.filters(), context.tree().arg("--invert"), @r###"
success: true
exit_code: 0
----- stdout -----
joblib v1.3.2
scikit-learn v1.4.1.post1
project v0.1.0
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 (*)
(*) Package tree already displayed
----- stderr -----
warning: `uv run` is experimental and may change without warning.
Resolved 6 packages in [TIME]
"###
);
uv_snapshot!(context.filters(), context.tree().arg("--invert").arg("--no-dedupe"), @r###"
success: true
exit_code: 0
----- stdout -----
joblib v1.3.2
scikit-learn v1.4.1.post1
project v0.1.0
numpy v1.26.4
scikit-learn v1.4.1.post1
project v0.1.0
scipy v1.12.0
scikit-learn v1.4.1.post1
project v0.1.0
threadpoolctl v3.4.0
scikit-learn v1.4.1.post1
project v0.1.0
----- stderr -----
warning: `uv run` is experimental and may change without warning.
Resolved 6 packages in [TIME]
"###
);
Ok(())
}