From 30e39de4f9b01e3c75c60f941b559bcd3c0e2ee8 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Fri, 23 Jun 2023 17:02:05 +0200 Subject: [PATCH 1/9] test: incremental lock file --- Cargo.lock | 2 + Cargo.toml | 4 + src/project/mod.rs | 70 ++++++++++++++++- tests/common/mod.rs | 23 ++++++ tests/common/repodata.rs | 166 +++++++++++++++++++++++++++++++++++++++ tests/install_tests.rs | 113 ++++++++++++++++++++++++++ 6 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 tests/common/repodata.rs diff --git a/Cargo.lock b/Cargo.lock index 564eac20c..3ecda0aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2928,6 +2928,7 @@ dependencies = [ "once_cell", "rattler", "rattler_conda_types", + "rattler_digest", "rattler_networking", "rattler_repodata_gateway", "rattler_shell", @@ -2935,6 +2936,7 @@ dependencies = [ "rattler_virtual_packages", "reqwest", "serde", + "serde_json", "serde_spanned", "serde_with", "shlex", diff --git a/Cargo.toml b/Cargo.toml index 2ba0ef56b..e113aeacb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,10 @@ tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = "2.4.0" +[dev-dependencies] +serde_json = "1.0.96" +rattler_digest = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } + [profile.release-lto] inherits = "release" lto = true diff --git a/src/project/mod.rs b/src/project/mod.rs index e2549a5dd..72dbfa2e5 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -6,16 +6,18 @@ use crate::consts; use crate::consts::PROJECT_MANIFEST; use crate::project::manifest::{ProjectManifest, TargetMetadata, TargetSelector}; use crate::report_error::ReportError; -use anyhow::Context; +use anyhow::{Context}; use ariadne::{Label, Report, ReportKind, Source}; -use rattler_conda_types::{Channel, MatchSpec, NamelessMatchSpec, Platform, Version}; +use rattler_conda_types::{ + Channel, ChannelConfig, MatchSpec, NamelessMatchSpec, Platform, Version, +}; use rattler_virtual_packages::VirtualPackage; use std::{ collections::HashMap, env, fs, path::{Path, PathBuf}, }; -use toml_edit::{Document, Item, Table, TomlError}; +use toml_edit::{Array, Document, Item, Table, TomlError, Value}; /// A project represented by a pixi.toml file. #[derive(Debug)] @@ -212,6 +214,68 @@ impl Project { &self.manifest.project.channels } + /// Adds the specified channels to the project. + pub fn add_channels( + &mut self, + channels: impl IntoIterator>, + ) -> anyhow::Result<()> { + let mut stored_channels = Vec::new(); + for channel in channels { + self.manifest.project.channels.push(Channel::from_str( + channel.as_ref(), + &ChannelConfig::default(), + )?); + stored_channels.push(channel.as_ref().to_owned()); + } + + let channels_array = self.channels_array_mut()?; + for channel in stored_channels { + channels_array.push(channel); + } + + Ok(()) + } + + /// Replaces all the channels in the project with the specified channels. + pub fn set_channels( + &mut self, + channels: impl IntoIterator>, + ) -> anyhow::Result<()> { + self.manifest.project.channels.clear(); + let mut stored_channels = Vec::new(); + for channel in channels { + self.manifest.project.channels.push(Channel::from_str( + channel.as_ref(), + &ChannelConfig::default(), + )?); + stored_channels.push(channel.as_ref().to_owned()); + } + + let channels_array = self.channels_array_mut()?; + channels_array.clear(); + for channel in stored_channels { + channels_array.push(channel); + } + Ok(()) + } + + /// Returns a mutable reference to the channels array. + fn channels_array_mut(&mut self) -> anyhow::Result<&mut Array> { + let project = &mut self.doc["project"]; + if project.is_none() { + *project = Item::Table(Table::new()); + } + + let channels = &mut project["chanels"]; + if channels.is_none() { + *channels = Item::Value(Value::Array(Array::new())) + } + + channels + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("malformed channels array")) + } + /// Returns the platforms this project targets pub fn platforms(&self) -> &[Platform] { self.manifest.project.platforms.as_ref().as_slice() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 61b4a2578..dbc7e9521 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,6 @@ +pub mod repodata; + +use crate::common::repodata::ChannelBuilder; use pixi::cli::{add, init, run}; use pixi::Project; use rattler_conda_types::conda_lock::CondaLock; @@ -5,6 +8,7 @@ use rattler_conda_types::{MatchSpec, Version}; use std::path::Path; use std::str::FromStr; use tempfile::TempDir; +use url::Url; /// To control the pixi process pub struct PixiControl { @@ -13,6 +17,9 @@ pub struct PixiControl { /// The project that could be worked on project: Option, + + /// Additional temporary directories to release when this is dropped. + extra_temp_dirs: Vec, } pub struct RunResult { @@ -69,6 +76,7 @@ impl PixiControl { Ok(PixiControl { tmpdir: tempdir, project: None, + extra_temp_dirs: Vec::new(), }) } @@ -135,4 +143,19 @@ impl PixiControl { pub async fn lock_file(&self) -> anyhow::Result { pixi::environment::load_lock_file(self.project()).await } + + /// Set the project to use a specific channel + pub async fn set_channel(&mut self, builder: ChannelBuilder) -> anyhow::Result<()> { + // Construct the fake channel + let channel_dir = builder.write_to_disk().await?; + + let url = + Url::from_directory_path(channel_dir.path()).expect("failed to create directory URL"); + + self.project_mut().set_channels([url.as_str()])?; + + self.extra_temp_dirs.push(channel_dir); + + Ok(()) + } } diff --git a/tests/common/repodata.rs b/tests/common/repodata.rs new file mode 100644 index 000000000..e7c9e05ef --- /dev/null +++ b/tests/common/repodata.rs @@ -0,0 +1,166 @@ +use itertools::Either; +use rattler_conda_types::package::ArchiveType; +use rattler_conda_types::{ChannelInfo, PackageRecord, Platform, RepoData, Version}; +use std::iter; + +#[derive(Default, Clone, Debug)] +pub struct ChannelBuilder { + subdirs: Vec, +} + +impl ChannelBuilder { + pub fn with_subdir(mut self, subdir: SubdirBuilder) -> Self { + self.subdirs.push(subdir); + self + } + + /// Writes the channel to disk + pub async fn write_to_disk(&self) -> anyhow::Result { + let dir = tempfile::TempDir::new()?; + + let empty_noarch = SubdirBuilder::new(Platform::NoArch); + + let subdirs = if self + .subdirs + .iter() + .any(|subdir| subdir.platform == Platform::NoArch) + { + Either::Left(self.subdirs.iter()) + } else { + Either::Right(self.subdirs.iter().chain(iter::once(&empty_noarch))) + }; + + for subdir in subdirs { + let subdir_path = dir.path().join(subdir.platform.as_str()); + + let repodata = RepoData { + info: Some(ChannelInfo { + subdir: subdir.platform.to_string(), + }), + packages: subdir + .packages + .iter() + .filter(|pkg| pkg.archive_type == ArchiveType::TarBz2) + .map(|pkg| pkg.as_package_record(subdir.platform)) + .collect(), + conda_packages: subdir + .packages + .iter() + .filter(|pkg| pkg.archive_type == ArchiveType::Conda) + .map(|pkg| pkg.as_package_record(subdir.platform)) + .collect(), + removed: Default::default(), + version: Some(1), + }; + let repodata_str = serde_json::to_string_pretty(&repodata)?; + + tokio::fs::create_dir_all(&subdir_path).await?; + tokio::fs::write(subdir_path.join("repodata.json"), repodata_str).await?; + } + + Ok(dir) + } +} + +#[derive(Clone, Debug)] +pub struct SubdirBuilder { + packages: Vec, + platform: Platform, +} + +impl SubdirBuilder { + pub fn new(platform: Platform) -> Self { + Self { + platform, + packages: vec![], + } + } + + pub fn with_package(mut self, package: PackageBuilder) -> Self { + self.packages.push(package); + self + } +} + +#[derive(Clone, Debug)] +pub struct PackageBuilder { + name: String, + version: Version, + build_string: String, + depends: Vec, + archive_type: ArchiveType, +} + +impl PackageBuilder { + pub fn new(package: impl Into, version: &str) -> Self { + Self { + name: package.into(), + version: version.parse().expect("invalid version"), + build_string: String::from("0"), + depends: vec![], + archive_type: ArchiveType::Conda, + } + } + + #[allow(dead_code)] + pub fn with_build_string(mut self, build_string: impl Into) -> Self { + self.build_string = build_string.into(); + debug_assert!(!self.build_string.is_empty()); + self + } + + #[allow(dead_code)] + pub fn with_archive_type(mut self, archive_type: ArchiveType) -> Self { + self.archive_type = archive_type; + self + } + + pub fn with_dependency(mut self, spec: impl Into) -> Self { + self.depends.push(spec.into()); + self + } + + pub fn as_package_record(&self, platform: Platform) -> (String, PackageRecord) { + // Construct the package filename + let filename = format!( + "{}-{}-{}.{}", + self.name.to_lowercase(), + &self.version, + &self.build_string, + match self.archive_type { + ArchiveType::TarBz2 => "tar.bz2", + ArchiveType::Conda => "conda", + } + ); + + // Construct the record + let record = PackageRecord { + // TODO: This is wrong, we should extract this from platform + arch: None, + platform: None, + + build: self.build_string.clone(), + build_number: 0, + constrains: vec![], + depends: self.depends.clone(), + features: None, + legacy_bz2_md5: None, + legacy_bz2_size: None, + license: None, + license_family: None, + md5: Some(rattler_digest::compute_bytes_digest::(&filename)), + name: self.name.clone(), + noarch: Default::default(), + sha256: Some(rattler_digest::compute_bytes_digest::( + &filename, + )), + size: None, + subdir: platform.to_string(), + timestamp: None, + track_features: vec![], + version: self.version.clone(), + }; + + (filename, record) + } +} diff --git a/tests/install_tests.rs b/tests/install_tests.rs index b41e9fa2b..0bb3028fb 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -1,6 +1,11 @@ mod common; +use crate::common::repodata::{ChannelBuilder, PackageBuilder, SubdirBuilder}; use common::{LockFileExt, PixiControl}; +use pixi::Project; +use rattler_conda_types::{Platform, Version}; +use std::str::FromStr; +use tempfile::TempDir; /// Should add a python version to the environment and lock file that matches the specified version /// and run it @@ -20,3 +25,111 @@ async fn install_run_python() { assert!(result.success()); assert_eq!(result.stdout(), "Python 3.11.0\n"); } + +#[tokio::test] +async fn init_creates_project_manifest() { + let tmp_dir = TempDir::new().unwrap(); + + // Run the init command + pixi::cli::init::execute(pixi::cli::init::Args { + path: tmp_dir.path().to_path_buf(), + }) + .await + .unwrap(); + + // There should be a loadable project manifest in the directory + let project = Project::load(&tmp_dir.path().join(pixi::consts::PROJECT_MANIFEST)).unwrap(); + + // Default configuration should be present in the file + assert!(!project.name().is_empty()); + assert_eq!(project.version(), &Version::from_str("0.1.0").unwrap()); +} + +#[tokio::test] +async fn test_custom_channel() { + let mut pixi = PixiControl::new().unwrap(); + + // Create a new project + pixi.init().await.unwrap(); + + // Set the channel to something we created + pixi.set_channel( + ChannelBuilder::default().with_subdir( + SubdirBuilder::new(Platform::current()) + .with_package( + PackageBuilder::new("foo", "1") + .with_dependency("bar >=1") + .with_build_string("helloworld"), + ) + .with_package(PackageBuilder::new("bar", "1")), + ), + ) + .await + .unwrap(); + + // Add a dependency on `foo` + pixi.add(["foo"]).await.unwrap(); + + // Get the created lock-file + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_matchspec("foo==1=helloworld")); +} + +#[tokio::test] +async fn test_incremental_lock_file() { + let mut pixi = PixiControl::new().unwrap(); + + // Create a new project + pixi.init().await.unwrap(); + + // Set the channel to something we created + pixi.set_channel( + ChannelBuilder::default().with_subdir( + SubdirBuilder::new(Platform::current()) + .with_package( + PackageBuilder::new("foo", "1") + .with_dependency("bar >=1") + .with_build_string("helloworld"), + ) + .with_package(PackageBuilder::new("bar", "1")), + ), + ) + .await + .unwrap(); + + // Add a dependency on `foo` + pixi.add(["foo"]).await.unwrap(); + + // Get the created lock-file + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_matchspec("foo==1=helloworld")); + + // Update the channel, add a version 2 for both foo and bar. + pixi.set_channel( + ChannelBuilder::default().with_subdir( + SubdirBuilder::new(Platform::current()) + .with_package( + PackageBuilder::new("foo", "1") + .with_dependency("bar >=1") + .with_build_string("helloworld"), + ) + .with_package( + PackageBuilder::new("foo", "2") + .with_dependency("bar >=1") + .with_build_string("awholenewworld"), + ) + .with_package(PackageBuilder::new("bar", "1")) + .with_package(PackageBuilder::new("bar", "2")), + ), + ) + .await + .unwrap(); + + // Change the dependency on `foo` to use version 2. + pixi.add(["foo >=2"]).await.unwrap(); + + // Get the created lock-file, `foo` should have been updated, but `bar` should remain on v1. + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_matchspec("foo==2=awholenewworld")); + assert!(lock.contains_matchspec("bar==1")); +} From bc15a5d752cfd84bf7b503282b4d922d98909339 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Mon, 26 Jun 2023 11:32:59 +0200 Subject: [PATCH 2/9] fix: added PackageDatabase --- Cargo.lock | 20 +-- src/cli/mod.rs | 2 +- src/project/mod.rs | 2 +- tests/common/mod.rs | 23 +--- tests/common/package_database.rs | 218 +++++++++++++++++++++++++++++++ tests/common/repodata.rs | 166 ----------------------- tests/install_tests.rs | 130 ++++++++---------- 7 files changed, 293 insertions(+), 268 deletions(-) create mode 100644 tests/common/package_database.rs delete mode 100644 tests/common/repodata.rs diff --git a/Cargo.lock b/Cargo.lock index 3ecda0aa4..15c6d75cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "rattler" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "anyhow", "apple-codesign", @@ -3203,7 +3203,7 @@ dependencies = [ [[package]] name = "rattler_conda_types" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "chrono", "fxhash", @@ -3231,7 +3231,7 @@ dependencies = [ [[package]] name = "rattler_digest" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "blake2", "digest 0.10.7", @@ -3246,7 +3246,7 @@ dependencies = [ [[package]] name = "rattler_macros" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "quote", "syn 2.0.18", @@ -3255,7 +3255,7 @@ dependencies = [ [[package]] name = "rattler_networking" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "anyhow", "dirs 5.0.1", @@ -3272,7 +3272,7 @@ dependencies = [ [[package]] name = "rattler_package_streaming" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "bzip2", "chrono", @@ -3295,7 +3295,7 @@ dependencies = [ [[package]] name = "rattler_repodata_gateway" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "anyhow", "async-compression", @@ -3333,7 +3333,7 @@ dependencies = [ [[package]] name = "rattler_shell" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "enum_dispatch", "indexmap", @@ -3348,7 +3348,7 @@ dependencies = [ [[package]] name = "rattler_solve" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "anyhow", "cc", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "rattler_virtual_packages" version = "0.4.0" -source = "git+https://github.com/mamba-org/rattler?branch=main#bb834bcca8a58411cdca20f2bc8f4711380c6bd7" +source = "git+https://github.com/mamba-org/rattler?branch=main#6336f612545a003ad9c7b377f5fa16524ff1ef36" dependencies = [ "cfg-if", "libloading", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1e24b92d0..16cf8fcf8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,9 +11,9 @@ pub mod add; pub mod auth; pub mod global; pub mod init; +pub mod install; pub mod run; pub mod shell; -pub mod install; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] diff --git a/src/project/mod.rs b/src/project/mod.rs index 72dbfa2e5..955b10143 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -6,7 +6,7 @@ use crate::consts; use crate::consts::PROJECT_MANIFEST; use crate::project::manifest::{ProjectManifest, TargetMetadata, TargetSelector}; use crate::report_error::ReportError; -use anyhow::{Context}; +use anyhow::Context; use ariadne::{Label, Report, ReportKind, Source}; use rattler_conda_types::{ Channel, ChannelConfig, MatchSpec, NamelessMatchSpec, Platform, Version, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dbc7e9521..ce7496ef4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,5 @@ -pub mod repodata; +pub mod package_database; -use crate::common::repodata::ChannelBuilder; use pixi::cli::{add, init, run}; use pixi::Project; use rattler_conda_types::conda_lock::CondaLock; @@ -8,7 +7,6 @@ use rattler_conda_types::{MatchSpec, Version}; use std::path::Path; use std::str::FromStr; use tempfile::TempDir; -use url::Url; /// To control the pixi process pub struct PixiControl { @@ -17,9 +15,6 @@ pub struct PixiControl { /// The project that could be worked on project: Option, - - /// Additional temporary directories to release when this is dropped. - extra_temp_dirs: Vec, } pub struct RunResult { @@ -76,7 +71,6 @@ impl PixiControl { Ok(PixiControl { tmpdir: tempdir, project: None, - extra_temp_dirs: Vec::new(), }) } @@ -145,17 +139,10 @@ impl PixiControl { } /// Set the project to use a specific channel - pub async fn set_channel(&mut self, builder: ChannelBuilder) -> anyhow::Result<()> { - // Construct the fake channel - let channel_dir = builder.write_to_disk().await?; - - let url = - Url::from_directory_path(channel_dir.path()).expect("failed to create directory URL"); - - self.project_mut().set_channels([url.as_str()])?; - - self.extra_temp_dirs.push(channel_dir); - + pub async fn set_channel(&mut self, channel: impl ToString) -> anyhow::Result<()> { + let project = self.project_mut(); + project.set_channels(&[channel.to_string()])?; + project.save()?; Ok(()) } } diff --git a/tests/common/package_database.rs b/tests/common/package_database.rs new file mode 100644 index 000000000..259ee1843 --- /dev/null +++ b/tests/common/package_database.rs @@ -0,0 +1,218 @@ +//! This modules defines [`PackageDatabase`], a struct that holds a bunch of easily constructable +//! package definitions. Using this struct it becomes easier to generate controllable fake repodata. + +// There are a bunch of functions that remain unused in tests but might be useful in the future. +#![allow(dead_code)] + +use itertools::Itertools; +use rattler_conda_types::{ + package::ArchiveType, ChannelInfo, PackageRecord, Platform, RepoData, Version, +}; +use std::{collections::HashSet, path::Path}; + +/// A database of packages +#[derive(Default, Clone, Debug)] +pub struct PackageDatabase { + packages: Vec, +} + +impl PackageDatabase { + /// Adds a package to the database + pub fn with_package(mut self, package: Package) -> Self { + self.packages.push(package); + self + } + + /// Adds a package to the database + pub fn add_package(&mut self, package: Package) { + self.packages.push(package); + } + + /// Writes the repodata of this instance to the specified channel directory + pub async fn write_repodata(&self, channel_path: &Path) -> anyhow::Result<()> { + let mut platforms = self.platforms(); + + // Make sure NoArch is always included + if !platforms.contains(&Platform::NoArch) { + platforms.insert(Platform::NoArch); + } + + // Make sure the current platform is included + let current_platform = Platform::current(); + if !platforms.contains(¤t_platform) { + platforms.insert(current_platform); + } + + for platform in platforms { + let subdir_path = channel_path.join(platform.as_str()); + + let repodata = RepoData { + info: Some(ChannelInfo { + subdir: platform.to_string(), + }), + packages: self + .packages_by_platform(platform) + .filter(|pkg| pkg.archive_type == ArchiveType::TarBz2) + .map(|pkg| (pkg.file_name(), pkg.package_record.clone())) + .sorted_by(|a, b| a.0.cmp(&b.0)) + .collect(), + conda_packages: self + .packages_by_platform(platform) + .filter(|pkg| pkg.archive_type == ArchiveType::Conda) + .map(|pkg| (pkg.file_name(), pkg.package_record.clone())) + .sorted_by(|a, b| a.0.cmp(&b.0)) + .collect(), + removed: Default::default(), + version: Some(1), + }; + let repodata_str = serde_json::to_string_pretty(&repodata)?; + + tokio::fs::create_dir_all(&subdir_path).await?; + tokio::fs::write(subdir_path.join("repodata.json"), repodata_str).await?; + } + + Ok(()) + } + + /// Returns all packages for the specified platform. + pub fn packages_by_platform( + &self, + platform: Platform, + ) -> impl Iterator + '_ { + self.packages + .iter() + .filter(move |pkg| pkg.subdir == platform) + } + + /// Returns all the platforms that this database has packages for + pub fn platforms(&self) -> HashSet { + self.packages.iter().map(|pkg| pkg.subdir).collect() + } +} + +/// Description of a package. +#[derive(Clone, Debug)] +pub struct Package { + package_record: PackageRecord, + subdir: Platform, + archive_type: ArchiveType, +} + +// Implement `AsRef` for a `PackageRecord` allows using `Package` in a number of algorithms used in +// `rattler_conda_types`. +impl AsRef for Package { + fn as_ref(&self) -> &PackageRecord { + &self.package_record + } +} + +/// A builder for a [`Package`] +pub struct PackageBuilder { + name: String, + version: Version, + build: Option, + build_number: Option, + depends: Vec, + subdir: Option, + archive_type: ArchiveType, +} + +impl Package { + /// Constructs a new [`Package`]. + pub fn build(name: impl ToString, version: &str) -> PackageBuilder { + PackageBuilder { + name: name.to_string(), + version: version.parse().unwrap(), + build: None, + build_number: None, + depends: vec![], + subdir: None, + archive_type: ArchiveType::Conda, + } + } + + /// Returns the file name for this package. + pub fn file_name(&self) -> String { + format!( + "{}-{}-{}{}", + self.package_record.name, + self.package_record.version, + self.package_record.build, + self.archive_type.extension() + ) + } +} + +impl PackageBuilder { + /// Set the build string of this package + pub fn with_build(mut self, build: impl ToString) -> Self { + self.build = Some(build.to_string()); + self + } + + /// Set the build string of this package + pub fn with_build_number(mut self, build_number: u64) -> Self { + self.build_number = Some(build_number); + self + } + + /// Set the build string of this package + pub fn with_dependency(mut self, dependency: impl ToString) -> Self { + self.depends.push(dependency.to_string()); + self + } + + /// Explicitly set the platform of this package + pub fn with_subdir(mut self, subdir: Platform) -> Self { + self.subdir = Some(subdir); + self + } + + /// Set the archive type of this package + pub fn with_archive_type(mut self, archive_type: ArchiveType) -> Self { + self.archive_type = archive_type; + self + } + + /// Finish construction of the package + pub fn finish(self) -> Package { + let subdir = self.subdir.unwrap_or(Platform::NoArch); + let build_number = self.build_number.unwrap_or(0); + let build = self.build.unwrap_or_else(|| format!("{build_number}")); + let hash = format!( + "{}-{}-{}{}", + &self.name, + &self.version, + &build, + self.archive_type.extension() + ); + let md5 = rattler_digest::compute_bytes_digest::(&hash); + let sha256 = rattler_digest::compute_bytes_digest::(&hash); + Package { + package_record: PackageRecord { + arch: None, + build, + build_number, + constrains: vec![], + depends: self.depends, + features: None, + legacy_bz2_md5: None, + legacy_bz2_size: None, + license: None, + license_family: None, + md5: Some(md5), + name: self.name, + noarch: Default::default(), + platform: None, + sha256: Some(sha256), + size: None, + subdir: subdir.to_string(), + timestamp: None, + track_features: vec![], + version: self.version, + }, + subdir, + archive_type: self.archive_type, + } + } +} diff --git a/tests/common/repodata.rs b/tests/common/repodata.rs deleted file mode 100644 index e7c9e05ef..000000000 --- a/tests/common/repodata.rs +++ /dev/null @@ -1,166 +0,0 @@ -use itertools::Either; -use rattler_conda_types::package::ArchiveType; -use rattler_conda_types::{ChannelInfo, PackageRecord, Platform, RepoData, Version}; -use std::iter; - -#[derive(Default, Clone, Debug)] -pub struct ChannelBuilder { - subdirs: Vec, -} - -impl ChannelBuilder { - pub fn with_subdir(mut self, subdir: SubdirBuilder) -> Self { - self.subdirs.push(subdir); - self - } - - /// Writes the channel to disk - pub async fn write_to_disk(&self) -> anyhow::Result { - let dir = tempfile::TempDir::new()?; - - let empty_noarch = SubdirBuilder::new(Platform::NoArch); - - let subdirs = if self - .subdirs - .iter() - .any(|subdir| subdir.platform == Platform::NoArch) - { - Either::Left(self.subdirs.iter()) - } else { - Either::Right(self.subdirs.iter().chain(iter::once(&empty_noarch))) - }; - - for subdir in subdirs { - let subdir_path = dir.path().join(subdir.platform.as_str()); - - let repodata = RepoData { - info: Some(ChannelInfo { - subdir: subdir.platform.to_string(), - }), - packages: subdir - .packages - .iter() - .filter(|pkg| pkg.archive_type == ArchiveType::TarBz2) - .map(|pkg| pkg.as_package_record(subdir.platform)) - .collect(), - conda_packages: subdir - .packages - .iter() - .filter(|pkg| pkg.archive_type == ArchiveType::Conda) - .map(|pkg| pkg.as_package_record(subdir.platform)) - .collect(), - removed: Default::default(), - version: Some(1), - }; - let repodata_str = serde_json::to_string_pretty(&repodata)?; - - tokio::fs::create_dir_all(&subdir_path).await?; - tokio::fs::write(subdir_path.join("repodata.json"), repodata_str).await?; - } - - Ok(dir) - } -} - -#[derive(Clone, Debug)] -pub struct SubdirBuilder { - packages: Vec, - platform: Platform, -} - -impl SubdirBuilder { - pub fn new(platform: Platform) -> Self { - Self { - platform, - packages: vec![], - } - } - - pub fn with_package(mut self, package: PackageBuilder) -> Self { - self.packages.push(package); - self - } -} - -#[derive(Clone, Debug)] -pub struct PackageBuilder { - name: String, - version: Version, - build_string: String, - depends: Vec, - archive_type: ArchiveType, -} - -impl PackageBuilder { - pub fn new(package: impl Into, version: &str) -> Self { - Self { - name: package.into(), - version: version.parse().expect("invalid version"), - build_string: String::from("0"), - depends: vec![], - archive_type: ArchiveType::Conda, - } - } - - #[allow(dead_code)] - pub fn with_build_string(mut self, build_string: impl Into) -> Self { - self.build_string = build_string.into(); - debug_assert!(!self.build_string.is_empty()); - self - } - - #[allow(dead_code)] - pub fn with_archive_type(mut self, archive_type: ArchiveType) -> Self { - self.archive_type = archive_type; - self - } - - pub fn with_dependency(mut self, spec: impl Into) -> Self { - self.depends.push(spec.into()); - self - } - - pub fn as_package_record(&self, platform: Platform) -> (String, PackageRecord) { - // Construct the package filename - let filename = format!( - "{}-{}-{}.{}", - self.name.to_lowercase(), - &self.version, - &self.build_string, - match self.archive_type { - ArchiveType::TarBz2 => "tar.bz2", - ArchiveType::Conda => "conda", - } - ); - - // Construct the record - let record = PackageRecord { - // TODO: This is wrong, we should extract this from platform - arch: None, - platform: None, - - build: self.build_string.clone(), - build_number: 0, - constrains: vec![], - depends: self.depends.clone(), - features: None, - legacy_bz2_md5: None, - legacy_bz2_size: None, - license: None, - license_family: None, - md5: Some(rattler_digest::compute_bytes_digest::(&filename)), - name: self.name.clone(), - noarch: Default::default(), - sha256: Some(rattler_digest::compute_bytes_digest::( - &filename, - )), - size: None, - subdir: platform.to_string(), - timestamp: None, - track_features: vec![], - version: self.version.clone(), - }; - - (filename, record) - } -} diff --git a/tests/install_tests.rs b/tests/install_tests.rs index 0bb3028fb..b1ea2eba3 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -1,11 +1,12 @@ mod common; -use crate::common::repodata::{ChannelBuilder, PackageBuilder, SubdirBuilder}; +use crate::common::package_database::{Package, PackageDatabase}; use common::{LockFileExt, PixiControl}; use pixi::Project; -use rattler_conda_types::{Platform, Version}; +use rattler_conda_types::Version; use std::str::FromStr; use tempfile::TempDir; +use url::Url; /// Should add a python version to the environment and lock file that matches the specified version /// and run it @@ -45,91 +46,76 @@ async fn init_creates_project_manifest() { assert_eq!(project.version(), &Version::from_str("0.1.0").unwrap()); } -#[tokio::test] -async fn test_custom_channel() { - let mut pixi = PixiControl::new().unwrap(); - - // Create a new project - pixi.init().await.unwrap(); - - // Set the channel to something we created - pixi.set_channel( - ChannelBuilder::default().with_subdir( - SubdirBuilder::new(Platform::current()) - .with_package( - PackageBuilder::new("foo", "1") - .with_dependency("bar >=1") - .with_build_string("helloworld"), - ) - .with_package(PackageBuilder::new("bar", "1")), - ), - ) - .await - .unwrap(); - - // Add a dependency on `foo` - pixi.add(["foo"]).await.unwrap(); - - // Get the created lock-file - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_matchspec("foo==1=helloworld")); -} - +/// This is a test to check that creating incremental lock files works. +/// +/// It works by using a fake channel that contains two packages: `foo` and `bar`. `foo` depends on +/// `bar` so adding a dependency on `foo` pulls in `bar`. Initially only version `1` of both +/// packages is added and a project is created that depends on `foo >=1`. This select `foo@1` and +/// `bar@1`. +/// Next, version 2 for both packages is added and the requirement in the project is updated to +/// `foo >=2`, this should then select `foo@1` but `bar` should remain on version `1` even though +/// version `2` is available. This is because `bar` was previously locked to version `1` and it is +/// still a valid solution to keep using version `1` of bar. #[tokio::test] async fn test_incremental_lock_file() { + let mut package_database = PackageDatabase::default(); + + // Add a package `foo` that depends on `bar` both set to version 1. + package_database.add_package(Package::build("bar", "1").finish()); + package_database.add_package( + Package::build("foo", "1") + .with_dependency("bar >=1") + .finish(), + ); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + let mut pixi = PixiControl::new().unwrap(); // Create a new project pixi.init().await.unwrap(); // Set the channel to something we created - pixi.set_channel( - ChannelBuilder::default().with_subdir( - SubdirBuilder::new(Platform::current()) - .with_package( - PackageBuilder::new("foo", "1") - .with_dependency("bar >=1") - .with_build_string("helloworld"), - ) - .with_package(PackageBuilder::new("bar", "1")), - ), - ) - .await - .unwrap(); + pixi.set_channel(Url::from_directory_path(channel_dir.path()).unwrap()) + .await + .unwrap(); // Add a dependency on `foo` pixi.add(["foo"]).await.unwrap(); // Get the created lock-file let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_matchspec("foo==1=helloworld")); - - // Update the channel, add a version 2 for both foo and bar. - pixi.set_channel( - ChannelBuilder::default().with_subdir( - SubdirBuilder::new(Platform::current()) - .with_package( - PackageBuilder::new("foo", "1") - .with_dependency("bar >=1") - .with_build_string("helloworld"), - ) - .with_package( - PackageBuilder::new("foo", "2") - .with_dependency("bar >=1") - .with_build_string("awholenewworld"), - ) - .with_package(PackageBuilder::new("bar", "1")) - .with_package(PackageBuilder::new("bar", "2")), - ), - ) - .await - .unwrap(); - - // Change the dependency on `foo` to use version 2. + assert!(lock.contains_matchspec("foo ==1")); + assert!(lock.contains_matchspec("bar ==1")); + + // Add version 2 of both `foo` and `bar`. + package_database.add_package(Package::build("bar", "2").finish()); + package_database.add_package( + Package::build("foo", "2") + .with_dependency("bar >=1") + .finish(), + ); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + // Force using version 2 of `foo`. This should force `foo` to version `2` but `bar` should still + // remaing on `1` because it was previously locked pixi.add(["foo >=2"]).await.unwrap(); - // Get the created lock-file, `foo` should have been updated, but `bar` should remain on v1. let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_matchspec("foo==2=awholenewworld")); - assert!(lock.contains_matchspec("bar==1")); + assert!( + lock.contains_matchspec("foo ==2"), + "expected `foo` to be on version 2 because we changed the requirement" + ); + assert!( + lock.contains_matchspec("bar ==1"), + "expected `bar` to remain locked to version 1." + ); } From fa5023311d9c407f233436b9d46d0df4fe75203f Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Mon, 26 Jun 2023 11:35:49 +0200 Subject: [PATCH 3/9] fix: appease precommit --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e113aeacb..08a50416c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,8 +59,8 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = "2.4.0" [dev-dependencies] -serde_json = "1.0.96" rattler_digest = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } +serde_json = "1.0.96" [profile.release-lto] inherits = "release" From 9ace48391d24feaf6dce15c9987d1daae6cdb2f3 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Mon, 26 Jun 2023 16:13:42 +0200 Subject: [PATCH 4/9] fix: format --- tests/install_tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/install_tests.rs b/tests/install_tests.rs index 597f0e607..923f1a473 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -1,14 +1,14 @@ mod common; use crate::common::package_database::{Package, PackageDatabase}; +use crate::common::{matchspec_from_iter, string_from_iter}; use common::{LockFileExt, PixiControl}; +use pixi::cli::{add, run}; use pixi::Project; use rattler_conda_types::Version; use std::str::FromStr; use tempfile::TempDir; use url::Url; -use pixi::cli::{add, run}; -use crate::common::{matchspec_from_iter, string_from_iter}; /// Should add a python version to the environment and lock file that matches the specified version /// and run it @@ -103,8 +103,8 @@ async fn test_incremental_lock_file() { specs: matchspec_from_iter(["foo"]), ..Default::default() }) - .await - .unwrap(); + .await + .unwrap(); // Get the created lock-file let lock = pixi.lock_file().await.unwrap(); @@ -129,8 +129,8 @@ async fn test_incremental_lock_file() { specs: matchspec_from_iter(["foo"]), ..Default::default() }) - .await - .unwrap(); + .await + .unwrap(); let lock = pixi.lock_file().await.unwrap(); assert!( From d997980ee7a09cea2f92973fe99ffcee1fb3184f Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 27 Jun 2023 14:36:05 +0200 Subject: [PATCH 5/9] fix: simplify pixi control usage --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/cli/init.rs | 14 +++-- tests/common/mod.rs | 114 ++++++++++++++++++++++++++++++++--------- tests/install_tests.rs | 59 ++++++++------------- 5 files changed, 126 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30a714692..2182883ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2475,9 +2475,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "0.32.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e813a9b613280e7d9e5be27ab556530d7c5562d26e5e6ef586e2c4512d34550d" +checksum = "75aa91cba87dcad6af3e53bc7adb9c99755eba2d49b6c5f10dbcc79d4727c1bd" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 78139f2da..cf1a473f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ indicatif = "0.17.3" insta = { version = "1.29.0", features = ["yaml"] } is_executable = "1.0.1" itertools = "0.10.5" -minijinja = { version = "0.32.0" } +minijinja = { version = "0.34.0", features=["builtins"] } once_cell = "1.17.1" rattler = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } rattler_conda_types = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } diff --git a/src/cli/init.rs b/src/cli/init.rs index f3ae3559a..d9a073816 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -10,6 +10,10 @@ pub struct Args { /// Where to place the project (defaults to current path) #[arg(default_value = ".")] pub path: PathBuf, + + /// Channels to use in the project. + #[arg(short)] + pub channels: Vec, } /// The pixi.toml template @@ -22,7 +26,7 @@ description = "Add a short description here" {%- if author %} authors = ["{{ author[0] }} <{{ author[1] }}>"] {%- endif %} -channels = ["{{ channel }}"] +channels = ["{{ channels|join("\", \"") }}"] platforms = ["{{ platform }}"] [commands] @@ -52,7 +56,11 @@ pub async fn execute(args: Args) -> anyhow::Result<()> { let name = dir.file_name().unwrap().to_string_lossy(); let version = "0.1.0"; let author = get_default_author(); - let channel = "conda-forge"; + let channels = if args.channels.is_empty() { + vec![String::from("conda-forge")] + } else { + args.channels + }; let platform = Platform::current(); let rv = env @@ -63,7 +71,7 @@ pub async fn execute(args: Args) -> anyhow::Result<()> { name, version, author, - channel, + channels, platform }, ) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5dc2bd772..1d2bd0e47 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + pub mod package_database; use pixi::cli::run::create_command; @@ -5,10 +7,13 @@ use pixi::cli::{add, init, run}; use pixi::{consts, Project}; use rattler_conda_types::conda_lock::CondaLock; use rattler_conda_types::{MatchSpec, Version}; +use std::future::{Future, IntoFuture}; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::process::Stdio; use std::str::FromStr; use tempfile::TempDir; +use url::Url; /// To control the pixi process pub struct PixiControl { @@ -32,13 +37,6 @@ impl RunResult { } } -/// MatchSpecs from an iterator -pub fn matchspec_from_iter(iter: impl IntoIterator>) -> Vec { - iter.into_iter() - .map(|s| MatchSpec::from_str(s.as_ref()).expect("could not parse matchspec")) - .collect() -} - /// MatchSpecs from an iterator pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { iter.into_iter().map(|s| s.as_ref().to_string()).collect() @@ -82,6 +80,11 @@ impl PixiControl { Ok(PixiControl { tmpdir: tempdir }) } + /// Loads the project manifest and returns it. + pub fn project(&self) -> anyhow::Result { + Project::load(&self.manifest_path()) + } + /// Get the path to the project pub fn project_path(&self) -> &Path { self.tmpdir.path() @@ -91,24 +94,29 @@ impl PixiControl { self.project_path().join(consts::PROJECT_MANIFEST) } - /// Initialize pixi inside a tempdir and set the tempdir as the current working directory. - pub async fn init(&self) -> anyhow::Result<()> { - let args = init::Args { - path: self.project_path().to_path_buf(), - }; - init::execute(args).await?; - Ok(()) + /// Initialize pixi project inside a temporary directory. + pub fn init(&self) -> InitBuilder { + InitBuilder { + args: init::Args { + path: self.project_path().to_path_buf(), + channels: Vec::new(), + }, + } } /// Add a dependency to the project - pub async fn add(&mut self, mut args: add::Args) -> anyhow::Result<()> { - args.manifest_path = Some(self.manifest_path()); - add::execute(args).await + pub fn add(&self, spec: impl IntoMatchSpec) -> AddBuilder { + AddBuilder { + args: add::Args { + manifest_path: Some(self.manifest_path()), + specs: vec![spec.into()], + }, + } } /// Run a command pub async fn run(&self, mut args: run::Args) -> anyhow::Result { - args.manifest_path = Some(self.manifest_path()); + args.manifest_path = args.manifest_path.or_else(|| Some(self.manifest_path())); let mut script_command = create_command(args).await?; let output = script_command .command @@ -122,12 +130,70 @@ impl PixiControl { pub async fn lock_file(&self) -> anyhow::Result { pixi::environment::load_lock_for_manifest_path(&self.manifest_path()).await } +} + +pub struct InitBuilder { + args: init::Args, +} + +impl InitBuilder { + pub fn with_channel(mut self, channel: impl ToString) -> Self { + self.args.channels.push(channel.to_string()); + self + } + + pub fn with_local_channel(self, channel: impl AsRef) -> Self { + self.with_channel(Url::from_directory_path(channel).unwrap()) + } +} + +impl IntoFuture for InitBuilder { + type Output = anyhow::Result<()>; + type IntoFuture = Pin + Send + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(init::execute(self.args)) + } +} + +pub struct AddBuilder { + args: add::Args, +} + +impl AddBuilder { + pub fn with_spec(mut self, spec: impl IntoMatchSpec) -> Self { + self.args.specs.push(spec.into()); + self + } +} + +impl IntoFuture for AddBuilder { + type Output = anyhow::Result<()>; + type IntoFuture = Pin + Send + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(add::execute(self.args)) + } +} + +pub trait IntoMatchSpec { + fn into(self) -> MatchSpec; +} + +impl IntoMatchSpec for &str { + fn into(self) -> MatchSpec { + MatchSpec::from_str(self).unwrap() + } +} + +impl IntoMatchSpec for String { + fn into(self) -> MatchSpec { + MatchSpec::from_str(&self).unwrap() + } +} - /// Set the project to use a specific channel - pub async fn set_channel(&mut self, channel: impl ToString) -> anyhow::Result<()> { - let mut project = Project::load(&self.manifest_path()).unwrap(); - project.set_channels(&[channel.to_string()])?; - project.save()?; - Ok(()) +impl IntoMatchSpec for MatchSpec { + fn into(self) -> MatchSpec { + self } } diff --git a/tests/install_tests.rs b/tests/install_tests.rs index 923f1a473..1e09dc0a3 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -1,28 +1,21 @@ mod common; use crate::common::package_database::{Package, PackageDatabase}; -use crate::common::{matchspec_from_iter, string_from_iter}; +use crate::common::string_from_iter; use common::{LockFileExt, PixiControl}; -use pixi::cli::{add, run}; -use pixi::Project; +use pixi::cli::run; use rattler_conda_types::Version; use std::str::FromStr; use tempfile::TempDir; -use url::Url; /// Should add a python version to the environment and lock file that matches the specified version /// and run it #[tokio::test] #[cfg_attr(not(feature = "slow_integration_tests"), ignore)] async fn install_run_python() { - let mut pixi = PixiControl::new().unwrap(); + let pixi = PixiControl::new().unwrap(); pixi.init().await.unwrap(); - pixi.add(add::Args { - specs: matchspec_from_iter(["python==3.11.0"]), - ..Default::default() - }) - .await - .unwrap(); + pixi.add("python==3.11.0").await.unwrap(); // Check if lock has python version let lock = pixi.lock_file().await.unwrap(); @@ -42,20 +35,24 @@ async fn install_run_python() { #[tokio::test] async fn init_creates_project_manifest() { - let tmp_dir = TempDir::new().unwrap(); - // Run the init command - pixi::cli::init::execute(pixi::cli::init::Args { - path: tmp_dir.path().to_path_buf(), - }) - .await - .unwrap(); + let pixi = PixiControl::new().unwrap(); + pixi.init().await.unwrap(); // There should be a loadable project manifest in the directory - let project = Project::load(&tmp_dir.path().join(pixi::consts::PROJECT_MANIFEST)).unwrap(); + let project = pixi.project().unwrap(); // Default configuration should be present in the file assert!(!project.name().is_empty()); + assert_eq!( + project.name(), + pixi.project_path() + .file_stem() + .unwrap() + .to_string_lossy() + .as_ref(), + "project name should match the directory name" + ); assert_eq!(project.version(), &Version::from_str("0.1.0").unwrap()); } @@ -88,23 +85,16 @@ async fn test_incremental_lock_file() { .await .unwrap(); - let mut pixi = PixiControl::new().unwrap(); - - // Create a new project - pixi.init().await.unwrap(); + let pixi = PixiControl::new().unwrap(); - // Set the channel to something we created - pixi.set_channel(Url::from_directory_path(channel_dir.path()).unwrap()) + // Create a new project using our package database. + pixi.init() + .with_local_channel(channel_dir.path()) .await .unwrap(); // Add a dependency on `foo` - pixi.add(add::Args { - specs: matchspec_from_iter(["foo"]), - ..Default::default() - }) - .await - .unwrap(); + pixi.add("foo").await.unwrap(); // Get the created lock-file let lock = pixi.lock_file().await.unwrap(); @@ -125,12 +115,7 @@ async fn test_incremental_lock_file() { // Force using version 2 of `foo`. This should force `foo` to version `2` but `bar` should still // remaing on `1` because it was previously locked - pixi.add(add::Args { - specs: matchspec_from_iter(["foo"]), - ..Default::default() - }) - .await - .unwrap(); + pixi.add("foo >=2").await.unwrap(); let lock = pixi.lock_file().await.unwrap(); assert!( From dfc52c3c806f73baae302ad75858a463141c93a8 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 27 Jun 2023 14:46:02 +0200 Subject: [PATCH 6/9] test: specify channel should be present in project.toml --- src/cli/init.rs | 2 +- tests/install_tests.rs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/cli/init.rs b/src/cli/init.rs index d9a073816..496c75c70 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -12,7 +12,7 @@ pub struct Args { pub path: PathBuf, /// Channels to use in the project. - #[arg(short)] + #[arg(short, long = "channel", id = "channel")] pub channels: Vec, } diff --git a/tests/install_tests.rs b/tests/install_tests.rs index 1e09dc0a3..ebb8b5326 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -4,7 +4,7 @@ use crate::common::package_database::{Package, PackageDatabase}; use crate::common::string_from_iter; use common::{LockFileExt, PixiControl}; use pixi::cli::run; -use rattler_conda_types::Version; +use rattler_conda_types::{Channel, ChannelConfig, Version}; use std::str::FromStr; use tempfile::TempDir; @@ -56,6 +56,44 @@ async fn init_creates_project_manifest() { assert_eq!(project.version(), &Version::from_str("0.1.0").unwrap()); } +/// Tests that when initializing an empty project with a custom channel it is actually used. +#[tokio::test] +async fn specific_channel() { + let pixi = PixiControl::new().unwrap(); + + // Init with a custom channel + pixi.init().with_channel("random").await.unwrap(); + + // Load the project + let project = pixi.project().unwrap(); + + // The only channel should be the "random" channel + let channels = project.channels(); + assert_eq!( + channels, + &[Channel::from_str("random", &ChannelConfig::default()).unwrap()] + ) +} + +/// Tests that when initializing an empty project the default channel `conda-forge` is used. +#[tokio::test] +async fn default_channel() { + let pixi = PixiControl::new().unwrap(); + + // Init a new project + pixi.init().await.unwrap(); + + // Load the project + let project = pixi.project().unwrap(); + + // The only channel should be the "conda-forge" channel + let channels = project.channels(); + assert_eq!( + channels, + &[Channel::from_str("conda-forge", &ChannelConfig::default()).unwrap()] + ) +} + /// This is a test to check that creating incremental lock files works. /// /// It works by using a fake channel that contains two packages: `foo` and `bar`. `foo` depends on From 35a29b2367d76eea10c7cfeff01c2aad0dafc388 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 27 Jun 2023 14:47:04 +0200 Subject: [PATCH 7/9] test: also check with multiple custom channels --- tests/install_tests.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/install_tests.rs b/tests/install_tests.rs index ebb8b5326..15741b40d 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -62,7 +62,11 @@ async fn specific_channel() { let pixi = PixiControl::new().unwrap(); // Init with a custom channel - pixi.init().with_channel("random").await.unwrap(); + pixi.init() + .with_channel("random") + .with_channel("foobar") + .await + .unwrap(); // Load the project let project = pixi.project().unwrap(); @@ -71,7 +75,10 @@ async fn specific_channel() { let channels = project.channels(); assert_eq!( channels, - &[Channel::from_str("random", &ChannelConfig::default()).unwrap()] + &[ + Channel::from_str("random", &ChannelConfig::default()).unwrap(), + Channel::from_str("foobar", &ChannelConfig::default()).unwrap() + ] ) } From d1a6f3a55225e358ffe654f094be456b4f90ffcc Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 27 Jun 2023 14:59:37 +0200 Subject: [PATCH 8/9] fix: pre-commit --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index cf1a473f8..78ece6e40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ indicatif = "0.17.3" insta = { version = "1.29.0", features = ["yaml"] } is_executable = "1.0.1" itertools = "0.10.5" -minijinja = { version = "0.34.0", features=["builtins"] } +minijinja = { version = "0.34.0", features = ["builtins"] } once_cell = "1.17.1" rattler = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } rattler_conda_types = { default-features = false, git = "https://github.com/mamba-org/rattler", branch = "main" } From 38b32b154ada730faee19fc43fca5d1a85dc81c3 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 29 Jun 2023 09:56:55 +0200 Subject: [PATCH 9/9] doc: adds documentation --- tests/common/mod.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1d2bd0e47..bf4d74f21 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -94,7 +94,8 @@ impl PixiControl { self.project_path().join(consts::PROJECT_MANIFEST) } - /// Initialize pixi project inside a temporary directory. + /// Initialize pixi project inside a temporary directory. Returns a [`InitBuilder`]. To execute + /// the command and await the result call `.await` on the return value. pub fn init(&self) -> InitBuilder { InitBuilder { args: init::Args { @@ -104,7 +105,8 @@ impl PixiControl { } } - /// Add a dependency to the project + /// Initialize pixi project inside a temporary directory. Returns a [`AddBuilder`]. To execute + /// the command and await the result call `.await` on the return value. pub fn add(&self, spec: impl IntoMatchSpec) -> AddBuilder { AddBuilder { args: add::Args { @@ -132,6 +134,8 @@ impl PixiControl { } } +/// Contains the arguments to pass to `init::execute()`. Call `.await` to call the CLI execute +/// method and await the result at the same time. pub struct InitBuilder { args: init::Args, } @@ -147,6 +151,11 @@ impl InitBuilder { } } +// When `.await` is called on an object that is not a `Future` the compiler will first check if the +// type implements `IntoFuture`. If it does it will call the `IntoFuture::into_future()` method and +// await the resulting `Future`. We can abuse this behavior in builder patterns because the +// `into_future` method can also be used as a `finish` function. This allows you to reduce the +// required code. impl IntoFuture for InitBuilder { type Output = anyhow::Result<()>; type IntoFuture = Pin + Send + 'static>>; @@ -156,6 +165,8 @@ impl IntoFuture for InitBuilder { } } +/// Contains the arguments to pass to `add::execute()`. Call `.await` to call the CLI execute method +/// and await the result at the same time. pub struct AddBuilder { args: add::Args, } @@ -167,6 +178,11 @@ impl AddBuilder { } } +// When `.await` is called on an object that is not a `Future` the compiler will first check if the +// type implements `IntoFuture`. If it does it will call the `IntoFuture::into_future()` method and +// await the resulting `Future`. We can abuse this behavior in builder patterns because the +// `into_future` method can also be used as a `finish` function. This allows you to reduce the +// required code. impl IntoFuture for AddBuilder { type Output = anyhow::Result<()>; type IntoFuture = Pin + Send + 'static>>; @@ -176,6 +192,8 @@ impl IntoFuture for AddBuilder { } } +/// A helper trait to convert from different types into a [`MatchSpec`] to make it simpler to +/// use them in tests. pub trait IntoMatchSpec { fn into(self) -> MatchSpec; }