diff --git a/Cargo.lock b/Cargo.lock index b7d5b66e1..eecebd9f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "fs-err", "futures", "ignore", + "indexmap", "indicatif", "indoc", "insta", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5ff66e44e..72fb38179 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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, - - /// Display only the specified packages. - #[arg(long)] - pub package: Vec, - - /// 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, } +#[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, +} + #[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, + + /// Display only the specified packages. + #[arg(long)] + pub package: Vec, + + /// 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, +} diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index cd230495b..1dabc0b29 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -324,6 +324,11 @@ impl Lock { &self.distributions } + /// Returns the owned [`Distribution`] entries in this lock. + pub fn into_distributions(self) -> Vec { + 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() diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 365122272..2070e6361 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -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 } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index a10541492..ba000c02f 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -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; diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index 95ac77921..5d52aaac9 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -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> + '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>, /// 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>, } -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, package: Vec, no_dedupe: bool, invert: bool, - markers: &'env MarkerEnvironment, - ) -> Result> { + markers: &MarkerEnvironment, + packages: IndexMap>, + ) -> 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>, path: &mut Vec<&'env PackageName>, - ) -> Result> { + ) -> Vec { // 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::>(); @@ -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> { + pub(crate) fn render(&self) -> Vec { let mut visited: FxHashMap<&PackageName, Vec> = FxHashMap::default(); let mut path: Vec<&PackageName> = Vec::new(); let mut lines: Vec = 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 } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index c3047c891..61e7ddbeb 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -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 { diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs new file mode 100644 index 000000000..0fa7ec2f3 --- /dev/null +++ b/crates/uv/src/commands/project/tree.rs @@ -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, + package: Vec, + no_dedupe: bool, + invert: bool, + python: Option, + settings: ResolverSettings, + python_preference: PythonPreference, + python_fetch: PythonFetch, + preview: PreviewMode, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + cache: &Cache, + printer: Printer, +) -> Result { + 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) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 153cfde39..4385b9b04 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -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 + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 50ea61617..04ba352a6 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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, + pub(crate) package: Vec, + pub(crate) no_dedupe: bool, + pub(crate) invert: bool, + pub(crate) python: Option, + pub(crate) resolver: ResolverSettings, +} + +impl TreeSettings { + /// Resolve the [`TreeSettings`] from the CLI and workspace configuration. + pub(crate) fn resolve(args: TreeArgs, filesystem: Option) -> 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) -> 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 { diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 06661bf74..7a8825779 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -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()); diff --git a/crates/uv/tests/tree.rs b/crates/uv/tests/tree.rs new file mode 100644 index 000000000..d6a52a47c --- /dev/null +++ b/crates/uv/tests/tree.rs @@ -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(()) +}