diff --git a/crates/pixi_cli/src/global/mod.rs b/crates/pixi_cli/src/global/mod.rs index d7e3d8c890..049a4b63cf 100644 --- a/crates/pixi_cli/src/global/mod.rs +++ b/crates/pixi_cli/src/global/mod.rs @@ -11,6 +11,7 @@ mod list; mod remove; mod shortcut; mod sync; +mod tree; mod uninstall; mod update; mod upgrade; @@ -41,6 +42,8 @@ pub enum Command { #[clap(alias = "ua")] #[command(hide = true)] UpgradeAll(upgrade_all::Args), + #[clap(visible_alias = "t")] + Tree(tree::Args), } /// Subcommand for global package management actions. @@ -53,6 +56,7 @@ pub struct Args { command: Command, } +/// Maps global command enum variants to their function handlers. pub async fn execute(cmd: Args) -> miette::Result<()> { match cmd.command { Command::Add(args) => add::execute(args).await?, @@ -67,6 +71,7 @@ pub async fn execute(cmd: Args) -> miette::Result<()> { Command::Update(args) => update::execute(args).await?, Command::Upgrade(args) => upgrade::execute(args).await?, Command::UpgradeAll(args) => upgrade_all::execute(args).await?, + Command::Tree(args) => tree::execute(args).await?, }; Ok(()) } diff --git a/crates/pixi_cli/src/global/tree.rs b/crates/pixi_cli/src/global/tree.rs new file mode 100644 index 0000000000..812db29829 --- /dev/null +++ b/crates/pixi_cli/src/global/tree.rs @@ -0,0 +1,116 @@ +use crate::shared::tree::{ + Package, PackageSource, build_reverse_dependency_map, print_dependency_tree, + print_inverted_dependency_tree, +}; +use ahash::HashSet; +use clap::Parser; +use console::Color; +use itertools::Itertools; +use miette::Context; +use pixi_consts::consts; +use pixi_global::common::find_package_records; +use pixi_global::{EnvRoot, EnvironmentName, Project}; +use std::collections::HashMap; +use std::str::FromStr; + +/// Show a tree of dependencies for a specific global environment. +#[derive(Debug, Parser)] +#[clap(arg_required_else_help = false, long_about = format!( + "\ + Show a tree of a global environment dependencies\n\ + \n\ + Dependency names highlighted in {} are directly specified in the manifest. + ", + console::style("green").fg(Color::Green).bold(), +))] +pub struct Args { + /// The environment to list packages for. + #[arg(short, long)] + pub environment: String, + + /// List only packages matching a regular expression + #[arg()] + pub regex: Option, + + /// Invert tree and show what depends on a given package in the regex argument + #[arg(short, long, requires = "regex")] + pub invert: bool, +} + +pub async fn execute(args: Args) -> miette::Result<()> { + let project = Project::discover_or_create().await?; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let env_name = EnvironmentName::from_str(args.environment.as_str())?; + let environment = project + .environment(&env_name) + .wrap_err("Environment not found")?; + // Contains all the dependencies under conda-meta + let records = find_package_records( + &EnvRoot::from_env() + .await? + .path() + .join(env_name.as_str()) + .join(consts::CONDA_META_DIR), + ) + .await?; + + let packages: HashMap = records + .iter() + .map(|record| { + let name = record + .repodata_record + .package_record + .name + .as_normalized() + .to_string(); + let package = Package { + name: name.clone(), + version: record + .repodata_record + .package_record + .version + .version() + .to_string(), + dependencies: record + .repodata_record + .package_record + .as_ref() + .depends + .iter() + .filter_map(|dep| { + dep.split([' ', '=']) + .next() + .map(|dep_name| dep_name.to_string()) + }) + .filter(|dep_name| !dep_name.starts_with("__")) // Filter virtual packages + .unique() // A package may be listed with multiple constraints + .collect(), + needed_by: Vec::new(), + source: PackageSource::Conda, // Global environments can only manage Conda packages + }; + (name, package) + }) + .collect(); + + let direct_deps = HashSet::from_iter( + environment + .dependencies + .specs + .iter() + .map(|(name, _)| name.as_normalized().to_string()), + ); + if args.invert { + print_inverted_dependency_tree( + &mut handle, + &build_reverse_dependency_map(&packages), + &direct_deps, + &args.regex, + ) + .wrap_err("Couldn't print the inverted dependency tree")?; + } else { + print_dependency_tree(&mut handle, &packages, &direct_deps, &args.regex) + .wrap_err("Couldn't print the dependency tree")?; + } + Ok(()) +} diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index c69e693d16..eca4df3082 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -1,3 +1,11 @@ +//! # Pixi CLI +//! +//! This module implements the CLI interface of Pixi. +//! +//! ## Structure +//! +//! - The [`Command`] enum defines the top-level commands available. +//! - The [`execute_command`] function matches on [`Command`] and calls the corresponding logic. #![deny(clippy::dbg_macro, clippy::unwrap_used)] use clap::builder::styling::{AnsiColor, Color, Style}; @@ -32,6 +40,7 @@ pub mod remove; pub mod run; pub mod search; pub mod self_update; +mod shared; pub mod shell; pub mod shell_hook; pub mod task; @@ -387,7 +396,7 @@ fn setup_logging(args: &Args, use_colors: bool) -> miette::Result<()> { Ok(()) } -/// Execute the actual command +/// Maps command enum variants to their actual function handlers. pub async fn execute_command( command: Command, global_options: &GlobalOptions, diff --git a/crates/pixi_cli/src/shared/mod.rs b/crates/pixi_cli/src/shared/mod.rs new file mode 100644 index 0000000000..c84057ec7c --- /dev/null +++ b/crates/pixi_cli/src/shared/mod.rs @@ -0,0 +1,3 @@ +//! This file contains utilities shared by the implementation of command logic + +pub mod tree; diff --git a/crates/pixi_cli/src/shared/tree.rs b/crates/pixi_cli/src/shared/tree.rs new file mode 100644 index 0000000000..b72609f72a --- /dev/null +++ b/crates/pixi_cli/src/shared/tree.rs @@ -0,0 +1,434 @@ +//! This file contains the logic to pretty-print dependency lists in a tree-like structure. + +use ahash::{HashSet, HashSetExt}; +use console::Color; +use miette::{Context, IntoDiagnostic}; +use regex::Regex; +use std::collections::HashMap; +use std::io::{StdoutLock, Write}; + +/// Defines the source of a package. Global packages can only have Conda dependencies. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum PackageSource { + Conda, + Pypi, +} + +/// Represents a view of a Package with only the fields required for the tree visualization. +#[derive(Debug, Clone)] +pub struct Package { + pub name: String, + pub version: String, + pub dependencies: Vec, + pub needed_by: Vec, + pub source: PackageSource, +} + +struct Symbols { + down: &'static str, + tee: &'static str, + ell: &'static str, + empty: &'static str, +} + +static UTF8_SYMBOLS: Symbols = Symbols { + down: "│ ", + tee: "├──", + ell: "└──", + empty: " ", +}; + +/// Prints a hierarchical tree of dependencies to the provided output handle. +/// Direct dependencies are shown at the top level, with their transitive dependencies indented below. +/// +/// If a regex pattern is provided, only dependencies matching the pattern will be displayed. +/// When no direct dependencies match the pattern but transitive ones do, it shows matching transitive dependencies instead. +/// +/// # Arguments +/// +/// * `handle` - A mutable lock on stdout for writing the output +/// * `dep_map` - A map of package names to their dependency information +/// * `direct_deps` - A set of package names that should be highlighted. +/// * `regex` - Optional regex pattern to filter which dependencies to display +/// +/// # Returns +/// +/// Returns `Ok(())` if the tree was printed successfully, or an error if the regex was invalid or no matches were found +pub fn print_dependency_tree( + handle: &mut StdoutLock, + dep_map: &HashMap, + direct_deps: &HashSet, + regex: &Option, +) -> miette::Result<()> { + let mut filtered_deps = direct_deps.clone(); + let mut transitive = false; + + if let Some(regex) = regex { + let regex = Regex::new(regex) + .into_diagnostic() + .wrap_err("Invalid regular expression")?; + + filtered_deps.retain(|p| regex.is_match(p)); + + if filtered_deps.is_empty() { + filtered_deps = dep_map.keys().cloned().collect(); + filtered_deps.retain(|p| regex.is_match(p)); + + if filtered_deps.is_empty() { + return Err(miette::miette!( + "No dependencies matched the given regular expression" + )); + } + + tracing::info!( + "No top-level dependencies matched the regular expression, showing matching transitive dependencies" + ); + transitive = true; + } + } + + let mut visited_pkgs = HashSet::new(); + let direct_dep_count = filtered_deps.len(); + + for (index, pkg_name) in filtered_deps.iter().enumerate() { + if !visited_pkgs.insert(pkg_name.clone()) { + continue; + } + + let last = index == direct_dep_count - 1; + let mut symbol = ""; + if !transitive { + symbol = if last { + UTF8_SYMBOLS.ell + } else { + UTF8_SYMBOLS.tee + }; + } + if let Some(pkg) = dep_map.get(pkg_name) { + print_package( + handle, + &format!("{symbol} "), + pkg, + direct_deps.contains(&pkg.name), + false, + )?; + + let prefix = if last { + UTF8_SYMBOLS.empty + } else { + UTF8_SYMBOLS.down + }; + print_dependency_node( + handle, + pkg, + format!("{} ", prefix), + dep_map, + &mut visited_pkgs, + direct_deps, + )?; + } + } + Ok(()) +} + +/// Prints a dependency tree node for a given package and its dependencies. +/// +/// This function traverses the dependencies of a given package (`pkg`) and prints +/// them in a structured, tree-like format. If a dependency has already been +/// visited, it prevents infinite recursion by skipping that dependency. +/// +/// # Arguments +/// +/// * `handle` - A mutable reference to the standard output lock used for writing the output. +/// * `pkg` - A reference to the package whose dependencies are to be printed. +/// * `prefix` - A string used as a prefix for formatting tree branches. +/// * `dep_map` - A map of dependency names to their corresponding `Package` data. +/// * `visited_pkgs` - A mutable set of package names that have already been visited in the tree. +/// * `direct_deps` - A set of package names that should be highlighted. +/// +/// # Returns +/// +/// Returns `Ok(())` if the dependency tree for the given package is successfully printed, +/// or an error of type `miette::Result` if any operation fails during the process. +/// +/// # Examples +/// +/// Given a package with dependencies structured as a tree, calling this function +/// generates a visual tree-like output. For instance: +/// +/// ```text +/// root +/// ├── dep1 +/// │ └── subdep1 +/// └── dep2 +/// ``` +/// +/// Here `dep1` and `dep2` are direct dependencies of the `root`, and `subdep1` is a sub-dependency. +/// +/// # Errors +/// +/// This function can return an error if writing to the output stream fails. +fn print_dependency_node( + handle: &mut StdoutLock, + package: &Package, + prefix: String, + dep_map: &HashMap, + visited_pkgs: &mut HashSet, + direct_deps: &HashSet, +) -> miette::Result<()> { + let dep_count = package.dependencies.len(); + for (index, dep_name) in package.dependencies.iter().enumerate() { + let last = index == dep_count - 1; + let symbol = if last { + UTF8_SYMBOLS.ell + } else { + UTF8_SYMBOLS.tee + }; + + if let Some(dep) = dep_map.get(dep_name) { + let visited = !visited_pkgs.insert(dep.name.clone()); + + print_package( + handle, + &format!("{prefix}{symbol} "), + dep, + direct_deps.contains(&dep.name), + visited, + )?; + + if visited { + continue; + } + + let new_prefix = if last { + format!("{}{} ", prefix, UTF8_SYMBOLS.empty) + } else { + format!("{}{} ", prefix, UTF8_SYMBOLS.down) + }; + print_dependency_node(handle, dep, new_prefix, dep_map, visited_pkgs, direct_deps)?; + } else { + let visited = !visited_pkgs.insert(dep_name.clone()); + + print_package( + handle, + &format!("{prefix}{symbol} "), + &Package { + name: dep_name.to_owned(), + version: String::from(""), + dependencies: Vec::new(), + needed_by: Vec::new(), + source: PackageSource::Conda, + }, + false, + visited, + )?; + } + } + Ok(()) +} + +/// Prints information about a package to the given output handle. +/// +/// # Arguments +/// +/// * `handle` - A mutable reference to the standard output lock used for writing the output. +/// * `prefix` - A string used as a prefix for formatting tree branches. +/// * `package` - A reference to the `Package` struct containing information about the package (e.g., name, version, source). +/// * `direct` - A boolean indicating if the package is a direct dependency. If `true`, the package name is formatted in bold green text. +/// * `visited` - A boolean indicating if the package has already been visited. If `true`, the printed output will include "(*)". +/// +/// # Errors +/// This function can return an error if writing to the output stream fails. +pub fn print_package( + handle: &mut StdoutLock, + prefix: &str, + package: &Package, + direct: bool, + visited: bool, +) -> miette::Result<()> { + writeln!( + handle, + "{}{} {} {}", + prefix, + if direct { + console::style(&package.name).fg(Color::Green).bold() + } else { + console::style(&package.name) + }, + match package.source { + PackageSource::Conda => console::style(&package.version).fg(Color::Yellow), + PackageSource::Pypi => console::style(&package.version).fg(Color::Blue), + }, + if visited { "(*)" } else { "" } + ) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::BrokenPipe { + // Exit gracefully + std::process::exit(0); + } else { + e + } + }) + .into_diagnostic() + .wrap_err("Failed to write package information") +} + +/// Prints an inverted hierarchical tree of dependencies to the provided output handle. +/// Dependencies with a name matching the passed regex are shown at the top level, with the packages that require them indented below. +/// +/// # Arguments +/// +/// * `handle` - A mutable lock on stdout for writing the output +/// * `inverted_dep_map` - Inverted map of packages with the `needed_by` field filled via [`build_reverse_dependency_map`] +/// * `direct_deps` - A set of package names that should be highlighted. +/// * `regex` - Regex pattern to filter which dependencies to display +/// +/// # Returns +/// +/// Returns `Ok(())` if the tree was printed successfully, or an error if the regex was invalid or no matches were found +pub fn print_inverted_dependency_tree( + handle: &mut StdoutLock, + inverted_dep_map: &HashMap, + direct_deps: &HashSet, + regex: &Option, +) -> miette::Result<()> { + let regex = regex + .as_ref() + .ok_or_else(|| miette::miette!("The -i flag requires a package name."))?; + + let regex = Regex::new(regex) + .into_diagnostic() + .wrap_err("Invalid regular expression")?; + + let root_pkg_names: Vec<_> = inverted_dep_map + .keys() + .filter(|p| regex.is_match(p)) + .collect(); + + if root_pkg_names.is_empty() { + return Err(miette::miette!( + "Nothing depends on the given regular expression" + )); + } + + let mut visited_pkgs = HashSet::new(); + for pkg_name in root_pkg_names { + if let Some(pkg) = inverted_dep_map.get(pkg_name) { + let visited = !visited_pkgs.insert(pkg_name.clone()); + print_package(handle, "\n", pkg, direct_deps.contains(&pkg.name), visited)?; + + if !visited { + print_inverted_node( + handle, + pkg, + String::from(""), + inverted_dep_map, + direct_deps, + &mut visited_pkgs, + )?; + } + } + } + + Ok(()) +} + +/// Prints a dependency tree node for a given package and its dependents. +/// +/// This function traverses the dependents of a given package (`pkg`) and prints +/// them in a structured, tree-like format. If a dependent has already been +/// visited, it prevents infinite recursion by skipping that dependent. +/// +/// # Arguments +/// +/// * `handle` - A mutable reference to the standard output lock used for writing the output. +/// * `package` - A reference to the package whose dependents are to be printed. +/// * `prefix` - A string used as a prefix for formatting tree branches. +/// * `inverted_dep_map` - A map of dependency names to their corresponding `Package` data. +/// * `visited_pkgs` - A mutable set of package names that have already been visited in the tree. +/// * `direct_deps` - A set of package names that should be highlighted. +/// +/// # Returns +/// +/// Returns `Ok(())` if the dependent tree for the given package is successfully printed, +/// or an error of type `miette::Result` if any operation fails during the process. +/// +/// # Errors +/// +/// This function can return an error if writing to the output stream fails. +fn print_inverted_node( + handle: &mut StdoutLock, + package: &Package, + prefix: String, + inverted_dep_map: &HashMap, + direct_deps: &HashSet, + visited_pkgs: &mut HashSet, +) -> miette::Result<()> { + let needed_count = package.needed_by.len(); + for (index, needed_name) in package.needed_by.iter().enumerate() { + let last = index == needed_count - 1; + let symbol = if last { + UTF8_SYMBOLS.ell + } else { + UTF8_SYMBOLS.tee + }; + + if let Some(needed_pkg) = inverted_dep_map.get(needed_name) { + let visited = !visited_pkgs.insert(needed_pkg.name.clone()); + print_package( + handle, + &format!("{prefix}{symbol} "), + needed_pkg, + direct_deps.contains(&needed_pkg.name), + visited, + )?; + + if !visited { + let new_prefix = if last { + format!("{}{} ", prefix, UTF8_SYMBOLS.empty) + } else { + format!("{}{} ", prefix, UTF8_SYMBOLS.down) + }; + + print_inverted_node( + handle, + needed_pkg, + new_prefix, + inverted_dep_map, + direct_deps, + visited_pkgs, + )?; + } + } + } + Ok(()) +} + +/// Creates an inverted dependency graph by populating each package's `needed_by` field with the packages +/// that directly depend on it. Used to support the generation of reverse dependency trees. +/// +/// This function is used to support generation of "reverse dependency" trees that show what packages depend +/// on a given package rather than what a package depends on. +/// +/// # Arguments +/// +/// * `dep_map` - A map of package names to `Package` objects representing the dependency graph +/// +/// # Returns +/// +/// A new dependency graph with `needed_by` fields populated to show reverse dependencies +pub fn build_reverse_dependency_map( + dep_map: &HashMap, +) -> HashMap { + let mut inverted_deps = dep_map.clone(); + + for pkg in dep_map.values() { + for dep in pkg.dependencies.iter() { + if let Some(idep) = inverted_deps.get_mut(dep) { + idep.needed_by.push(pkg.name.clone()); + } + } + } + + inverted_deps +} diff --git a/crates/pixi_cli/src/tree.rs b/crates/pixi_cli/src/tree.rs index 6620a2c933..7a2ed0acce 100644 --- a/crates/pixi_cli/src/tree.rs +++ b/crates/pixi_cli/src/tree.rs @@ -1,21 +1,20 @@ -use std::{ - collections::HashMap, - io::{StdoutLock, Write}, +use crate::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; +use crate::shared::tree::{ + Package, PackageSource, build_reverse_dependency_map, print_dependency_tree, + print_inverted_dependency_tree, }; - -use ahash::{HashSet, HashSetExt}; +use ahash::HashSet; use clap::Parser; use console::Color; use fancy_display::FancyDisplay; use itertools::Itertools; -use miette::{IntoDiagnostic, WrapErr}; -use pixi_core::{WorkspaceLocator, lock_file::UpdateLockFileOptions, workspace::Environment}; +use miette::WrapErr; +use pixi_core::workspace::Environment; +use pixi_core::{WorkspaceLocator, lock_file::UpdateLockFileOptions}; use pixi_manifest::FeaturesExt; use rattler_conda_types::Platform; use rattler_lock::LockedPackageRef; -use regex::Regex; - -use crate::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; +use std::collections::HashMap; /// Show a tree of workspace dependencies #[derive(Debug, Parser)] @@ -58,20 +57,13 @@ pub struct Args { pub invert: bool, } -struct Symbols { - down: &'static str, - tee: &'static str, - ell: &'static str, - empty: &'static str, +/// Simplified package information extracted from the lock file +pub struct PackageInfo { + name: String, + dependencies: Vec, + source: PackageSource, } -static UTF8_SYMBOLS: Symbols = Symbols { - down: "│ ", - tee: "├──", - ell: "└──", - empty: " ", -}; - pub async fn execute(args: Args) -> miette::Result<()> { let workspace = WorkspaceLocator::for_cli() .with_search_start(args.workspace_config.workspace_locator_start()) @@ -111,7 +103,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { if args.invert { print_inverted_dependency_tree( &mut handle, - &invert_dep_map(&dep_map), + &build_reverse_dependency_map(&dep_map), &direct_deps, &args.regex, ) @@ -123,367 +115,13 @@ pub async fn execute(args: Args) -> miette::Result<()> { Ok(()) } -/// Filter and print an inverted dependency tree -fn print_inverted_dependency_tree( - handle: &mut StdoutLock, - inverted_dep_map: &HashMap, - direct_deps: &HashSet, - regex: &Option, -) -> miette::Result<()> { - let regex = regex - .as_ref() - .ok_or_else(|| miette::miette!("The -i flag requires a package name."))?; - - let regex = Regex::new(regex) - .into_diagnostic() - .wrap_err("Invalid regular expression")?; - - let root_pkg_names: Vec<_> = inverted_dep_map - .keys() - .filter(|p| regex.is_match(p)) - .collect(); - - if root_pkg_names.is_empty() { - return Err(miette::miette!( - "Nothing depends on the given regular expression" - )); - } - - let mut visited_pkgs = HashSet::new(); - for pkg_name in root_pkg_names { - if let Some(pkg) = inverted_dep_map.get(pkg_name) { - let visited = !visited_pkgs.insert(pkg_name.clone()); - print_package(handle, "\n", pkg, direct_deps.contains(&pkg.name), visited)?; - - if !visited { - print_inverted_leaf( - handle, - pkg, - String::from(""), - inverted_dep_map, - direct_deps, - &mut visited_pkgs, - )?; - } - } - } - - Ok(()) -} - -/// Recursively print inverted dependency tree leaf nodes -fn print_inverted_leaf( - handle: &mut StdoutLock, - pkg: &Package, - prefix: String, - inverted_dep_map: &HashMap, - direct_deps: &HashSet, - visited_pkgs: &mut HashSet, -) -> miette::Result<()> { - let needed_count = pkg.needed_by.len(); - for (index, needed_name) in pkg.needed_by.iter().enumerate() { - let last = index == needed_count - 1; - let symbol = if last { - UTF8_SYMBOLS.ell - } else { - UTF8_SYMBOLS.tee - }; - - if let Some(needed_pkg) = inverted_dep_map.get(needed_name) { - let visited = !visited_pkgs.insert(needed_pkg.name.clone()); - print_package( - handle, - &format!("{prefix}{symbol} "), - needed_pkg, - direct_deps.contains(&needed_pkg.name), - visited, - )?; - - if !visited { - let new_prefix = if last { - format!("{}{} ", prefix, UTF8_SYMBOLS.empty) - } else { - format!("{}{} ", prefix, UTF8_SYMBOLS.down) - }; - - print_inverted_leaf( - handle, - needed_pkg, - new_prefix, - inverted_dep_map, - direct_deps, - visited_pkgs, - )?; - } - } - } - Ok(()) -} - -/// Print a transitive dependency tree -fn print_transitive_dependency_tree( - handle: &mut StdoutLock, - dep_map: &HashMap, - direct_deps: &HashSet, - filtered_keys: Vec, -) -> miette::Result<()> { - let mut visited_pkgs = HashSet::new(); - - for pkg_name in filtered_keys.iter() { - if !visited_pkgs.insert(pkg_name.clone()) { - continue; - } - - if let Some(pkg) = dep_map.get(pkg_name) { - print_package(handle, "\n", pkg, direct_deps.contains(&pkg.name), false)?; - - print_dependency_leaf( - handle, - pkg, - "".to_string(), - dep_map, - &mut visited_pkgs, - direct_deps, - )?; - } - } - Ok(()) -} - -/// Filter and print a top-down dependency tree -fn print_dependency_tree( - handle: &mut StdoutLock, - dep_map: &HashMap, - direct_deps: &HashSet, - regex: &Option, -) -> miette::Result<()> { - let mut filtered_deps = direct_deps.clone(); - - if let Some(regex) = regex { - let regex = Regex::new(regex) - .into_diagnostic() - .wrap_err("Invalid regular expression")?; - - filtered_deps.retain(|p| regex.is_match(p)); - - if filtered_deps.is_empty() { - let mut filtered_keys = dep_map.keys().cloned().collect_vec(); - filtered_keys.retain(|p| regex.is_match(p)); - - if filtered_keys.is_empty() { - return Err(miette::miette!( - "No dependencies matched the given regular expression" - )); - } - - tracing::info!( - "No top-level dependencies matched the regular expression, showing matching transitive dependencies" - ); - - return print_transitive_dependency_tree(handle, dep_map, direct_deps, filtered_keys); - } - } - - let mut visited_pkgs = HashSet::new(); - let direct_dep_count = filtered_deps.len(); - - for (index, pkg_name) in filtered_deps.iter().enumerate() { - if !visited_pkgs.insert(pkg_name.clone()) { - continue; - } - - let last = index == direct_dep_count - 1; - let symbol = if last { - UTF8_SYMBOLS.ell - } else { - UTF8_SYMBOLS.tee - }; - if let Some(pkg) = dep_map.get(pkg_name) { - print_package( - handle, - &format!("{symbol} "), - pkg, - direct_deps.contains(&pkg.name), - false, - )?; - - let prefix = if last { - UTF8_SYMBOLS.empty - } else { - UTF8_SYMBOLS.down - }; - print_dependency_leaf( - handle, - pkg, - format!("{} ", prefix), - dep_map, - &mut visited_pkgs, - direct_deps, - )?; - } - } - Ok(()) -} - -/// Recursively print top-down dependency tree nodes -fn print_dependency_leaf( - handle: &mut StdoutLock, - pkg: &Package, - prefix: String, - dep_map: &HashMap, - visited_pkgs: &mut HashSet, - direct_deps: &HashSet, -) -> miette::Result<()> { - let dep_count = pkg.dependencies.len(); - for (index, dep_name) in pkg.dependencies.iter().enumerate() { - let last = index == dep_count - 1; - let symbol = if last { - UTF8_SYMBOLS.ell - } else { - UTF8_SYMBOLS.tee - }; - - if let Some(dep) = dep_map.get(dep_name) { - let visited = !visited_pkgs.insert(dep.name.clone()); - - print_package( - handle, - &format!("{prefix}{symbol} "), - dep, - direct_deps.contains(&dep.name), - visited, - )?; - - if visited { - continue; - } - - let new_prefix = if last { - format!("{}{} ", prefix, UTF8_SYMBOLS.empty) - } else { - format!("{}{} ", prefix, UTF8_SYMBOLS.down) - }; - print_dependency_leaf(handle, dep, new_prefix, dep_map, visited_pkgs, direct_deps)?; - } else { - let visited = !visited_pkgs.insert(dep_name.clone()); - - print_package( - handle, - &format!("{prefix}{symbol} "), - &Package { - name: dep_name.to_owned(), - version: String::from(""), - dependencies: Vec::new(), - needed_by: Vec::new(), - source: PackageSource::Conda, - }, - false, - visited, - )?; - } - } - Ok(()) -} - -/// Print package and style by attributes -fn print_package( - handle: &mut StdoutLock, - prefix: &str, - package: &Package, - direct: bool, - visited: bool, -) -> miette::Result<()> { - writeln!( - handle, - "{}{} {} {}", - prefix, - if direct { - console::style(&package.name).fg(Color::Green).bold() - } else { - console::style(&package.name) - }, - match package.source { - PackageSource::Conda => console::style(&package.version).fg(Color::Yellow), - PackageSource::Pypi => console::style(&package.version).fg(Color::Blue), - }, - if visited { "(*)" } else { "" } - ) - .map_err(|e| { - if e.kind() == std::io::ErrorKind::BrokenPipe { - // Exit gracefully - std::process::exit(0); - } else { - e - } - }) - .into_diagnostic() - .wrap_err("Failed to write package information") -} - -/// Extract the direct Conda and PyPI dependencies from the environment -fn direct_dependencies( - environment: &Environment<'_>, - platform: &Platform, - dep_map: &HashMap, -) -> HashSet { - let mut project_dependency_names = environment - .combined_dependencies(Some(*platform)) - .names() - .filter(|p| { - if let Some(value) = dep_map.get(p.as_source()) { - value.source == PackageSource::Conda - } else { - false - } - }) - .map(|p| p.as_source().to_string()) - .collect::>(); - - project_dependency_names.extend( - environment - .pypi_dependencies(Some(*platform)) - .into_iter() - .filter(|(name, _)| { - if let Some(value) = dep_map.get(&*name.as_normalized().as_dist_info_name()) { - value.source == PackageSource::Pypi - } else { - false - } - }) - .map(|(name, _)| name.as_normalized().as_dist_info_name().into_owned()), - ); - project_dependency_names -} - -#[derive(Debug, Copy, Clone, PartialEq)] -enum PackageSource { - Conda, - Pypi, -} - -#[derive(Debug, Clone)] -struct Package { - name: String, - version: String, - dependencies: Vec, - needed_by: Vec, - source: PackageSource, -} - -/// Simplified package information extracted from the lock file -struct PackageInfo { - name: String, - dependencies: Vec, - source: PackageSource, -} - -/// Helper function to extract package information -fn extract_package_info(package: rattler_lock::LockedPackageRef<'_>) -> Option { +/// Helper function to extract package information from a package reference obtained from a lock file. +pub(crate) fn extract_package_info( + package: rattler_lock::LockedPackageRef<'_>, +) -> Option { if let Some(conda_package) = package.as_conda() { - // Extract name let name = conda_package.record().name.as_normalized().to_string(); - // Extract dependencies let dependencies: Vec = conda_package .record() .depends @@ -500,10 +138,7 @@ fn extract_package_info(package: rattler_lock::LockedPackageRef<'_>) -> Option

) -> Option

], -) -> HashMap { +/// Generate a map of dependencies from a list of locked packages. +pub fn generate_dependency_map(locked_deps: &[LockedPackageRef<'_>]) -> HashMap { let mut package_dependencies_map = HashMap::new(); for &package in locked_deps { @@ -550,7 +183,12 @@ fn generate_dependency_map( } LockedPackageRef::Pypi(pypi_data, _) => pypi_data.version.to_string(), }, - dependencies: package_info.dependencies.into_iter().unique().collect(), + dependencies: package_info + .dependencies + .into_iter() + .filter(|pkg| !pkg.starts_with("__")) + .unique() + .collect(), needed_by: Vec::new(), source: package_info.source, }, @@ -560,17 +198,37 @@ fn generate_dependency_map( package_dependencies_map } -/// Given a map of dependencies, invert it -fn invert_dep_map(dep_map: &HashMap) -> HashMap { - let mut inverted_deps = dep_map.clone(); - - for pkg in dep_map.values() { - for dep in pkg.dependencies.iter() { - if let Some(idep) = inverted_deps.get_mut(dep) { - idep.needed_by.push(pkg.name.clone()); +/// Extract the direct Conda and PyPI dependencies from the environment +pub fn direct_dependencies( + environment: &Environment<'_>, + platform: &Platform, + dep_map: &HashMap, +) -> HashSet { + let mut project_dependency_names = environment + .combined_dependencies(Some(*platform)) + .names() + .filter(|p| { + if let Some(value) = dep_map.get(p.as_source()) { + value.source == PackageSource::Conda + } else { + false } - } - } + }) + .map(|p| p.as_source().to_string()) + .collect::>(); - inverted_deps + project_dependency_names.extend( + environment + .pypi_dependencies(Some(*platform)) + .into_iter() + .filter(|(name, _)| { + if let Some(value) = dep_map.get(&*name.as_normalized().as_dist_info_name()) { + value.source == PackageSource::Pypi + } else { + false + } + }) + .map(|(name, _)| name.as_normalized().as_dist_info_name().into_owned()), + ); + project_dependency_names } diff --git a/docs/reference/cli/pixi/global.md b/docs/reference/cli/pixi/global.md index ff8a6e6730..68b5036d94 100644 --- a/docs/reference/cli/pixi/global.md +++ b/docs/reference/cli/pixi/global.md @@ -24,6 +24,7 @@ pixi global | [`expose`](global/expose.md) | Interact with the exposure of binaries in the global environment | | [`shortcut`](global/shortcut.md) | Interact with the shortcuts on your machine | | [`update`](global/update.md) | Updates environments in the global environment | +| [`tree`](global/tree.md) | Show a tree of dependencies for a specific global environment | ## Description diff --git a/docs/reference/cli/pixi/global/tree.md b/docs/reference/cli/pixi/global/tree.md new file mode 100644 index 0000000000..586e646326 --- /dev/null +++ b/docs/reference/cli/pixi/global/tree.md @@ -0,0 +1,31 @@ + +# [pixi](../../pixi.md) [global](../global.md) tree + +## About +Show a tree of dependencies for a specific global environment + +--8<-- "docs/reference/cli/pixi/global/tree_extender:description" + +## Usage +``` +pixi global tree [OPTIONS] --environment [REGEX] +``` + +## Arguments +- `` +: List only packages matching a regular expression + +## Options +- `--environment (-e) ` +: The environment to list packages for +
**required**: `true` +- `--invert (-i)` +: Invert tree and show what depends on a given package in the regex argument + +## Description +Show a tree of a global environment dependencies + +Dependency names highlighted in green are directly specified in the manifest. + + +--8<-- "docs/reference/cli/pixi/global/tree_extender:example" diff --git a/tests/integration_python/pixi_global/test_global.py b/tests/integration_python/pixi_global/test_global.py index 43b7411374..84d8cc4de0 100644 --- a/tests/integration_python/pixi_global/test_global.py +++ b/tests/integration_python/pixi_global/test_global.py @@ -2039,3 +2039,113 @@ def test_update_remove_old_env( ) assert not dummy_a.is_file() assert manifest.read_text() == original_toml + + +def test_tree(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: + env = {"PIXI_HOME": str(tmp_path)} + manifests = tmp_path.joinpath("manifests") + manifests.mkdir() + + # Install dummy-a and dummy-b from dummy-channel-1 + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "dummy-b==0.1.0", + "dummy-a==0.1.0", + ], + env=env, + ) + + # Verify tree with dummy-b environment + verify_cli_command( + [pixi, "global", "tree", "--environment", "dummy-b"], + env=env, + stdout_contains=["dummy-b", "0.1.0"], + ) + + # Verify tree with dummy-a environment + verify_cli_command( + [pixi, "global", "tree", "--environment", "dummy-a"], + env=env, + stdout_contains=["dummy-a", "0.1.0"], + ) + + +def test_tree_with_filter(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: + env = {"PIXI_HOME": str(tmp_path)} + manifests = tmp_path.joinpath("manifests") + manifests.mkdir() + + # Install dummy-a and dummy-b from dummy-channel-1 + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "--environment", + "dummy", + "dummy-b==0.1.0", + "dummy-a==0.1.0", + ], + env=env, + ) + + # Verify tree with regex filter for dummy environment + verify_cli_command( + [pixi, "global", "tree", "--environment", "dummy", "dummy-a"], + env=env, + stdout_contains=["dummy-a", "0.1.0"], + stdout_excludes=["dummy-b"], + ) + + # Verify tree with regex filter for dummy-b + verify_cli_command( + [pixi, "global", "tree", "--environment", "dummy", "dummy-b"], + env=env, + stdout_contains=["dummy-b", "0.1.0"], + stdout_excludes=["dummy-a"], + ) + + +def test_tree_nonexistent_environment(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: + env = {"PIXI_HOME": str(tmp_path)} + + # Try to show tree for non-existent environment + verify_cli_command( + [pixi, "global", "tree", "--environment", "nonexistent"], + ExitCode.FAILURE, + env=env, + stderr_contains="Environment not found", + ) + + +def test_tree_invert(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: + env = {"PIXI_HOME": str(tmp_path)} + manifests = tmp_path.joinpath("manifests") + manifests.mkdir() + + # Install dummy-a which has dummy-c as a dependency + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "dummy-a==0.1.0", + ], + env=env, + ) + + # Verify inverted tree showing what depends on dummy-c + verify_cli_command( + [pixi, "global", "tree", "--environment", "dummy-a", "--invert", "dummy-c"], + env=env, + stdout_contains=["dummy-c", "dummy-a 0.1.0"], + )