diff --git a/pixi.toml b/pixi.toml index 35c3d1804..a40b1e35f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -48,6 +48,10 @@ test-integration-dev = { cmd = "pytest --numprocesses=auto --durations=10 tests/ test-integration-global = { cmd = "pytest --numprocesses=auto --durations=10 tests/integration/test_global.py", depends-on = [ "build", ] } +# pass the file to run as an argument to the task +# you can also pass a specific test function, like this: +# /path/to/test.py::test_function +test-specific-test = { cmd = "pytest", depends-on = ["build"] } typecheck-integration = "mypy --strict tests/integration" [feature.dev.dependencies] diff --git a/src/cli/global/install.rs b/src/cli/global/install.rs index fe3c2ed16..e52a0a53c 100644 --- a/src/cli/global/install.rs +++ b/src/cli/global/install.rs @@ -9,7 +9,10 @@ use rattler_conda_types::{MatchSpec, NamedChannelOrUrl, PackageName, Platform}; use crate::{ cli::{global::revert_environment_after_error, has_specs::HasSpecs}, - global::{self, EnvironmentName, ExposedName, Mapping, Project, StateChange, StateChanges}, + global::{ + self, common::NotChangedReason, list::list_global_environments, EnvChanges, EnvState, + EnvironmentName, ExposedName, Mapping, Project, StateChange, StateChanges, + }, prefix::Prefix, }; use pixi_config::{self, Config, ConfigCli}; @@ -86,6 +89,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { } let mut state_changes = StateChanges::default(); + let mut env_changes = EnvChanges::default(); let mut last_updated_project = project_original; let specs = args.specs()?; for env_name in &env_names { @@ -104,6 +108,15 @@ pub async fn execute(args: Args) -> miette::Result<()> { .wrap_err_with(|| format!("Couldn't install {}", env_name.fancy_display())) { Ok(sc) => { + match sc.has_changed() { + true => env_changes + .changes + .insert(env_name.clone(), EnvState::Installed), + false => env_changes.changes.insert( + env_name.clone(), + EnvState::NotChanged(NotChangedReason::AlreadyInstalled), + ), + }; state_changes |= sc; } Err(err) => { @@ -116,7 +129,15 @@ pub async fn execute(args: Args) -> miette::Result<()> { } last_updated_project = project; } - state_changes.report(); + + // After installing, we always want to list the changed environments + list_global_environments( + &last_updated_project, + Some(env_names), + Some(&env_changes), + None, + ) + .await?; Ok(()) } diff --git a/src/cli/global/list.rs b/src/cli/global/list.rs index 5fa1e141d..93d591218 100644 --- a/src/cli/global/list.rs +++ b/src/cli/global/list.rs @@ -1,18 +1,8 @@ -use crate::global::common::find_package_records; -use crate::global::project::ParsedEnvironment; -use crate::global::{EnvironmentName, Mapping, Project}; +use crate::global::list::{list_environment, list_global_environments, GlobalSortBy}; +use crate::global::{EnvironmentName, Project}; use clap::Parser; use fancy_display::FancyDisplay; -use human_bytes::human_bytes; -use indexmap::{IndexMap, IndexSet}; -use itertools::Itertools; -use miette::{miette, IntoDiagnostic}; use pixi_config::{Config, ConfigCli}; -use pixi_consts::consts; -use pixi_spec::PixiSpec; -use rattler_conda_types::{PackageName, PackageRecord, PrefixRecord, Version}; -use serde::Serialize; -use std::io::{stdout, Write}; use std::str::FromStr; /// Lists all packages previously installed into a globally accessible location via `pixi global install`. @@ -57,352 +47,15 @@ pub async fn execute(args: Args) -> miette::Result<()> { if !project.environment_in_sync(&env_name).await? { tracing::warn!("The environment {} is not in sync with the manifest, to sync run\n\tpixi global sync", env_name.fancy_display()); } - list_environment(project, &env_name, args.sort_by, args.regex).await?; + + list_environment(&project, &env_name, args.sort_by, args.regex).await?; } else { // Verify that the environments are in sync with the manifest and report to the user otherwise if !project.environments_in_sync().await? { tracing::warn!("The environments are not in sync with the manifest, to sync run\n\tpixi global sync"); } - list_global_environments(project, args.regex).await?; - } - - Ok(()) -} - -/// Sorting strategy for the package table -#[derive(clap::ValueEnum, Clone, Debug, Serialize)] -pub enum GlobalSortBy { - Size, - Name, -} - -#[derive(Serialize, Hash, Eq, PartialEq)] -struct PackageToOutput { - name: PackageName, - version: Version, - build: Option, - size_bytes: Option, - is_explicit: bool, -} - -impl PackageToOutput { - fn new(record: &PackageRecord, is_explicit: bool) -> Self { - Self { - name: record.name.clone(), - version: record.version.version().clone(), - build: Some(record.build.clone()), - size_bytes: record.size, - is_explicit, - } - } -} - -/// List package and binaries in environment -async fn list_environment( - project: Project, - environment_name: &EnvironmentName, - sort_by: GlobalSortBy, - regex: Option, -) -> miette::Result<()> { - let env = project - .environments() - .get(environment_name) - .ok_or_else(|| miette!("Environment {} not found", environment_name.fancy_display()))?; - - let records = find_package_records( - &project - .env_root - .path() - .join(environment_name.as_str()) - .join(consts::CONDA_META_DIR), - ) - .await?; - - let mut packages_to_output: Vec = records - .iter() - .map(|record| { - PackageToOutput::new( - &record.repodata_record.package_record, - env.dependencies() - .contains_key(&record.repodata_record.package_record.name), - ) - }) - .collect(); - - // Filter according to the regex - if let Some(ref regex) = regex { - let regex = regex::Regex::new(regex).into_diagnostic()?; - packages_to_output.retain(|package| regex.is_match(package.name.as_normalized())); - } - - let output_message = if let Some(ref regex) = regex { - format!( - "The {} environment has {} packages filtered by regex `{}`:", - environment_name.fancy_display(), - console::style(packages_to_output.len()).bold(), - regex - ) - } else { - format!( - "The {} environment has {} packages:", - environment_name.fancy_display(), - console::style(packages_to_output.len()).bold() - ) - }; - - // Sort according to the sorting strategy - match sort_by { - GlobalSortBy::Size => { - packages_to_output - .sort_by(|a, b| a.size_bytes.unwrap_or(0).cmp(&b.size_bytes.unwrap_or(0))); - } - GlobalSortBy::Name => { - packages_to_output.sort_by(|a, b| a.name.cmp(&b.name)); - } + list_global_environments(&project, None, None, args.regex).await?; } - println!("{}", output_message); - print_package_table(packages_to_output).into_diagnostic()?; - println!(); - print_meta_info(env); Ok(()) } - -fn print_meta_info(environment: &ParsedEnvironment) { - // Print exposed binaries, if binary similar to path only print once. - let formatted_exposed = environment.exposed.iter().map(format_mapping).join(", "); - println!( - "{}\n{}", - console::style("Exposes:").bold().cyan(), - if !formatted_exposed.is_empty() { - formatted_exposed - } else { - "Nothing".to_string() - } - ); - - // Print channels - if !environment.channels().is_empty() { - println!( - "{}\n{}", - console::style("Channels:").bold().cyan(), - environment.channels().iter().join(", ") - ); - } - - // Print platform - if let Some(platform) = environment.platform() { - println!("{} {}", console::style("Platform:").bold().cyan(), platform); - } -} - -/// Create a human-readable representation of the global environment. -/// Using a tabwriter to align the columns. -fn print_package_table(packages: Vec) -> Result<(), std::io::Error> { - let mut writer = tabwriter::TabWriter::new(stdout()); - let header_style = console::Style::new().bold().cyan(); - let header = format!( - "{}\t{}\t{}\t{}", - header_style.apply_to("Package"), - header_style.apply_to("Version"), - header_style.apply_to("Build"), - header_style.apply_to("Size"), - ); - writeln!(writer, "{}", &header)?; - - for package in packages { - // Convert size to human-readable format - let size_human = package - .size_bytes - .map(|size| human_bytes(size as f64)) - .unwrap_or_default(); - - let package_info = format!( - "{}\t{}\t{}\t{}", - package.name.as_normalized(), - &package.version, - package.build.as_deref().unwrap_or(""), - size_human - ); - - writeln!( - writer, - "{}", - if package.is_explicit { - console::style(package_info).green().to_string() - } else { - package_info - } - )?; - } - - writeln!(writer, "{}", header)?; - - writer.flush() -} - -/// List all environments in the global environment -async fn list_global_environments(project: Project, regex: Option) -> miette::Result<()> { - let mut envs = project.environments().clone(); - envs.sort_by(|a, _, b, _| a.to_string().cmp(&b.to_string())); - - if let Some(regex) = regex { - let regex = regex::Regex::new(®ex).into_diagnostic()?; - envs.retain(|env_name, _| regex.is_match(env_name.as_str())); - } - - let mut message = String::new(); - - let len = envs.len(); - for (idx, (env_name, env)) in envs.iter().enumerate() { - let env_dir = project.env_root.path().join(env_name.as_str()); - let records = find_package_records(&env_dir.join(consts::CONDA_META_DIR)).await?; - - let last = (idx + 1) == len; - - if last { - message.push_str("└──"); - } else { - message.push_str("├──"); - } - - if !env - .dependencies() - .iter() - .any(|(pkg_name, _spec)| pkg_name.as_normalized() != env_name.as_str()) - { - if let Some(env_package) = records.iter().find(|rec| { - rec.repodata_record.package_record.name.as_normalized() == env_name.as_str() - }) { - message.push_str(&format!( - " {}: {}", - env_name.fancy_display(), - console::style(env_package.repodata_record.package_record.version.clone()) - .blue() - )); - } else { - message.push_str(&format!(" {}", env_name.fancy_display())); - } - } else { - message.push_str(&format!(" {}", env_name.fancy_display())); - } - - // Write dependencies - if let Some(dep_message) = format_dependencies( - env_name.as_str(), - &env.dependencies, - &records, - last, - !env.exposed.is_empty(), - ) { - message.push_str(&dep_message); - } - - // Write exposed binaries - if let Some(exp_message) = format_exposed(env_name.as_str(), env.exposed(), last) { - message.push_str(&exp_message); - } - - if !last { - message.push('\n'); - } - } - if message.is_empty() { - println!("No global environments found."); - } else { - println!( - "Global environments as specified in '{}'\n{}", - console::style(project.manifest.path.display()).bold(), - message - ); - } - - Ok(()) -} - -/// Display a dependency in a human-readable format. -fn display_dependency(name: &PackageName, version: Option) -> String { - if let Some(version) = version { - format!( - "{} {}", - console::style(name.as_normalized()).green(), - console::style(version).blue() - ) - } else { - console::style(name.as_normalized()).green().to_string() - } -} - -/// Creating the ASCII art representation of a section. -fn format_asciiart_section(label: &str, content: String, last: bool, more: bool) -> String { - let prefix = if last { " " } else { "│" }; - let symbol = if more { "├" } else { "└" }; - format!("\n{} {}─ {}: {}", prefix, symbol, label, content) -} - -fn format_dependencies( - env_name: &str, - dependencies: &IndexMap, - records: &[PrefixRecord], - last: bool, - more: bool, -) -> Option { - if dependencies - .iter() - .any(|(pkg_name, _spec)| pkg_name.as_normalized() != env_name) - { - let content = dependencies - .iter() - .map(|(name, _spec)| { - let version = records - .iter() - .find(|rec| { - rec.repodata_record.package_record.name.as_normalized() - == name.as_normalized() - }) - .map(|rec| rec.repodata_record.package_record.version.version().clone()); - display_dependency(name, version) - }) - .join(", "); - Some(format_asciiart_section("dependencies", content, last, more)) - } else { - None - } -} - -fn format_exposed(env_name: &str, exposed: &IndexSet, last: bool) -> Option { - if exposed.is_empty() { - Some(format_asciiart_section( - "exposes", - console::style("Nothing").dim().red().to_string(), - last, - false, - )) - } else if exposed - .iter() - .any(|mapping| mapping.exposed_name().to_string() != env_name) - { - let formatted_exposed = exposed.iter().map(format_mapping).join(", "); - Some(format_asciiart_section( - "exposes", - formatted_exposed, - last, - false, - )) - } else { - None - } -} - -fn format_mapping(mapping: &Mapping) -> String { - let exp = mapping.exposed_name().to_string(); - if exp == mapping.executable_name() { - console::style(exp).yellow().to_string() - } else { - format!( - "{} -> {}", - console::style(exp).yellow(), - console::style(mapping.executable_name()).yellow() - ) - } -} diff --git a/src/cli/global/update.rs b/src/cli/global/update.rs index 20be901d7..8cbdb5a7b 100644 --- a/src/cli/global/update.rs +++ b/src/cli/global/update.rs @@ -25,6 +25,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { project: &mut Project, ) -> miette::Result { let mut state_changes = StateChanges::default(); + // Reinstall the environment project.install_environment(env_name).await?; diff --git a/src/global/common.rs b/src/global/common.rs index 77f8edd30..4472f4131 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -1,4 +1,5 @@ use super::{extract_executable_from_script, EnvironmentName, ExposedName, Mapping}; +use console::StyledObject; use fancy_display::FancyDisplay; use fs_err as fs; use fs_err::tokio as tokio_fs; @@ -194,6 +195,63 @@ pub(crate) async fn find_package_records(conda_meta: &Path) -> miette::Result) -> std::fmt::Result { + match self { + NotChangedReason::AlreadyInstalled => write!(f, "already installed"), + } + } +} + +impl NotChangedReason { + /// Returns the name of the environment. + pub fn as_str(&self) -> &str { + match self { + NotChangedReason::AlreadyInstalled => "already installed", + } + } +} + +impl FancyDisplay for NotChangedReason { + fn fancy_display(&self) -> StyledObject<&str> { + console::style(self.as_str()).cyan() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EnvState { + Installed, + NotChanged(NotChangedReason), +} + +impl EnvState { + pub fn as_str(&self) -> &str { + match self { + EnvState::Installed => "installed", + EnvState::NotChanged(reason) => reason.as_str(), + } + } +} + +impl FancyDisplay for EnvState { + fn fancy_display(&self) -> StyledObject<&str> { + match self { + EnvState::Installed => console::style(self.as_str()).green(), + EnvState::NotChanged(ref reason) => reason.fancy_display(), + } + } +} + +#[derive(Debug, Default)] +pub(crate) struct EnvChanges { + pub changes: HashMap, +} + #[derive(Debug, Clone, PartialEq, Eq)] #[must_use] pub(crate) enum StateChange { @@ -220,6 +278,7 @@ impl StateChanges { } } + /// Checks if there are any changes in the state. pub(crate) fn has_changed(&self) -> bool { !self.changes.values().all(Vec::is_empty) } @@ -273,12 +332,9 @@ impl StateChanges { self.prune(); for (env_name, changes_for_env) in &self.changes { + // If there are no changes for the environment, skip it if changes_for_env.is_empty() { - eprintln!( - "{}The environment {} was already up-to-date", - console::style(console::Emoji("✔ ", "")).green(), - env_name.fancy_display() - ); + continue; } let mut iter = changes_for_env.iter().peekable(); diff --git a/src/global/list.rs b/src/global/list.rs new file mode 100644 index 000000000..c6d0c9bce --- /dev/null +++ b/src/global/list.rs @@ -0,0 +1,376 @@ +use std::io::stdout; + +use fancy_display::FancyDisplay; +use human_bytes::human_bytes; +use indexmap::{IndexMap, IndexSet}; +use itertools::Itertools; +use pixi_consts::consts; +use pixi_spec::PixiSpec; +use rattler_conda_types::{PackageName, PackageRecord, PrefixRecord, Version}; +use serde::Serialize; +use std::io::Write; + +use miette::{miette, IntoDiagnostic}; + +use crate::global::common::find_package_records; + +use super::{project::ParsedEnvironment, EnvChanges, EnvState, EnvironmentName, Mapping, Project}; + +/// Sorting strategy for the package table +#[derive(clap::ValueEnum, Clone, Debug, Serialize, Default)] +pub enum GlobalSortBy { + Size, + #[default] + Name, +} + +/// Creating the ASCII art representation of a section. +pub fn format_asciiart_section(label: &str, content: String, last: bool, more: bool) -> String { + let prefix = if last { " " } else { "│" }; + let symbol = if more { "├" } else { "└" }; + format!("\n{} {}─ {}: {}", prefix, symbol, label, content) +} + +pub fn format_exposed(exposed: &IndexSet, last: bool) -> Option { + if exposed.is_empty() { + Some(format_asciiart_section( + "exposes", + console::style("Nothing").dim().red().to_string(), + last, + false, + )) + } else { + let formatted_exposed = exposed.iter().map(format_mapping).join(", "); + Some(format_asciiart_section( + "exposes", + formatted_exposed, + last, + false, + )) + } +} + +fn format_mapping(mapping: &Mapping) -> String { + let exp = mapping.exposed_name().to_string(); + if exp == mapping.executable_name() { + console::style(exp).yellow().to_string() + } else { + format!( + "{} -> {}", + console::style(exp).yellow(), + console::style(mapping.executable_name()).yellow() + ) + } +} + +fn print_meta_info(environment: &ParsedEnvironment) { + // Print exposed binaries, if binary similar to path only print once. + let formatted_exposed = environment.exposed.iter().map(format_mapping).join(", "); + println!( + "{}\n{}", + console::style("Exposes:").bold().cyan(), + if !formatted_exposed.is_empty() { + formatted_exposed + } else { + "Nothing".to_string() + } + ); + + // Print channels + if !environment.channels().is_empty() { + println!( + "{}\n{}", + console::style("Channels:").bold().cyan(), + environment.channels().iter().join(", ") + ); + } + + // Print platform + if let Some(platform) = environment.platform() { + println!("{} {}", console::style("Platform:").bold().cyan(), platform); + } +} + +/// Create a human-readable representation of the global environment. +/// Using a tabwriter to align the columns. +fn print_package_table(packages: Vec) -> Result<(), std::io::Error> { + let mut writer = tabwriter::TabWriter::new(stdout()); + let header_style = console::Style::new().bold().cyan(); + let header = format!( + "{}\t{}\t{}\t{}", + header_style.apply_to("Package"), + header_style.apply_to("Version"), + header_style.apply_to("Build"), + header_style.apply_to("Size"), + ); + writeln!(writer, "{}", &header)?; + + for package in packages { + // Convert size to human-readable format + let size_human = package + .size_bytes + .map(|size| human_bytes(size as f64)) + .unwrap_or_default(); + + let package_info = format!( + "{}\t{}\t{}\t{}", + package.name.as_normalized(), + &package.version, + package.build.as_deref().unwrap_or(""), + size_human + ); + + writeln!( + writer, + "{}", + if package.is_explicit { + console::style(package_info).green().to_string() + } else { + package_info + } + )?; + } + + writeln!(writer, "{}", header)?; + + writer.flush() +} + +/// List package and binaries in environment +pub async fn list_environment( + project: &Project, + environment_name: &EnvironmentName, + sort_by: GlobalSortBy, + regex: Option, +) -> miette::Result<()> { + let env = project + .environments() + .get(environment_name) + .ok_or_else(|| miette!("Environment {} not found", environment_name.fancy_display()))?; + + let records = find_package_records( + &project + .env_root + .path() + .join(environment_name.as_str()) + .join(consts::CONDA_META_DIR), + ) + .await?; + + let mut packages_to_output: Vec = records + .iter() + .map(|record| { + PackageToOutput::new( + &record.repodata_record.package_record, + env.dependencies() + .contains_key(&record.repodata_record.package_record.name), + ) + }) + .collect(); + + // Filter according to the regex + if let Some(ref regex) = regex { + let regex = regex::Regex::new(regex).into_diagnostic()?; + packages_to_output.retain(|package| regex.is_match(package.name.as_normalized())); + } + + let output_message = if let Some(ref regex) = regex { + format!( + "The {} environment has {} packages filtered by regex `{}`:", + environment_name.fancy_display(), + console::style(packages_to_output.len()).bold(), + regex + ) + } else { + format!( + "The {} environment has {} packages:", + environment_name.fancy_display(), + console::style(packages_to_output.len()).bold() + ) + }; + + // Sort according to the sorting strategy + match sort_by { + GlobalSortBy::Size => { + packages_to_output + .sort_by(|a, b| a.size_bytes.unwrap_or(0).cmp(&b.size_bytes.unwrap_or(0))); + } + GlobalSortBy::Name => { + packages_to_output.sort_by(|a, b| a.name.cmp(&b.name)); + } + } + println!("{}", output_message); + print_package_table(packages_to_output).into_diagnostic()?; + println!(); + print_meta_info(env); + + Ok(()) +} + +/// List all environments in the global environment +pub async fn list_global_environments( + project: &Project, + envs: Option>, + envs_changes: Option<&EnvChanges>, + regex: Option, +) -> miette::Result<()> { + let mut project_envs = project.environments().clone(); + project_envs.sort_by(|a, _, b, _| a.to_string().cmp(&b.to_string())); + + if let Some(regex) = regex { + let regex = regex::Regex::new(®ex).into_diagnostic()?; + project_envs.retain(|env_name, _| regex.is_match(env_name.as_str())); + } + + if let Some(envs) = envs { + project_envs.retain(|env_name, _| envs.contains(env_name)); + } + + let mut message = String::new(); + + let len = project_envs.len(); + for (idx, (env_name, env)) in project_envs.iter().enumerate() { + let env_dir = project.env_root.path().join(env_name.as_str()); + let records = find_package_records(&env_dir.join(consts::CONDA_META_DIR)).await?; + + let last = (idx + 1) == len; + + if last { + message.push_str("└──"); + } else { + message.push_str("├──"); + } + + // get the state of the environment if available + // and also it's state if present + let state = envs_changes + .and_then(|env_changes| env_changes.changes.get(env_name)) + .map(|state| match state { + EnvState::Installed => { + format!("({})", console::style("installed".to_string()).green()) + } + EnvState::NotChanged(ref reason) => { + format!("({})", reason.fancy_display()) + } + }) + .unwrap_or("".to_string()); + + if !env + .dependencies() + .iter() + .any(|(pkg_name, _spec)| pkg_name.as_normalized() != env_name.as_str()) + { + if let Some(env_package) = records.iter().find(|rec| { + rec.repodata_record.package_record.name.as_normalized() == env_name.as_str() + }) { + // output the environment name and version + message.push_str(&format!( + " {}: {} {}", + env_name.fancy_display(), + console::style(env_package.repodata_record.package_record.version.clone()) + .blue(), + state + )); + } else { + message.push_str(&format!(" {} {}", env_name.fancy_display(), state)); + } + } else { + message.push_str(&format!(" {} {}", env_name.fancy_display(), state)); + } + + // Write dependencies + if let Some(dep_message) = format_dependencies( + env_name.as_str(), + &env.dependencies, + &records, + last, + !env.exposed.is_empty(), + ) { + message.push_str(&dep_message); + } + + // Write exposed binaries + if let Some(exp_message) = format_exposed(env.exposed(), last) { + message.push_str(&exp_message); + } + + if !last { + message.push('\n'); + } + } + if message.is_empty() { + println!("No global environments found."); + } else { + println!( + "Global environments as specified in '{}'\n{}", + console::style(project.manifest.path.display()).bold(), + message + ); + } + + Ok(()) +} + +/// Display a dependency in a human-readable format. +fn display_dependency(name: &PackageName, version: Option) -> String { + if let Some(version) = version { + format!( + "{} {}", + console::style(name.as_normalized()).green(), + console::style(version).blue() + ) + } else { + console::style(name.as_normalized()).green().to_string() + } +} + +fn format_dependencies( + env_name: &str, + dependencies: &IndexMap, + records: &[PrefixRecord], + last: bool, + more: bool, +) -> Option { + if dependencies + .iter() + .any(|(pkg_name, _spec)| pkg_name.as_normalized() != env_name) + { + let content = dependencies + .iter() + .map(|(name, _spec)| { + let version = records + .iter() + .find(|rec| { + rec.repodata_record.package_record.name.as_normalized() + == name.as_normalized() + }) + .map(|rec| rec.repodata_record.package_record.version.version().clone()); + display_dependency(name, version) + }) + .join(", "); + Some(format_asciiart_section("dependencies", content, last, more)) + } else { + None + } +} + +#[derive(Serialize, Hash, Eq, PartialEq)] +struct PackageToOutput { + name: PackageName, + version: Version, + build: Option, + size_bytes: Option, + is_explicit: bool, +} + +impl PackageToOutput { + fn new(record: &PackageRecord, is_explicit: bool) -> Self { + Self { + name: record.name.clone(), + version: record.version.version().clone(), + build: Some(record.build.clone()), + size_bytes: record.size, + is_explicit, + } + } +} diff --git a/src/global/mod.rs b/src/global/mod.rs index 2491acdda..67b834321 100644 --- a/src/global/mod.rs +++ b/src/global/mod.rs @@ -1,8 +1,9 @@ pub(crate) mod common; pub(crate) mod install; +pub(crate) mod list; pub(crate) mod project; -pub(crate) use common::{BinDir, EnvDir, EnvRoot, StateChange, StateChanges}; +pub(crate) use common::{BinDir, EnvChanges, EnvDir, EnvRoot, EnvState, StateChange, StateChanges}; pub(crate) use install::extract_executable_from_script; pub(crate) use project::{EnvironmentName, ExposedName, Mapping, Project}; diff --git a/tests/integration/common.py b/tests/integration/common.py index cc0efd7d4..35a3dfa9a 100644 --- a/tests/integration/common.py +++ b/tests/integration/common.py @@ -12,6 +12,22 @@ class ExitCode(IntEnum): INCORRECT_USAGE = 2 +class Output: + command: list[Path | str] + stdout: str + stderr: str + returncode: int + + def __init__(self, command: list[Path | str], stdout: str, stderr: str, returncode: int): + self.command = command + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + def __str__(self) -> str: + return f"command: {self.command}" + + def verify_cli_command( command: list[Path | str], expected_exit_code: ExitCode = ExitCode.SUCCESS, @@ -20,7 +36,7 @@ def verify_cli_command( stderr_contains: str | list[str] | None = None, stderr_excludes: str | list[str] | None = None, env: dict[str, str] | None = None, -) -> None: +) -> Output: # Setup the environment type safe. base_env = dict(os.environ) if env is not None: @@ -32,6 +48,7 @@ def verify_cli_command( process = subprocess.run(command, capture_output=True, text=True, env=complete_env) stdout, stderr, returncode = process.stdout, process.stderr, process.returncode + output = Output(command, stdout, stderr, returncode) print(f"command: {command}, stdout: {stdout}, stderr: {stderr}, code: {returncode}") if expected_exit_code is not None: assert ( @@ -61,3 +78,5 @@ def verify_cli_command( stderr_excludes = [stderr_excludes] for substring in stderr_excludes: assert substring not in stderr, f"'{substring}' unexpectedly found in stderr: {stderr}" + + return output diff --git a/tests/integration/test_global.py b/tests/integration/test_global.py index 4e6b08884..22319e7b1 100644 --- a/tests/integration/test_global.py +++ b/tests/integration/test_global.py @@ -527,6 +527,7 @@ def test_install_twice(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None "dummy-b", ], env=env, + stdout_contains="dummy-b: 0.1.0 (installed)", ) assert dummy_b.is_file() @@ -541,7 +542,56 @@ def test_install_twice(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None "dummy-b", ], env=env, - stderr_contains="The environment dummy-b was already up-to-date", + stdout_contains="dummy-b: 0.1.0 (already installed)", + ) + assert dummy_b.is_file() + + +def test_install_twice_with_same_env_name_as_expose( + pixi: Path, tmp_path: Path, dummy_channel_1: str +) -> None: + # This test is to ensure that when the environment name is the same as the expose name, exposes are printed correctly + # and we also ensure that when custom name for environment is used, + # we output state for it + env = {"PIXI_HOME": str(tmp_path)} + + dummy_b = tmp_path / "bin" / exec_extension("customdummyb") + + # Install dummy-b + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "dummy-b", + "--environment", + "customdummyb", + "--expose", + "customdummyb=dummy-b", + ], + env=env, + stdout_contains=["customdummyb (installed)", "exposes: customdummyb -> dummy-b"], + ) + assert dummy_b.is_file() + + # Install dummy-b again, there should be nothing to do + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + dummy_channel_1, + "dummy-b", + "--environment", + "customdummyb", + "--expose", + "customdummyb=dummy-b", + ], + env=env, + stdout_contains=["customdummyb (already installed)", "exposes: customdummyb -> dummy-b"], ) assert dummy_b.is_file() @@ -564,6 +614,7 @@ def test_install_twice_with_force_reinstall( "dummy-b", ], env=env, + stdout_contains="dummy-b: 0.1.0 (installed)", ) assert dummy_b.is_file() @@ -587,7 +638,7 @@ def test_install_twice_with_force_reinstall( "dummy-b", ], env=env, - stderr_contains="The environment dummy-b was already up-to-date", + stdout_contains="dummy-b: 0.1.0 (already installed)", ) # Install dummy-b again, but with force-reinstall @@ -603,7 +654,7 @@ def test_install_twice_with_force_reinstall( "dummy-b", ], env=env, - stderr_contains="Added package dummy-b=0.1.0 to environment dummy-b", + stdout_contains="dummy-b: 0.1.0 (installed)", )