From a7bf03b579261ab752be9df56cf2dc55a28105cb Mon Sep 17 00:00:00 2001 From: Carbonhell Date: Sun, 24 Aug 2025 19:14:32 +0200 Subject: [PATCH 1/5] feat(cli): add pixi global tree command tweak(cli): filter virtual packages from pixi tree command --- crates/pixi_core/src/global/common.rs | 2 +- crates/pixi_core/src/lib.rs | 1 + crates/pixi_core/src/shared/mod.rs | 3 + crates/pixi_core/src/shared/tree.rs | 437 ++++++++++++++++++++++++ src/cli/global/mod.rs | 5 + src/cli/global/tree.rs | 111 +++++++ src/cli/mod.rs | 14 +- src/cli/tree.rs | 456 ++++---------------------- 8 files changed, 624 insertions(+), 405 deletions(-) create mode 100644 crates/pixi_core/src/shared/mod.rs create mode 100644 crates/pixi_core/src/shared/tree.rs create mode 100644 src/cli/global/tree.rs diff --git a/crates/pixi_core/src/global/common.rs b/crates/pixi_core/src/global/common.rs index 93ba8e04f4..8ea8c03893 100644 --- a/crates/pixi_core/src/global/common.rs +++ b/crates/pixi_core/src/global/common.rs @@ -179,7 +179,7 @@ pub(crate) fn is_binary(file_path: impl AsRef) -> miette::Result { } /// Finds the package record from the `conda-meta` directory. -pub(crate) async fn find_package_records(conda_meta: &Path) -> miette::Result> { +pub async fn find_package_records(conda_meta: &Path) -> miette::Result> { let read_dir = tokio_fs::read_dir(conda_meta).await; let mut records = Vec::new(); diff --git a/crates/pixi_core/src/lib.rs b/crates/pixi_core/src/lib.rs index d42b19150d..f16d9f64bd 100644 --- a/crates/pixi_core/src/lib.rs +++ b/crates/pixi_core/src/lib.rs @@ -12,6 +12,7 @@ pub mod task; pub mod workspace; pub mod signals; +pub mod shared; pub use lock_file::UpdateLockFileOptions; pub use workspace::{DependencyType, Workspace, WorkspaceLocator}; diff --git a/crates/pixi_core/src/shared/mod.rs b/crates/pixi_core/src/shared/mod.rs new file mode 100644 index 0000000000..f73d4a42df --- /dev/null +++ b/crates/pixi_core/src/shared/mod.rs @@ -0,0 +1,3 @@ +//! This file contains utilities shared by the implementation of command logic + +pub mod tree; \ No newline at end of file diff --git a/crates/pixi_core/src/shared/tree.rs b/crates/pixi_core/src/shared/tree.rs new file mode 100644 index 0000000000..e4178088f2 --- /dev/null +++ b/crates/pixi_core/src/shared/tree.rs @@ -0,0 +1,437 @@ +//! This file contains the logic to pretty-print dependency lists in a tree-like structure. + +use std::collections::HashMap; +use std::io::{StdoutLock, Write}; +use ahash::{HashSet, HashSetExt}; +use console::Color; +use miette::{Context, IntoDiagnostic}; +use regex::Regex; + +/// 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 +} \ No newline at end of file diff --git a/src/cli/global/mod.rs b/src/cli/global/mod.rs index 956868f0a8..a0bc1f97f8 100644 --- a/src/cli/global/mod.rs +++ b/src/cli/global/mod.rs @@ -15,6 +15,7 @@ mod uninstall; mod update; mod upgrade; mod upgrade_all; +mod tree; #[derive(Debug, Parser)] pub enum Command { @@ -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/src/cli/global/tree.rs b/src/cli/global/tree.rs new file mode 100644 index 0000000000..ba81aef81f --- /dev/null +++ b/src/cli/global/tree.rs @@ -0,0 +1,111 @@ +use ahash::HashSet; +use clap::Parser; +use console::Color; +use itertools::Itertools; +use miette::Context; +use pixi_consts::consts; +use pixi_core::global::common::find_package_records; +use pixi_core::global::{EnvRoot, EnvironmentName, Project}; +use pixi_core::shared::tree::{ + build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource, +}; +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/src/cli/mod.rs b/src/cli/mod.rs index 7d451eaa1c..5a4badb658 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.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. use clap::builder::styling::{AnsiColor, Color, Style}; use clap::{CommandFactory, Parser}; use indicatif::ProgressDrawTarget; @@ -339,8 +347,8 @@ fn setup_logging(_args: &Args, _use_colors: bool) -> miette::Result<()> { fn setup_logging(args: &Args, use_colors: bool) -> miette::Result<()> { use pixi_utils::indicatif::IndicatifWriter; use tracing_subscriber::{ - EnvFilter, filter::LevelFilter, prelude::__tracing_subscriber_SubscriberExt, - util::SubscriberInitExt, + filter::LevelFilter, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, + EnvFilter, }; let (low_level_filter, level_filter, pixi_level) = match args.log_level_filter() { @@ -384,7 +392,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/src/cli/tree.rs b/src/cli/tree.rs index 0f0da3a31b..bee6fa9013 100644 --- a/src/cli/tree.rs +++ b/src/cli/tree.rs @@ -1,21 +1,17 @@ -use std::{ - collections::HashMap, - io::{StdoutLock, Write}, -}; - -use ahash::{HashSet, HashSetExt}; +use std::collections::HashMap; +use ahash::HashSet; +use crate::cli::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; 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 pixi_manifest::FeaturesExt; +use fancy_display::FancyDisplay; +use miette::WrapErr; +use pixi_core::shared::tree::{build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource}; +use pixi_core::{lock_file::UpdateLockFileOptions, WorkspaceLocator}; use rattler_conda_types::Platform; use rattler_lock::LockedPackageRef; -use regex::Regex; - -use crate::cli::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; +use pixi_core::workspace::Environment; +use pixi_manifest::FeaturesExt; /// Show a tree of workspace dependencies #[derive(Debug, Parser)] @@ -58,19 +54,13 @@ pub struct Args { pub invert: bool, } -struct Symbols { - down: &'static str, - tee: &'static str, - ell: &'static str, - empty: &'static str, -} -static UTF8_SYMBOLS: Symbols = Symbols { - down: "│ ", - tee: "├──", - ell: "└──", - empty: " ", -}; +/// Simplified package information extracted from the lock file +pub struct PackageInfo { + name: String, + dependencies: Vec, + source: PackageSource, +} pub async fn execute(args: Args) -> miette::Result<()> { let workspace = WorkspaceLocator::for_cli() @@ -111,7 +101,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 +113,12 @@ 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 +135,7 @@ fn extract_package_info(package: rattler_lock::LockedPackageRef<'_>) -> Option

) -> Option

], + +/// 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(); @@ -550,7 +183,7 @@ 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 +193,38 @@ 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 +} \ No newline at end of file From b6859b84eca270a6cd501f37c8c75a059c88c489 Mon Sep 17 00:00:00 2001 From: Carbonhell Date: Sun, 24 Aug 2025 19:27:43 +0200 Subject: [PATCH 2/5] refactor(cli): adapt pixi global tree changes to new pixi_cli crate changes --- crates/pixi_cli/src/global/tree.rs | 6 +++--- crates/pixi_cli/src/lib.rs | 1 + crates/{pixi_core => pixi_cli}/src/shared/mod.rs | 0 crates/{pixi_core => pixi_cli}/src/shared/tree.rs | 0 crates/pixi_cli/src/tree.rs | 4 ++-- crates/pixi_core/src/lib.rs | 1 - 6 files changed, 6 insertions(+), 6 deletions(-) rename crates/{pixi_core => pixi_cli}/src/shared/mod.rs (100%) rename crates/{pixi_core => pixi_cli}/src/shared/tree.rs (100%) diff --git a/crates/pixi_cli/src/global/tree.rs b/crates/pixi_cli/src/global/tree.rs index ba81aef81f..4a36813c27 100644 --- a/crates/pixi_cli/src/global/tree.rs +++ b/crates/pixi_cli/src/global/tree.rs @@ -4,13 +4,13 @@ use console::Color; use itertools::Itertools; use miette::Context; use pixi_consts::consts; -use pixi_core::global::common::find_package_records; -use pixi_core::global::{EnvRoot, EnvironmentName, Project}; -use pixi_core::shared::tree::{ +use crate::shared::tree::{ build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource, }; use std::collections::HashMap; use std::str::FromStr; +use pixi_global::common::find_package_records; +use pixi_global::{EnvRoot, EnvironmentName, Project}; /// Show a tree of dependencies for a specific global environment. #[derive(Debug, Parser)] diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index 53300fd3a6..6f93e8a3c4 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -21,6 +21,7 @@ use tracing::level_filters::LevelFilter; pub mod add; mod build; +mod shared; pub mod clean; pub mod cli_config; pub mod command_info; diff --git a/crates/pixi_core/src/shared/mod.rs b/crates/pixi_cli/src/shared/mod.rs similarity index 100% rename from crates/pixi_core/src/shared/mod.rs rename to crates/pixi_cli/src/shared/mod.rs diff --git a/crates/pixi_core/src/shared/tree.rs b/crates/pixi_cli/src/shared/tree.rs similarity index 100% rename from crates/pixi_core/src/shared/tree.rs rename to crates/pixi_cli/src/shared/tree.rs diff --git a/crates/pixi_cli/src/tree.rs b/crates/pixi_cli/src/tree.rs index bee6fa9013..d8c6fa5e76 100644 --- a/crates/pixi_cli/src/tree.rs +++ b/crates/pixi_cli/src/tree.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use ahash::HashSet; -use crate::cli::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; +use crate::cli_config::{LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}; use clap::Parser; use console::Color; use itertools::Itertools; use fancy_display::FancyDisplay; use miette::WrapErr; -use pixi_core::shared::tree::{build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource}; +use crate::shared::tree::{build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource}; use pixi_core::{lock_file::UpdateLockFileOptions, WorkspaceLocator}; use rattler_conda_types::Platform; use rattler_lock::LockedPackageRef; diff --git a/crates/pixi_core/src/lib.rs b/crates/pixi_core/src/lib.rs index 260095bb33..fae2657107 100644 --- a/crates/pixi_core/src/lib.rs +++ b/crates/pixi_core/src/lib.rs @@ -10,7 +10,6 @@ pub mod repodata; pub mod workspace; pub mod signals; -pub mod shared; pub use lock_file::UpdateLockFileOptions; pub use workspace::{DependencyType, Workspace, WorkspaceLocator}; From 6718caaa3c8e174c93d0913a67373fd1619250b5 Mon Sep 17 00:00:00 2001 From: Carbonhell Date: Sun, 24 Aug 2025 19:47:55 +0200 Subject: [PATCH 3/5] docs(cli): regenerate CLI docs to include the new global tree command --- docs/reference/cli/pixi/global.md | 1 + docs/reference/cli/pixi/global/tree.md | 31 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 docs/reference/cli/pixi/global/tree.md 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" From fefcb759aca7ec033cff439f5887828d3a64e84f Mon Sep 17 00:00:00 2001 From: Carbonhell Date: Sun, 24 Aug 2025 19:51:09 +0200 Subject: [PATCH 4/5] ci: apply lint changes --- crates/pixi_cli/src/global/mod.rs | 2 +- crates/pixi_cli/src/global/tree.rs | 21 ++++++++++------- crates/pixi_cli/src/lib.rs | 2 +- crates/pixi_cli/src/shared/mod.rs | 2 +- crates/pixi_cli/src/shared/tree.rs | 37 +++++++++++++---------------- crates/pixi_cli/src/tree.rs | 38 +++++++++++++++++------------- 6 files changed, 54 insertions(+), 48 deletions(-) diff --git a/crates/pixi_cli/src/global/mod.rs b/crates/pixi_cli/src/global/mod.rs index 68fc6c18c7..049a4b63cf 100644 --- a/crates/pixi_cli/src/global/mod.rs +++ b/crates/pixi_cli/src/global/mod.rs @@ -11,11 +11,11 @@ mod list; mod remove; mod shortcut; mod sync; +mod tree; mod uninstall; mod update; mod upgrade; mod upgrade_all; -mod tree; #[derive(Debug, Parser)] pub enum Command { diff --git a/crates/pixi_cli/src/global/tree.rs b/crates/pixi_cli/src/global/tree.rs index 4a36813c27..812db29829 100644 --- a/crates/pixi_cli/src/global/tree.rs +++ b/crates/pixi_cli/src/global/tree.rs @@ -1,16 +1,17 @@ +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 crate::shared::tree::{ - build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource, -}; -use std::collections::HashMap; -use std::str::FromStr; 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)] @@ -26,7 +27,7 @@ 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, @@ -40,7 +41,7 @@ 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 env_name = EnvironmentName::from_str(args.environment.as_str())?; let environment = project .environment(&env_name) .wrap_err("Environment not found")?; @@ -77,7 +78,11 @@ pub async fn execute(args: Args) -> miette::Result<()> { .as_ref() .depends .iter() - .filter_map(|dep| dep.split([' ', '=']).next().map(|dep_name| dep_name.to_string())) + .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(), diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index 6f93e8a3c4..eca4df3082 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -21,7 +21,6 @@ use tracing::level_filters::LevelFilter; pub mod add; mod build; -mod shared; pub mod clean; pub mod cli_config; pub mod command_info; @@ -41,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; diff --git a/crates/pixi_cli/src/shared/mod.rs b/crates/pixi_cli/src/shared/mod.rs index f73d4a42df..c84057ec7c 100644 --- a/crates/pixi_cli/src/shared/mod.rs +++ b/crates/pixi_cli/src/shared/mod.rs @@ -1,3 +1,3 @@ //! This file contains utilities shared by the implementation of command logic -pub mod tree; \ No newline at end of file +pub mod tree; diff --git a/crates/pixi_cli/src/shared/tree.rs b/crates/pixi_cli/src/shared/tree.rs index e4178088f2..b72609f72a 100644 --- a/crates/pixi_cli/src/shared/tree.rs +++ b/crates/pixi_cli/src/shared/tree.rs @@ -1,11 +1,11 @@ //! This file contains the logic to pretty-print dependency lists in a tree-like structure. -use std::collections::HashMap; -use std::io::{StdoutLock, Write}; 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)] @@ -47,7 +47,7 @@ static UTF8_SYMBOLS: Symbols = Symbols { /// # Arguments /// /// * `handle` - A mutable lock on stdout for writing the output -/// * `dep_map` - A map of package names to their dependency information +/// * `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 /// @@ -261,21 +261,18 @@ pub fn print_package( }, 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") + .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. /// @@ -336,7 +333,6 @@ pub fn print_inverted_dependency_tree( 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 @@ -408,7 +404,6 @@ fn print_inverted_node( 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. /// @@ -422,7 +417,9 @@ fn print_inverted_node( /// # Returns /// /// A new dependency graph with `needed_by` fields populated to show reverse dependencies -pub fn build_reverse_dependency_map(dep_map: &HashMap) -> HashMap { +pub fn build_reverse_dependency_map( + dep_map: &HashMap, +) -> HashMap { let mut inverted_deps = dep_map.clone(); for pkg in dep_map.values() { @@ -434,4 +431,4 @@ pub fn build_reverse_dependency_map(dep_map: &HashMap) -> HashM } inverted_deps -} \ No newline at end of file +} diff --git a/crates/pixi_cli/src/tree.rs b/crates/pixi_cli/src/tree.rs index d8c6fa5e76..7a2ed0acce 100644 --- a/crates/pixi_cli/src/tree.rs +++ b/crates/pixi_cli/src/tree.rs @@ -1,17 +1,20 @@ -use std::collections::HashMap; -use ahash::HashSet; 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; use clap::Parser; use console::Color; -use itertools::Itertools; use fancy_display::FancyDisplay; +use itertools::Itertools; use miette::WrapErr; -use crate::shared::tree::{build_reverse_dependency_map, print_dependency_tree, print_inverted_dependency_tree, Package, PackageSource}; -use pixi_core::{lock_file::UpdateLockFileOptions, WorkspaceLocator}; -use rattler_conda_types::Platform; -use rattler_lock::LockedPackageRef; 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 std::collections::HashMap; /// Show a tree of workspace dependencies #[derive(Debug, Parser)] @@ -54,7 +57,6 @@ pub struct Args { pub invert: bool, } - /// Simplified package information extracted from the lock file pub struct PackageInfo { name: String, @@ -113,9 +115,10 @@ pub async fn execute(args: Args) -> miette::Result<()> { Ok(()) } - /// 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 { +pub(crate) fn extract_package_info( + package: rattler_lock::LockedPackageRef<'_>, +) -> Option { if let Some(conda_package) = package.as_conda() { let name = conda_package.record().name.as_normalized().to_string(); @@ -164,11 +167,8 @@ pub(crate) fn extract_package_info(package: rattler_lock::LockedPackageRef<'_>) } } - /// Generate a map of dependencies from a list of locked packages. -pub fn generate_dependency_map( - locked_deps: &[LockedPackageRef<'_>], -) -> HashMap { +pub fn generate_dependency_map(locked_deps: &[LockedPackageRef<'_>]) -> HashMap { let mut package_dependencies_map = HashMap::new(); for &package in locked_deps { @@ -183,7 +183,12 @@ pub fn generate_dependency_map( } LockedPackageRef::Pypi(pypi_data, _) => pypi_data.version.to_string(), }, - dependencies: package_info.dependencies.into_iter().filter(|pkg| !pkg.starts_with("__")).unique().collect(), + dependencies: package_info + .dependencies + .into_iter() + .filter(|pkg| !pkg.starts_with("__")) + .unique() + .collect(), needed_by: Vec::new(), source: package_info.source, }, @@ -193,7 +198,6 @@ pub fn generate_dependency_map( package_dependencies_map } - /// Extract the direct Conda and PyPI dependencies from the environment pub fn direct_dependencies( environment: &Environment<'_>, @@ -227,4 +231,4 @@ pub fn direct_dependencies( .map(|(name, _)| name.as_normalized().as_dist_info_name().into_owned()), ); project_dependency_names -} \ No newline at end of file +} From ebe0ee861e110b5252ff9ba4770eb39784d2109e Mon Sep 17 00:00:00 2001 From: Ruben Arts Date: Mon, 25 Aug 2025 10:36:16 +0200 Subject: [PATCH 5/5] test: integration tests for `global tree` --- .../pixi_global/test_global.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) 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"], + )