diff --git a/docs/reference/cli/pixi/install.md b/docs/reference/cli/pixi/install.md index c897f0a641..c23aacdf75 100644 --- a/docs/reference/cli/pixi/install.md +++ b/docs/reference/cli/pixi/install.md @@ -17,6 +17,9 @@ pixi install [OPTIONS]
May be provided more than once. - `--all (-a)` : Install all environments +- `--skip ` +: Skip installation of specific packages present in the lockfile. Requires --frozen. This can be useful for instance in a Dockerfile to skip local source dependencies when installing dependencies +
May be provided more than once. ## Config Options - `--auth-file ` diff --git a/src/cli/install.rs b/src/cli/install.rs index fa0906171e..6141c98299 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -2,6 +2,7 @@ use clap::Parser; use fancy_display::FancyDisplay; use itertools::Itertools; use pixi_config::ConfigCli; +use std::fmt::Write; use crate::{ UpdateLockFileOptions, WorkspaceLocator, @@ -47,6 +48,11 @@ pub struct Args { /// Install all environments #[arg(long, short, conflicts_with = "environment")] pub all: bool, + + /// Skip installation of specific packages present in the lockfile. Requires --frozen. + /// This can be useful for instance in a Dockerfile to skip local source dependencies when installing dependencies. + #[arg(long, requires = "frozen")] + pub skip: Option>, } pub async fn execute(args: Args) -> miette::Result<()> { @@ -79,7 +85,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { .collect::, _>>()?; // Update the prefixes by installing all packages - get_update_lock_file_and_prefixes( + let (lock_file, _) = get_update_lock_file_and_prefixes( &environments, UpdateMode::Revalidate, UpdateLockFileOptions { @@ -88,37 +94,68 @@ pub async fn execute(args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.config().max_concurrent_solves(), }, ReinstallPackages::default(), + &args.skip.clone().unwrap_or_default(), ) .await?; let installed_envs = environments - .into_iter() + .iter() .map(|env| env.name()) .collect::>(); // Message what's installed - let detached_envs_message = - if let Ok(Some(path)) = workspace.config().detached_environments().path() { - format!(" in '{}'", console::style(path.display()).bold()) - } else { - "".to_string() - }; + let mut message = console::style(console::Emoji("✔ ", "")).green().to_string(); if installed_envs.len() == 1 { - eprintln!( - "{}The {} environment has been installed{}.", - console::style(console::Emoji("✔ ", "")).green(), + write!( + &mut message, + "The {} environment has been installed", installed_envs[0].fancy_display(), - detached_envs_message - ); + ) + .unwrap(); } else { - eprintln!( - "{}The following environments have been installed: {}\t{}", - console::style(console::Emoji("✔ ", "")).green(), + write!( + &mut message, + "The following environments have been installed: {}", installed_envs.iter().map(|n| n.fancy_display()).join(", "), - detached_envs_message - ); + ) + .unwrap(); + } + + if let Ok(Some(path)) = workspace.config().detached_environments().path() { + write!( + &mut message, + " in '{}'", + console::style(path.display()).bold() + ) + .unwrap() } + if let Some(skip) = &args.skip { + let mut all_skipped_packages = std::collections::HashSet::new(); + for env in &environments { + let skipped_packages = lock_file.get_skipped_package_names(env, skip)?; + all_skipped_packages.extend(skipped_packages); + } + + if !all_skipped_packages.is_empty() { + let mut skipped_packages_vec: Vec<_> = all_skipped_packages.into_iter().collect(); + skipped_packages_vec.sort(); + write!( + &mut message, + " excluding '{}'", + skipped_packages_vec.join("', '") + ) + .unwrap(); + } else { + tracing::warn!( + "No packages were skipped. '{}' did not match any packages in the lockfile.", + skip.join("', '") + ); + } + } + + eprintln!("{}.", message); + Ok(()) } diff --git a/src/cli/reinstall.rs b/src/cli/reinstall.rs index 5a2e2e2d61..84a031ccaa 100644 --- a/src/cli/reinstall.rs +++ b/src/cli/reinstall.rs @@ -86,6 +86,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.config().max_concurrent_solves(), }, reinstall_packages.clone(), + &[], ) .await?; diff --git a/src/cli/remove.rs b/src/cli/remove.rs index 7b64e309d9..752c7602fb 100644 --- a/src/cli/remove.rs +++ b/src/cli/remove.rs @@ -125,6 +125,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; } diff --git a/src/cli/run.rs b/src/cli/run.rs index bf907d1216..dbfc8eb585 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -259,6 +259,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { &executable_task.run_environment, args.prefix_update_config.update_mode(), &ReinstallPackages::default(), + &[], ) .await?; diff --git a/src/cli/shell.rs b/src/cli/shell.rs index 86ea937484..0549885421 100644 --- a/src/cli/shell.rs +++ b/src/cli/shell.rs @@ -288,6 +288,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; let lock_file = lock_file_data.into_lock_file(); diff --git a/src/cli/shell_hook.rs b/src/cli/shell_hook.rs index 171cf428f9..640db5c0d0 100644 --- a/src/cli/shell_hook.rs +++ b/src/cli/shell_hook.rs @@ -168,6 +168,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; diff --git a/src/cli/workspace/channel/add.rs b/src/cli/workspace/channel/add.rs index bb08b0f7ec..d5ee7550f9 100644 --- a/src/cli/workspace/channel/add.rs +++ b/src/cli/workspace/channel/add.rs @@ -32,6 +32,7 @@ pub async fn execute(args: AddRemoveArgs) -> miette::Result<()> { max_concurrent_solves: workspace.workspace().config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; diff --git a/src/cli/workspace/channel/remove.rs b/src/cli/workspace/channel/remove.rs index 8b12116fbf..8eba053ebc 100644 --- a/src/cli/workspace/channel/remove.rs +++ b/src/cli/workspace/channel/remove.rs @@ -30,6 +30,7 @@ pub async fn execute(args: AddRemoveArgs) -> miette::Result<()> { max_concurrent_solves: workspace.workspace().config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; let workspace = workspace.save().await.into_diagnostic()?; diff --git a/src/cli/workspace/platform/add.rs b/src/cli/workspace/platform/add.rs index 427a8018d0..88aae34fec 100644 --- a/src/cli/workspace/platform/add.rs +++ b/src/cli/workspace/platform/add.rs @@ -57,6 +57,7 @@ pub async fn execute(workspace: Workspace, args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.workspace().config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; workspace.save().await.into_diagnostic()?; diff --git a/src/cli/workspace/platform/remove.rs b/src/cli/workspace/platform/remove.rs index 78e1266b24..a4e9520d6a 100644 --- a/src/cli/workspace/platform/remove.rs +++ b/src/cli/workspace/platform/remove.rs @@ -46,6 +46,7 @@ pub async fn execute(workspace: Workspace, args: Args) -> miette::Result<()> { max_concurrent_solves: workspace.workspace().config().max_concurrent_solves(), }, ReinstallPackages::default(), + &[], ) .await?; workspace.save().await.into_diagnostic()?; diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 8b8979fc8c..a6899b892c 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -111,28 +111,29 @@ impl LockedEnvironmentHash { pub(crate) fn from_environment( environment: rattler_lock::Environment, platform: Platform, + skipped: &[String], ) -> Self { let mut hasher = Xxh3::new(); - if let Some(packages) = environment.packages(platform) { - for package in packages { - // Always has the url or path - package.location().to_owned().to_string().hash(&mut hasher); - - match package { - // A select set of fields are used to hash the package - LockedPackageRef::Conda(pack) => { - if let Some(sha) = pack.record().sha256 { - sha.hash(&mut hasher); - } else if let Some(md5) = pack.record().md5 { - md5.hash(&mut hasher); - } - } - LockedPackageRef::Pypi(pack, env) => { - pack.editable.hash(&mut hasher); - env.extras.hash(&mut hasher); + for package in + LockFileDerivedData::filter_skipped_packages(environment.packages(platform), skipped) + { + // Always has the url or path + package.location().to_owned().to_string().hash(&mut hasher); + + match package { + // A select set of fields are used to hash the package + LockedPackageRef::Conda(pack) => { + if let Some(sha) = pack.record().sha256 { + sha.hash(&mut hasher); + } else if let Some(md5) = pack.record().md5 { + md5.hash(&mut hasher); } } + LockedPackageRef::Pypi(pack, env) => { + pack.editable.hash(&mut hasher); + env.extras.hash(&mut hasher); + } } } @@ -418,12 +419,14 @@ pub async fn get_update_lock_file_and_prefix<'env>( update_mode: UpdateMode, update_lock_file_options: UpdateLockFileOptions, reinstall_packages: ReinstallPackages, + skipped: &[String], ) -> miette::Result<(LockFileDerivedData<'env>, Prefix)> { let (lock_file, prefixes) = get_update_lock_file_and_prefixes( &[environment.clone()], update_mode, update_lock_file_options, reinstall_packages, + skipped, ) .await?; Ok(( @@ -442,6 +445,7 @@ pub async fn get_update_lock_file_and_prefixes<'env>( update_mode: UpdateMode, update_lock_file_options: UpdateLockFileOptions, reinstall_packages: ReinstallPackages, + skipped: &[String], ) -> miette::Result<(LockFileDerivedData<'env>, Vec)> { if environments.is_empty() { return Err(miette::miette!("No environments provided to install.")); @@ -487,7 +491,7 @@ pub async fn get_update_lock_file_and_prefixes<'env>( std::future::ready(Ok(Prefix::new(env.dir()))).left_future() } else { lock_file_ref - .prefix(env, update_mode, reinstall_packages) + .prefix(env, update_mode, reinstall_packages, skipped) .right_future() } }) diff --git a/src/lib.rs b/src/lib.rs index cad84fc472..c5a28aad16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,7 @@ pub mod environment; mod global; mod install_pypi; pub mod lock_file; -mod prefix; +pub mod prefix; mod prompt; pub(crate) mod repodata; pub mod task; diff --git a/src/lock_file/update.rs b/src/lock_file/update.rs index 72511c5269..210a83551d 100644 --- a/src/lock_file/update.rs +++ b/src/lock_file/update.rs @@ -43,9 +43,7 @@ use pypi_mapping::{self, MappingClient}; use pypi_modifiers::pypi_marker_env::determine_marker_environment; use rattler::package_cache::PackageCache; use rattler_conda_types::{Arch, GenericVirtualPackage, PackageName, Platform}; -use rattler_lock::{ - LockFile, ParseCondaLockError, PypiIndexes, PypiPackageData, PypiPackageEnvironmentData, -}; +use rattler_lock::{LockFile, LockedPackageRef, ParseCondaLockError}; use std::{ cmp::PartialEq, collections::{HashMap, HashSet}, @@ -305,6 +303,7 @@ impl<'p> LockFileDerivedData<'p> { fn locked_environment_hash( &self, environment: &Environment<'p>, + skipped: &[String], ) -> miette::Result { let locked_environment = self .lock_file @@ -313,6 +312,7 @@ impl<'p> LockFileDerivedData<'p> { Ok(LockedEnvironmentHash::from_environment( locked_environment, environment.best_platform(), + skipped, )) } @@ -322,10 +322,11 @@ impl<'p> LockFileDerivedData<'p> { environment: &Environment<'p>, update_mode: UpdateMode, reinstall_packages: &ReinstallPackages, + skipped: &[String], ) -> miette::Result { // Check if the prefix is already up-to-date by validating the hash with the // environment file - let hash = self.locked_environment_hash(environment)?; + let hash = self.locked_environment_hash(environment, skipped)?; if update_mode == UpdateMode::QuickValidate { if let Some(prefix) = self.cached_prefix(environment, &hash) { return prefix; @@ -333,7 +334,9 @@ impl<'p> LockFileDerivedData<'p> { } // Get the up-to-date prefix - let prefix = self.update_prefix(environment, reinstall_packages).await?; + let prefix = self + .update_prefix(environment, reinstall_packages, skipped) + .await?; // Save an environment file to the environment directory after the update. // Avoiding writing the cache away before the update is done. @@ -403,6 +406,7 @@ impl<'p> LockFileDerivedData<'p> { &self, environment: &Environment<'p>, reinstall_packages: &ReinstallPackages, + skipped: &[String], ) -> miette::Result { let prefix_once_cell = self .updated_pypi_prefixes @@ -426,9 +430,22 @@ impl<'p> LockFileDerivedData<'p> { ))?; let platform = environment.best_platform(); - let pixi_records = self - .pixi_records(environment, platform)? - .unwrap_or_default(); + let locked_env = self.locked_env(environment)?; + let packages = + Self::filter_skipped_packages(locked_env.packages(platform), skipped); + + // Separate the packages into conda and pypi packages + let (conda_packages, pypi_packages) = packages + .into_iter() + .partition::, _>(|p| p.as_conda().is_some()); + + let pixi_records = locked_packages_to_pixi_records(conda_packages)?; + + let pypi_records = pypi_packages + .into_iter() + .filter_map(LockedPackageRef::as_pypi) + .map(|(data, env_data)| (data.clone(), env_data.clone())) + .collect::>(); let conda_reinstall_packages = match reinstall_packages { ReinstallPackages::None => None, @@ -445,13 +462,9 @@ impl<'p> LockFileDerivedData<'p> { // Get the prefix with the conda packages installed. let (prefix, python_status) = self - .conda_prefix(environment, conda_reinstall_packages) + .conda_prefix(environment, conda_reinstall_packages, skipped) .await?; - let pypi_records = self - .pypi_records(environment, platform)? - .unwrap_or_default(); - // No `uv` support for WASM right now if platform.arch() == Some(Arch::Wasm32) { return Ok(prefix); @@ -508,7 +521,7 @@ impl<'p> LockFileDerivedData<'p> { // Update the prefix with Pypi records { - let pypi_indexes = self.pypi_indexes(environment)?; + let pypi_indexes = self.locked_env(environment)?.pypi_indexes().cloned(); let config = PyPIUpdateConfig { environment_name: environment.name(), @@ -553,58 +566,36 @@ impl<'p> LockFileDerivedData<'p> { .cloned() } - fn pypi_records( + fn locked_env( &self, environment: &Environment<'p>, - platform: Platform, - ) -> Result>, UpdateError> { - let locked_env = self - .lock_file - .environment(environment.name().as_str()) - .ok_or_else(|| UpdateError::LockFileMissingEnv(environment.name().clone()))?; - - let packages = locked_env.pypi_packages(platform); - Ok(packages.map(|iter| { - iter.map(|(data, env_data)| (data.clone(), env_data.clone())) - .collect() - })) - } - - fn pypi_indexes( - &self, - environment: &Environment<'p>, - ) -> Result, UpdateError> { - let locked_env = self - .lock_file + ) -> Result, UpdateError> { + self.lock_file .environment(environment.name().as_str()) - .ok_or_else(|| UpdateError::LockFileMissingEnv(environment.name().clone()))?; - Ok(locked_env.pypi_indexes().cloned()) + .ok_or_else(|| UpdateError::LockFileMissingEnv(environment.name().clone())) } - fn pixi_records( - &self, - environment: &Environment<'p>, - platform: Platform, - ) -> Result>, UpdateError> { - let locked_env = self - .lock_file - .environment(environment.name().as_str()) - .ok_or_else(|| UpdateError::LockFileMissingEnv(environment.name().clone()))?; - - Ok(locked_env - .conda_packages(platform) - .map(|iter| { - iter.cloned() - .map(PixiRecord::try_from) - .collect::, _>>() - }) - .transpose()?) + /// Filters out packages that are in the `skipped` list. + pub fn filter_skipped_packages<'lock>( + packages: Option< + impl DoubleEndedIterator> + ExactSizeIterator + 'lock, + >, + skipped: &'lock [String], + ) -> Vec> { + match (packages, skipped.is_empty()) { + (Some(packages), true) => packages.collect(), + (Some(packages), false) => packages + .filter(|p| !skipped.iter().any(|s| s == p.name())) + .collect(), + (None, _) => Vec::new(), + } } async fn conda_prefix( &self, environment: &Environment<'p>, reinstall_packages: Option>, + skipped: &[String], ) -> miette::Result<(Prefix, PythonStatus)> { // If we previously updated this environment, early out. let prefix_once_cell = self @@ -631,9 +622,11 @@ impl<'p> LockFileDerivedData<'p> { .finish()?; // Get the locked environment from the lock-file. - let records = self - .pixi_records(environment, platform)? - .unwrap_or_default(); + let locked_env = self.locked_env(environment)?; + let packages = + Self::filter_skipped_packages(locked_env.packages(platform), skipped); + let records = locked_packages_to_pixi_records(packages)?; + // Update the conda prefix let CondaPrefixUpdated { prefix, @@ -648,6 +641,50 @@ impl<'p> LockFileDerivedData<'p> { .await .map(|(prefix, python_status)| (prefix.clone(), python_status.clone())) } + + /// Returns a list of matching pypi or conda package names in the lock file + /// that are also present in the `skipped` list. + pub fn get_skipped_package_names( + &self, + environment: &Environment<'p>, + skipped: &[String], + ) -> miette::Result> { + let platform = environment.best_platform(); + let locked_env = self.locked_env(environment)?; + + // Get all package names + let all_package_names: HashSet = locked_env + .packages(platform) + .into_iter() + .flat_map(|p| p.map(|p| p.name().to_string())) + .collect(); + + // Get kept package names + let kept_package_names: HashSet = + Self::filter_skipped_packages(locked_env.packages(platform), skipped) + .into_iter() + .map(|p| p.name().to_string()) + .collect(); + + Ok(all_package_names + .difference(&kept_package_names) + .cloned() + .sorted() + .collect()) + } +} + +fn locked_packages_to_pixi_records( + conda_packages: Vec>, +) -> Result, Report> { + let pixi_records = conda_packages + .into_iter() + .filter_map(LockedPackageRef::as_conda) + .cloned() + .map(PixiRecord::try_from) + .collect::, _>>() + .into_diagnostic()?; + Ok(pixi_records) } pub struct UpdateContext<'p> { diff --git a/src/prefix.rs b/src/prefix.rs index 531cca7d83..e61afe41fc 100644 --- a/src/prefix.rs +++ b/src/prefix.rs @@ -37,7 +37,7 @@ pub struct Prefix { impl Prefix { /// Constructs a new instance. - pub(crate) fn new(path: impl Into) -> Self { + pub fn new(path: impl Into) -> Self { let root = path.into(); Self { root } } diff --git a/src/workspace/workspace_mut.rs b/src/workspace/workspace_mut.rs index 098846f0f6..6ed9223256 100644 --- a/src/workspace/workspace_mut.rs +++ b/src/workspace/workspace_mut.rs @@ -429,6 +429,7 @@ impl WorkspaceMut { &self.workspace().default_environment(), UpdateMode::Revalidate, &ReinstallPackages::default(), + &[], ) .await?; } diff --git a/tests/integration_rust/common/builders.rs b/tests/integration_rust/common/builders.rs index 65405fbefe..03dc910636 100644 --- a/tests/integration_rust/common/builders.rs +++ b/tests/integration_rust/common/builders.rs @@ -461,6 +461,10 @@ impl InstallBuilder { self.args.lock_file_usage.frozen = true; self } + pub fn with_skipped(mut self, names: Vec) -> Self { + self.args.skip = Some(names); + self + } } impl IntoFuture for InstallBuilder { diff --git a/tests/integration_rust/common/logging.rs b/tests/integration_rust/common/logging.rs new file mode 100644 index 0000000000..e48dbdc3f9 --- /dev/null +++ b/tests/integration_rust/common/logging.rs @@ -0,0 +1,65 @@ +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; + +use tracing_subscriber::{ + filter::LevelFilter, + fmt::{self, writer::MakeWriter}, +}; + +/// A mock writer that can be used to capture logs in tests. +#[derive(Clone)] +pub struct MockWriter { + buf: Arc>>, +} + +impl MockWriter { + /// Creates a new `MockWriter`. + pub fn new() -> Self { + Self { + buf: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Returns the captured output as a string. + pub fn get_output(&self) -> String { + let buf = self.buf.lock().unwrap(); + String::from_utf8_lossy(&buf).to_string() + } +} + +impl Write for MockWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().unwrap().flush() + } +} + +impl<'a> MakeWriter<'a> for MockWriter { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +/// Initializes a tracing subscriber for tests. +/// This subscriber will capture all logs and store them in a buffer. +pub fn try_init_test_subscriber() -> MockWriter { + let writer = MockWriter::new(); + let subscriber = fmt::Subscriber::builder() + .with_max_level(LevelFilter::WARN) + .with_writer(writer.clone()) + .finish(); + + // Try to set the global default subscriber. This may fail if a subscriber has + // already been set. This is fine, we just won't capture any logs in that + // case. + let _ = tracing::subscriber::set_global_default(subscriber); + + writer +} diff --git a/tests/integration_rust/common/mod.rs b/tests/integration_rust/common/mod.rs index 0489c17a00..57ad1a4d4a 100644 --- a/tests/integration_rust/common/mod.rs +++ b/tests/integration_rust/common/mod.rs @@ -2,6 +2,7 @@ pub mod builders; pub mod client; +pub mod logging; pub mod package_database; use std::{ @@ -531,6 +532,7 @@ impl PixiControl { &task.run_environment, UpdateMode::Revalidate, &ReinstallPackages::default(), + &[], ) .await?; let env = @@ -573,6 +575,7 @@ impl PixiControl { }, config: Default::default(), all: false, + skip: None, }, } } diff --git a/tests/integration_rust/install_tests.rs b/tests/integration_rust/install_tests.rs index fd93dcd59b..fe79b6f366 100644 --- a/tests/integration_rust/install_tests.rs +++ b/tests/integration_rust/install_tests.rs @@ -28,6 +28,7 @@ use tempfile::{TempDir, tempdir}; use tokio::{fs, task::JoinSet}; use url::Url; use uv_configuration::RAYON_INITIALIZE; +use uv_pep508::PackageName; use uv_python::PythonEnvironment; use crate::common::{ @@ -35,6 +36,7 @@ use crate::common::{ builders::{ HasDependencyConfig, HasLockFileUpdateConfig, HasPrefixUpdateConfig, string_from_iter, }, + logging::try_init_test_subscriber, package_database::{Package, PackageDatabase}, }; @@ -322,6 +324,112 @@ fn create_uv_environment(prefix: &Path, cache: &uv_cache::Cache) -> PythonEnviro uv_python::PythonEnvironment::from_interpreter(interpreter) } +// Helper to check if a pypi package is installed. +fn is_pypi_package_installed(env: &PythonEnvironment, package_name: &str) -> bool { + let site_packages = uv_installer::SitePackages::from_environment(env).unwrap(); + let found_packages = site_packages.get_packages(&PackageName::from_str(package_name).unwrap()); + !found_packages.is_empty() +} + +// Helper to check if a conda package is installed. +async fn is_conda_package_installed(prefix_path: &Path, package_name: &str) -> bool { + let conda_prefix = pixi::prefix::Prefix::new(prefix_path.to_path_buf()); + conda_prefix + .find_designated_package(&rattler_conda_types::PackageName::try_from(package_name).unwrap()) + .await + .is_ok() +} + +/// Test `pixi install --frozen --skip` functionality +#[tokio::test] +#[cfg_attr(not(feature = "slow_integration_tests"), ignore)] +async fn install_frozen_skip() { + // Create a project with a local python dependency 'no-build-editable' + // and a local conda dependency 'python_rich' + let current_platform = Platform::current(); + let manifest = format!( + r#" + [workspace] + channels = ["conda-forge"] + description = "Add a short description here" + name = "pyproject" + platforms = ["{platform}"] + preview = ["pixi-build"] + version = "0.1.0" + + [dependencies] + python = "*" + python_rich = {{ path = "./python" }} + + [pypi-dependencies] + no-build-editable = {{ path = "./no-build-editable" }} + "#, + platform = current_platform, + ); + + let pixi = PixiControl::from_manifest(&manifest).expect("cannot instantiate pixi project"); + + fs_extra::dir::copy( + "docs/source_files/pixi_workspaces/pixi_build/python", + pixi.workspace_path(), + &fs_extra::dir::CopyOptions::new(), + ) + .unwrap(); + + fs_extra::dir::copy( + "tests/data/satisfiability/no-build-editable", + pixi.workspace_path(), + &fs_extra::dir::CopyOptions::new(), + ) + .unwrap(); + + pixi.update_lock_file().await.unwrap(); + + // Check that neither 'python_rich' nor 'no-build-editable' are installed when skipped + pixi.install() + .with_frozen() + .with_skipped(vec!["no-build-editable".into(), "python_rich".into()]) + .await + .unwrap(); + + let prefix_path = pixi.default_env_path().unwrap(); + let cache = uv_cache::Cache::temp().unwrap(); + let env = create_uv_environment(&prefix_path, &cache); + + assert!(!is_pypi_package_installed(&env, "no-build-editable")); + assert!(!is_conda_package_installed(&prefix_path, "python_rich").await); + + // Check that 'no-build-editable' and 'python_rich' are installed after a followup normal install + pixi.install().with_frozen().await.unwrap(); + + assert!(is_pypi_package_installed(&env, "no-build-editable")); + assert!(is_conda_package_installed(&prefix_path, "python_rich").await); +} + +/// Test `pixi install --frozen --skip` functionality with a non existing package +#[tokio::test] +#[cfg_attr(not(feature = "slow_integration_tests"), ignore)] +async fn install_skip_non_existent_package_warning() { + let pixi = PixiControl::new().unwrap(); + pixi.init().await.unwrap(); + // Add a dependency to create a lock file + pixi.add("python").await.unwrap(); + + let log_buffer = try_init_test_subscriber(); + + // Install with a skipped package that doesn't exist in the lock file + pixi.install() + .with_frozen() + .with_skipped(vec!["non-existent-package".to_string()]) + .await + .unwrap(); + + let output = log_buffer.get_output(); + assert!(output.contains( + "No packages were skipped. 'non-existent-package' did not match any packages in the lockfile." + )); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[cfg_attr(not(feature = "slow_integration_tests"), ignore)] async fn pypi_reinstall_python() { @@ -601,6 +709,7 @@ async fn test_old_lock_install() { ..Default::default() }, ReinstallPackages::default(), + &[], ) .await .unwrap();