Skip to content

Commit

Permalink
implement uv pip tree (#3859)
Browse files Browse the repository at this point in the history
## Summary

resolves #3272

added it as a new subcommand rather than a flag on an existing
command since that seems more consistent with `cargo tree` + cleaner
code organization, but can make changes if it's preferred the other way.
  • Loading branch information
ChannyClaus authored Jun 21, 2024
1 parent 7e574f5 commit dd45fce
Show file tree
Hide file tree
Showing 7 changed files with 942 additions and 3 deletions.
48 changes: 48 additions & 0 deletions crates/uv/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ pub(crate) enum PipCommand {
List(PipListArgs),
/// Show information about one or more installed packages.
Show(PipShowArgs),
/// Display the dependency tree.
Tree(PipTreeArgs),
/// Verify installed packages have compatible dependencies.
Check(PipCheckArgs),
}
Expand Down Expand Up @@ -1344,6 +1346,52 @@ pub(crate) struct PipShowArgs {
pub(crate) no_system: bool,
}

#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct PipTreeArgs {
/// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues.
#[arg(long, overrides_with("no_strict"))]
pub(crate) strict: bool,

#[arg(long, overrides_with("strict"), hide = true)]
pub(crate) no_strict: bool,

/// The Python interpreter for which packages should be listed.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found.
///
/// 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(crate) python: Option<String>,

/// List packages for the system Python.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found. The `--system` option
/// instructs `uv` to use the first Python found in the system `PATH`.
///
/// WARNING: `--system` is intended for use in continuous integration (CI) environments and
/// should be used with caution.
#[arg(
long,
env = "UV_SYSTEM_PYTHON",
value_parser = clap::builder::BoolishValueParser::new(),
overrides_with("no_system")
)]
pub(crate) system: bool,

#[arg(long, overrides_with("system"))]
pub(crate) no_system: bool,
}

#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct VenvArgs {
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub(crate) use pip::install::pip_install;
pub(crate) use pip::list::pip_list;
pub(crate) use pip::show::pip_show;
pub(crate) use pip::sync::pip_sync;
pub(crate) use pip::tree::pip_tree;
pub(crate) use pip::uninstall::pip_uninstall;
pub(crate) use project::add::add;
pub(crate) use project::lock::lock;
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/pip/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) mod list;
pub(crate) mod operations;
pub(crate) mod show;
pub(crate) mod sync;
pub(crate) mod tree;
pub(crate) mod uninstall;

// Determine the tags, markers, and interpreter to use for resolution.
Expand Down
235 changes: 235 additions & 0 deletions crates/uv/src/commands/pip/tree.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
use std::fmt::Write;

use distribution_types::{Diagnostic, InstalledDist, Name};
use owo_colors::OwoColorize;
use tracing::debug;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
use uv_toolchain::EnvironmentPreference;
use uv_toolchain::PythonEnvironment;
use uv_toolchain::ToolchainRequest;

use crate::commands::ExitStatus;
use crate::printer::Printer;
use std::collections::{HashMap, HashSet};

use pypi_types::VerbatimParsedUrl;

/// Display the installed packages in the current environment as a dependency tree.
pub(crate) fn pip_tree(
strict: bool,
python: Option<&str>,
system: bool,
_preview: PreviewMode,
cache: &Cache,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
// Detect the current Python interpreter.
let environment = PythonEnvironment::find(
&python.map(ToolchainRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
)?;

debug!(
"Using Python {} environment at {}",
environment.interpreter().python_version(),
environment.python_executable().user_display().cyan()
);

// Build the installed index.
let site_packages = SitePackages::from_executable(&environment)?;

let rendered_tree = DisplayDependencyGraph::new(&site_packages)
.render()
.join("\n");
writeln!(printer.stdout(), "{rendered_tree}").unwrap();
if rendered_tree.contains('*') {
writeln!(
printer.stdout(),
r#"{}: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated."#,
"Note".yellow().bold()
)?;
}

// Validate that the environment is consistent.
if strict {
for diagnostic in site_packages.diagnostics()? {
writeln!(
printer.stderr(),
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
}
}
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 required_with_no_extra(dist: &InstalledDist) -> Vec<pep508_rs::Requirement<VerbatimParsedUrl>> {
let metadata = dist.metadata().unwrap();
return metadata
.requires_dist
.into_iter()
.filter(|r| {
r.marker.is_none()
|| !r
.marker
.as_ref()
.unwrap()
.evaluate_optional_environment(None, &metadata.provides_extras[..])
})
.collect::<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,
// Map from package name to the installed distribution.
dist_by_package_name: HashMap<&'a PackageName, &'a InstalledDist>,
// Set of package names that are required by at least one installed distribution.
// It is used to determine the starting nodes when recursing the
// dependency graph.
required_packages: HashSet<PackageName>,
}

impl<'a> DisplayDependencyGraph<'a> {
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
fn new(site_packages: &'a SitePackages) -> DisplayDependencyGraph<'a> {
let mut dist_by_package_name = HashMap::new();
let mut required_packages = HashSet::new();
for site_package in site_packages.iter() {
dist_by_package_name.insert(site_package.name(), site_package);
}
for site_package in site_packages.iter() {
for required in required_with_no_extra(site_package) {
required_packages.insert(required.name.clone());
}
}

Self {
site_packages,
dist_by_package_name,
required_packages,
}
}

// Depth-first traversal of the given distribution and its dependencies.
fn visit(
&self,
installed_dist: &InstalledDist,
visited: &mut HashSet<String>,
path: &mut Vec<String>,
) -> Vec<String> {
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;
}

path.push(package_name.clone());
visited.insert(package_name.clone());
let required_packages = required_with_no_extra(installed_dist);
for (index, required_package) in required_packages.iter().enumerate() {
// Skip if the current package is not one of the installed distributions.
if !self
.dist_by_package_name
.contains_key(&required_package.name)
{
continue;
}

// 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 required_packages.len() - 1 == index {
("└── ", " ")
} else {
("├── ", "│ ")
};

let mut prefixed_lines = Vec::new();
for (visited_index, visited_line) in self
.visit(
self.dist_by_package_name[&required_package.name],
visited,
path,
)
.iter()
.enumerate()
{
prefixed_lines.push(format!(
"{}{}",
if visited_index == 0 {
prefix_top
} else {
prefix_rest
},
visited_line
));
}
lines.extend(prefixed_lines);
}
path.pop();
lines
}

// Depth-first traverse the nodes to render the tree.
// The starting nodes are the ones without incoming edges.
fn render(&self) -> Vec<String> {
let mut visited: HashSet<String> = HashSet::new();
let mut lines: Vec<String> = Vec::new();
for site_package in self.site_packages.iter() {
// If the current package is not required by any other package, start the traversal
// with the current package as the root.
if !self.required_packages.contains(site_package.name()) {
lines.extend(self.visit(site_package, &mut visited, &mut Vec::new()));
}
}
lines
}
}
25 changes: 25 additions & 0 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use anyhow::Result;
use clap::error::{ContextKind, ContextValue};
use clap::{CommandFactory, Parser};
use owo_colors::OwoColorize;
use settings::PipTreeSettings;
use tracing::{debug, instrument};

use cli::{ToolCommand, ToolNamespace, ToolchainCommand, ToolchainNamespace};
Expand Down Expand Up @@ -105,6 +106,12 @@ async fn run() -> Result<ExitStatus> {
ContextValue::String("uv pip show".to_string()),
);
}
"tree" => {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::String("uv pip tree".to_string()),
);
}
_ => {}
}
}
Expand Down Expand Up @@ -530,6 +537,24 @@ async fn run() -> Result<ExitStatus> {
printer,
)
}
Commands::Pip(PipNamespace {
command: PipCommand::Tree(args),
}) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = PipTreeSettings::resolve(args, filesystem);

// Initialize the cache.
let cache = cache.init()?;

commands::pip_tree(
args.shared.strict,
args.shared.python.as_deref(),
args.shared.system,
globals.preview,
&cache,
printer,
)
}
Commands::Pip(PipNamespace {
command: PipCommand::Check(args),
}) => {
Expand Down
Loading

0 comments on commit dd45fce

Please sign in to comment.