From 97d95c701980e326cab9967f4d774316f4fd6164 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 8 Aug 2024 17:42:09 +0200 Subject: [PATCH] refactor: add `pixi_spec` crate (#1741) Co-authored-by: Ruben Arts --- Cargo.lock | 23 + Cargo.toml | 5 + crates/pixi_manifest/Cargo.toml | 1 + crates/pixi_manifest/src/dependencies.rs | 45 +- crates/pixi_manifest/src/document.rs | 111 +---- crates/pixi_manifest/src/error.rs | 6 +- crates/pixi_manifest/src/feature.rs | 11 +- crates/pixi_manifest/src/has_manifest_ref.rs | 6 + crates/pixi_manifest/src/lib.rs | 1 - crates/pixi_manifest/src/manifest.rs | 106 ++-- .../pixi_manifest/src/nameless_matchspec.rs | 32 -- crates/pixi_manifest/src/parsed_manifest.rs | 192 +++++--- crates/pixi_manifest/src/pyproject.rs | 35 +- ...st__document__tests__nameless_to_toml.snap | 4 +- crates/pixi_manifest/src/target.rs | 43 +- crates/pixi_manifest/src/utils/mod.rs | 7 - crates/pixi_spec/Cargo.toml | 28 ++ crates/pixi_spec/src/detailed.rs | 68 +++ crates/pixi_spec/src/git.rs | 27 ++ crates/pixi_spec/src/lib.rs | 459 ++++++++++++++++++ crates/pixi_spec/src/path.rs | 95 ++++ crates/pixi_spec/src/serde.rs | 369 ++++++++++++++ .../pixi_spec__serde__test__round_trip.snap | 139 ++++++ ..._spec__test__into_nameless_match_spec.snap | 80 +++ crates/pixi_spec/src/url.rs | 84 ++++ schema/examples/valid/full.toml | 23 +- schema/model.py | 30 +- schema/schema.json | 66 +++ src/cli/add.rs | 25 +- src/lock_file/satisfiability.rs | 187 ++++--- src/lock_file/update.rs | 24 +- src/project/environment.rs | 6 +- src/project/mod.rs | 35 +- ...ect__environment__tests__dependencies.snap | 9 +- tests/add_tests.rs | 90 +++- 35 files changed, 2034 insertions(+), 438 deletions(-) delete mode 100644 crates/pixi_manifest/src/nameless_matchspec.rs create mode 100644 crates/pixi_spec/Cargo.toml create mode 100644 crates/pixi_spec/src/detailed.rs create mode 100644 crates/pixi_spec/src/git.rs create mode 100644 crates/pixi_spec/src/lib.rs create mode 100644 crates/pixi_spec/src/path.rs create mode 100644 crates/pixi_spec/src/serde.rs create mode 100644 crates/pixi_spec/src/snapshots/pixi_spec__serde__test__round_trip.snap create mode 100644 crates/pixi_spec/src/snapshots/pixi_spec__test__into_nameless_match_spec.snap create mode 100644 crates/pixi_spec/src/url.rs diff --git a/Cargo.lock b/Cargo.lock index 3cc6f968d..ea4c19df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2292,6 +2292,7 @@ dependencies = [ "globset", "lazy_static", "linked-hash-map", + "regex", "serde", "similar", "walkdir", @@ -3439,6 +3440,7 @@ dependencies = [ "pixi_manifest", "pixi_progress", "pixi_pty", + "pixi_spec", "pixi_utils", "pixi_uv_conversions", "platform-tags", @@ -3551,6 +3553,7 @@ dependencies = [ "pep440_rs", "pep508_rs", "pixi_consts", + "pixi_spec", "pyproject-toml", "rattler_conda_types", "rattler_lock", @@ -3590,6 +3593,26 @@ dependencies = [ "signal-hook", ] +[[package]] +name = "pixi_spec" +version = "0.1.0" +dependencies = [ + "dirs", + "file_url", + "insta", + "rattler_conda_types", + "rattler_digest", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "serde_yaml", + "thiserror", + "toml_edit 0.22.20", + "typed-path", + "url", +] + [[package]] name = "pixi_utils" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5039dd4a3..137af609c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ distribution-filename = { git = "https://github.com/astral-sh/uv", tag = "0.2.18 distribution-types = { git = "https://github.com/astral-sh/uv", tag = "0.2.18" } dunce = "1.0.4" fd-lock = "4.0.2" +file_url = "0.1.3" flate2 = "1.0.28" fs_extra = "1.3.0" futures = "0.3.30" @@ -92,6 +93,8 @@ tokio-util = "0.7.10" toml_edit = "0.22.11" tracing = "0.1.40" tracing-subscriber = "0.3.18" +typed-path = "0.9.1" + # Bumping this to a higher version breaks the Windows path handling. url = "2.5.0" uv-auth = { git = "https://github.com/astral-sh/uv", tag = "0.2.18" } @@ -117,6 +120,7 @@ pixi_consts = { path = "crates/pixi_consts" } pixi_default_versions = { path = "crates/pixi_default_versions" } pixi_manifest = { path = "crates/pixi_manifest" } pixi_progress = { path = "crates/pixi_progress" } +pixi_spec = { path = "crates/pixi_spec" } pixi_utils = { path = "crates/pixi_utils", default-features = false } pixi_uv_conversions = { path = "crates/pixi_uv_conversions" } pypi_mapping = { path = "crates/pypi_mapping" } @@ -229,6 +233,7 @@ pixi_consts = { workspace = true } pixi_default_versions = { workspace = true } pixi_manifest = { workspace = true } pixi_progress = { workspace = true } +pixi_spec = { workspace = true, features = ["toml_edit"] } pixi_utils = { workspace = true, default-features = false } pixi_uv_conversions = { workspace = true } pypi_mapping = { workspace = true } diff --git a/crates/pixi_manifest/Cargo.toml b/crates/pixi_manifest/Cargo.toml index 74ab81407..48a53a2ed 100644 --- a/crates/pixi_manifest/Cargo.toml +++ b/crates/pixi_manifest/Cargo.toml @@ -17,6 +17,7 @@ lazy_static = { workspace = true } pep440_rs = { workspace = true } pep508_rs = { workspace = true } pixi_consts = { workspace = true } +pixi_spec = { workspace = true, features = ["toml_edit"] } regex = { workspace = true } serde = { workspace = true } serde-untagged = { workspace = true } diff --git a/crates/pixi_manifest/src/dependencies.rs b/crates/pixi_manifest/src/dependencies.rs index 097d30fef..ece789de7 100644 --- a/crates/pixi_manifest/src/dependencies.rs +++ b/crates/pixi_manifest/src/dependencies.rs @@ -1,21 +1,26 @@ +use std::{borrow::Cow, hash::Hash}; + use indexmap::{Equivalent, IndexMap, IndexSet}; use itertools::Either; +use pixi_spec::PixiSpec; use rattler_conda_types::{MatchSpec, NamelessMatchSpec, PackageName}; -use std::{borrow::Cow, hash::Hash}; use crate::{pypi::PyPiPackageName, PyPiRequirement}; pub type PyPiDependencies = Dependencies; -pub type CondaDependencies = Dependencies; +pub type CondaDependencies = Dependencies; -/// Holds a list of dependencies where for each package name there can be multiple requirements. +/// Holds a list of dependencies where for each package name there can be +/// multiple requirements. /// -/// This is used when combining the dependencies of multiple features. Although each target can only -/// have one requirement for a given package, when combining the dependencies of multiple features -/// there can be multiple requirements for a given package. +/// This is used when combining the dependencies of multiple features. Although +/// each target can only have one requirement for a given package, when +/// combining the dependencies of multiple features there can be multiple +/// requirements for a given package. /// -/// The generic 'Dependencies' struct is aliased as specific PyPiDependencies and CondaDependencies struct to represent -/// Pypi and Conda dependencies respectively. +/// The generic 'Dependencies' struct is aliased as specific PyPiDependencies +/// and CondaDependencies struct to represent Pypi and Conda dependencies +/// respectively. #[derive(Debug, Clone)] pub struct Dependencies { @@ -43,7 +48,8 @@ impl<'a, M, N: Hash + Eq + Clone + 'a, D: Hash + Eq + Clone + 'a> From for De where M: IntoIterator>>, { - /// Create Dependencies from an iterator over items of type Cow<'a, IndexMap + /// Create Dependencies from an iterator over items of type Cow<'a, + /// IndexMap fn from(m: M) -> Self { m.into_iter().fold(Self::default(), |mut acc: Self, deps| { // Either clone the values from the Cow or move the values from the owned map. @@ -85,8 +91,8 @@ impl Dependencies { self.map.shift_remove_entry(name) } - /// Combines two sets of dependencies where the requirements of `self` are overwritten if the - /// same package is also defined in `other`. + /// Combines two sets of dependencies where the requirements of `self` are + /// overwritten if the same package is also defined in `other`. pub fn overwrite(&self, other: &Self) -> Self { let mut map = self.map.clone(); for (name, specs) in other.map.iter() { @@ -95,12 +101,14 @@ impl Dependencies { Self { map } } - /// Returns an iterator over tuples of dependency names and their combined requirements. + /// Returns an iterator over tuples of dependency names and their combined + /// requirements. pub fn iter(&self) -> impl DoubleEndedIterator)> + '_ { self.map.iter() } - /// Returns an iterator over tuples of dependency names and individual requirements. + /// Returns an iterator over tuples of dependency names and individual + /// requirements. pub fn iter_specs(&self) -> impl DoubleEndedIterator + '_ { self.map .iter() @@ -112,7 +120,8 @@ impl Dependencies { self.map.keys() } - /// Converts this instance into an iterator over tuples of dependency names and individual requirements. + /// Converts this instance into an iterator over tuples of dependency names + /// and individual requirements. pub fn into_specs(self) -> impl DoubleEndedIterator { self.map .into_iter() @@ -126,6 +135,14 @@ impl Dependencies { { self.map.contains_key(name) } + + /// Returns the package specs for the specified package name. + pub fn get(&self, name: &Q) -> Option<&IndexSet> + where + Q: Hash + Equivalent, + { + self.map.get(name) + } } impl Dependencies { diff --git a/crates/pixi_manifest/src/document.rs b/crates/pixi_manifest/src/document.rs index ab2d91313..06c830384 100644 --- a/crates/pixi_manifest/src/document.rs +++ b/crates/pixi_manifest/src/document.rs @@ -1,7 +1,8 @@ use std::fmt; -use rattler_conda_types::{ChannelConfig, NamelessMatchSpec, PackageName, Platform}; -use toml_edit::{value, Array, InlineTable, Item, Table, Value}; +use pixi_spec::PixiSpec; +use rattler_conda_types::{PackageName, Platform}; +use toml_edit::{value, Array, Item, Table, Value}; use super::{consts, error::TomlError, pypi::PyPiPackageName, PyPiRequirement}; use crate::{consts::PYPROJECT_PIXI_PREFIX, FeatureName, SpecType, Task}; @@ -226,18 +227,14 @@ impl ManifestSource { pub fn add_dependency( &mut self, name: &PackageName, - spec: &NamelessMatchSpec, + spec: &PixiSpec, spec_type: SpecType, platform: Option, feature_name: &FeatureName, - channel_config: &ChannelConfig, ) -> Result<(), TomlError> { let dependency_table = self.get_or_insert_toml_table(platform, feature_name, spec_type.name())?; - dependency_table.insert( - name.as_normalized(), - Item::Value(nameless_match_spec_to_toml(spec, channel_config)), - ); + dependency_table.insert(name.as_normalized(), Item::Value(spec.to_toml_value())); Ok(()) } @@ -370,91 +367,9 @@ impl ManifestSource { } } -/// Given a nameless matchspec convert it into a TOML value. If the spec only -/// contains a version a string is returned, otherwise an entire table is -/// constructed. -fn nameless_match_spec_to_toml(spec: &NamelessMatchSpec, channel_config: &ChannelConfig) -> Value { - match spec { - NamelessMatchSpec { - version, - build: None, - build_number: None, - file_name: None, - channel: None, - subdir: None, - namespace: None, - md5: None, - sha256: None, - url: None, - } => { - // No other fields besides the version was specified, so we can just return the - // version as a string. - version - .as_ref() - .map_or_else(|| String::from("*"), |v| v.to_string()) - .into() - } - NamelessMatchSpec { - version, - build, - build_number, - file_name, - channel, - subdir, - namespace, - md5, - sha256, - url, - } => { - let mut table = InlineTable::new(); - table.insert( - "version", - version - .as_ref() - .map_or_else(|| String::from("*"), |v| v.to_string()) - .into(), - ); - if let Some(build) = build { - table.insert("build", build.to_string().into()); - } - if let Some(build_number) = build_number { - table.insert("build_number", build_number.to_string().into()); - } - if let Some(file_name) = file_name { - table.insert("file_name", file_name.to_string().into()); - } - if let Some(channel) = channel { - table.insert( - "channel", - channel_config - .canonical_name(channel.base_url()) - .as_str() - .into(), - ); - } - if let Some(subdir) = subdir { - table.insert("subdir", subdir.to_string().into()); - } - if let Some(namespace) = namespace { - table.insert("namespace", namespace.to_string().into()); - } - if let Some(md5) = md5 { - table.insert("md5", format!("{:x}", md5).into()); - } - if let Some(sha256) = sha256 { - table.insert("sha256", format!("{:x}", sha256).into()); - } - if let Some(url) = url { - table.insert("url", url.to_string().into()); - } - table.into() - } - } -} - #[cfg(test)] mod tests { - use std::path::{Path, PathBuf}; + use std::path::Path; use insta::assert_snapshot; use rattler_conda_types::{MatchSpec, ParseStrictness::Strict}; @@ -471,6 +386,12 @@ mod tests { platforms = ["linux-64", "win-64", "osx-64"] "#; + fn default_channel_config() -> rattler_conda_types::ChannelConfig { + rattler_conda_types::ChannelConfig::default_with_root_dir( + std::env::current_dir().expect("Could not retrieve the current directory"), + ) + } + #[test] fn test_nameless_to_toml() { let examples = [ @@ -481,17 +402,15 @@ mod tests { "rattler >=1 *cuda", ]; + let channel_config = default_channel_config(); let mut table = toml_edit::DocumentMut::new(); - let channel_config = ChannelConfig::default_with_root_dir(PathBuf::new()); for example in examples { let spec = MatchSpec::from_str(example, Strict) .unwrap() .into_nameless() .1; - table.insert( - example, - Item::Value(nameless_match_spec_to_toml(&spec, &channel_config)), - ); + let spec = PixiSpec::from_nameless_matchspec(spec, &channel_config); + table.insert(example, Item::Value(spec.to_toml_value())); } assert_snapshot!(table); } diff --git a/crates/pixi_manifest/src/error.rs b/crates/pixi_manifest/src/error.rs index 4548e6dd2..e4e273573 100644 --- a/crates/pixi_manifest/src/error.rs +++ b/crates/pixi_manifest/src/error.rs @@ -2,7 +2,7 @@ use std::{borrow::Borrow, fmt::Display}; use itertools::Itertools; use miette::{Diagnostic, IntoDiagnostic, LabeledSpan, NamedSource, Report}; -use rattler_conda_types::{InvalidPackageNameError, ParseMatchSpecError}; +use rattler_conda_types::{version_spec::ParseVersionSpecError, InvalidPackageNameError}; use thiserror::Error; use super::pypi::pypi_requirement::Pep508ToPyPiRequirementError; @@ -24,8 +24,8 @@ pub enum DependencyError { pub enum RequirementConversionError { #[error("Invalid package name error")] InvalidPackageNameError(#[from] InvalidPackageNameError), - #[error("Failed to parse specification")] - ParseError(#[from] ParseMatchSpecError), + #[error("Failed to parse version")] + InvalidVersion(#[from] ParseVersionSpecError), } #[derive(Error, Debug, Clone, Diagnostic)] diff --git a/crates/pixi_manifest/src/feature.rs b/crates/pixi_manifest/src/feature.rs index 3e54ac575..d9bfd22e0 100644 --- a/crates/pixi_manifest/src/feature.rs +++ b/crates/pixi_manifest/src/feature.rs @@ -7,7 +7,8 @@ use std::{ use indexmap::{IndexMap, IndexSet}; use itertools::Either; -use rattler_conda_types::{NamelessMatchSpec, PackageName, Platform}; +use pixi_spec::PixiSpec; +use rattler_conda_types::{PackageName, Platform}; use rattler_solve::ChannelPriority; use serde::{de::Error, Deserialize, Deserializer}; use serde_with::{serde_as, SerializeDisplay}; @@ -196,7 +197,7 @@ impl Feature { &self, spec_type: Option, platform: Option, - ) -> Option>> { + ) -> Option>> { self.targets .resolve(platform) // Get the targets in reverse order, from least specific to most specific. @@ -312,13 +313,13 @@ impl<'de> Deserialize<'de> for Feature { target: IndexMap, Target>, #[serde(default, deserialize_with = "deserialize_package_map")] - dependencies: IndexMap, + dependencies: IndexMap, #[serde(default, deserialize_with = "deserialize_opt_package_map")] - host_dependencies: Option>, + host_dependencies: Option>, #[serde(default, deserialize_with = "deserialize_opt_package_map")] - build_dependencies: Option>, + build_dependencies: Option>, #[serde(default)] pypi_dependencies: Option>, diff --git a/crates/pixi_manifest/src/has_manifest_ref.rs b/crates/pixi_manifest/src/has_manifest_ref.rs index becb0cede..03be1a4f3 100644 --- a/crates/pixi_manifest/src/has_manifest_ref.rs +++ b/crates/pixi_manifest/src/has_manifest_ref.rs @@ -5,3 +5,9 @@ pub trait HasManifestRef<'source> { /// Returns access to the original pixi manifest fn manifest(&self) -> &'source crate::Manifest; } + +impl<'source> HasManifestRef<'source> for &'source crate::Manifest { + fn manifest(&self) -> &'source crate::Manifest { + self + } +} diff --git a/crates/pixi_manifest/src/lib.rs b/crates/pixi_manifest/src/lib.rs index 3e8bd3e42..a537e0482 100644 --- a/crates/pixi_manifest/src/lib.rs +++ b/crates/pixi_manifest/src/lib.rs @@ -11,7 +11,6 @@ mod has_features_iter; mod has_manifest_ref; mod manifest; mod metadata; -mod nameless_matchspec; mod parsed_manifest; pub mod pypi; pub mod pyproject; diff --git a/crates/pixi_manifest/src/manifest.rs b/crates/pixi_manifest/src/manifest.rs index d2ad28a91..4b8958477 100644 --- a/crates/pixi_manifest/src/manifest.rs +++ b/crates/pixi_manifest/src/manifest.rs @@ -1,25 +1,31 @@ -use crate::document::ManifestSource; -use crate::error::{DependencyError, TomlError, UnknownFeature}; -use crate::pypi::PyPiPackageName; -use crate::pyproject::PyProjectManifest; -use crate::{ - consts, to_options, DependencyOverwriteBehavior, Environment, EnvironmentName, Feature, - FeatureName, GetFeatureError, ParsedManifest, PrioritizedChannel, SpecType, Target, - TargetSelector, Task, TaskName, +use std::{ + borrow::Borrow, + collections::HashMap, + ffi::OsStr, + fmt::Display, + hash::Hash, + path::{Path, PathBuf}, + str::FromStr, }; + use indexmap::{Equivalent, IndexSet}; use itertools::Itertools; use miette::{miette, IntoDiagnostic, NamedSource, WrapErr}; +use pixi_spec::PixiSpec; use rattler_conda_types::{ChannelConfig, MatchSpec, PackageName, Platform, Version}; -use std::borrow::Borrow; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::fmt::Display; -use std::hash::Hash; -use std::path::{Path, PathBuf}; -use std::str::FromStr; use toml_edit::DocumentMut; +use crate::{ + consts, + document::ManifestSource, + error::{DependencyError, TomlError, UnknownFeature}, + pypi::PyPiPackageName, + pyproject::PyProjectManifest, + to_options, DependencyOverwriteBehavior, Environment, EnvironmentName, Feature, FeatureName, + GetFeatureError, ParsedManifest, PrioritizedChannel, SpecType, Target, TargetSelector, Task, + TaskName, +}; + #[derive(Debug, Clone)] pub enum ManifestKind { Pixi, @@ -342,6 +348,7 @@ impl Manifest { let (Some(name), spec) = spec.clone().into_nameless() else { miette::bail!("pixi does not support wildcard dependencies") }; + let spec = PixiSpec::from_nameless_matchspec(spec, channel_config); let mut any_added = false; for platform in to_options(platforms) { // Add the dependency to the manifest @@ -356,7 +363,6 @@ impl Manifest { spec_type, platform, feature_name, - channel_config, )?; any_added = true; } @@ -635,20 +641,22 @@ impl Manifest { #[cfg(test)] mod tests { - use indexmap::IndexMap; use std::str::FromStr; + use indexmap::IndexMap; use insta::assert_snapshot; use miette::NarratableReportHandler; - use rattler_conda_types::ParseStrictness::Strict; - use rattler_conda_types::{NamedChannelOrUrl, ParseStrictness}; + use rattler_conda_types::{ + NamedChannelOrUrl, ParseStrictness, + ParseStrictness::{Lenient, Strict}, + VersionSpec, + }; use rattler_solve::ChannelPriority; use rstest::*; use tempfile::tempdir; use super::*; - use crate::manifest::Manifest; - use crate::{channel::PrioritizedChannel, utils::default_channel_config}; + use crate::{channel::PrioritizedChannel, manifest::Manifest}; const PROJECT_BOILERPLATE: &str = r#" [project] @@ -658,6 +666,12 @@ mod tests { platforms = ["linux-64", "win-64", "osx-64"] "#; + fn default_channel_config() -> rattler_conda_types::ChannelConfig { + rattler_conda_types::ChannelConfig::default_with_root_dir( + std::env::current_dir().expect("Could not retrieve the current directory"), + ) + } + #[test] fn test_from_path() { // Test the toml from a path @@ -1644,8 +1658,8 @@ platforms = ["linux-64", "win-64"] .unwrap() .get(&PackageName::from_str("cuda").unwrap()) .unwrap() - .to_string(), - "==x.y.z" + .as_version_spec(), + Some(&VersionSpec::from_str("x.y.z", Lenient).unwrap()) ); assert_eq!( cuda_feature @@ -1656,8 +1670,8 @@ platforms = ["linux-64", "win-64"] .unwrap() .get(&PackageName::from_str("cudnn").unwrap()) .unwrap() - .to_string(), - "==12.0" + .as_version_spec(), + Some(&VersionSpec::from_str("12", Lenient).unwrap()) ); assert_eq!( cuda_feature @@ -1681,8 +1695,8 @@ platforms = ["linux-64", "win-64"] .unwrap() .get(&PackageName::from_str("cmake").unwrap()) .unwrap() - .to_string(), - "*" + .as_version_spec(), + Some(&VersionSpec::Any) ); assert_eq!( cuda_feature @@ -1733,8 +1747,8 @@ platforms = ["linux-64", "win-64"] .unwrap() .get(&PackageName::from_str("mlx").unwrap()) .unwrap() - .to_string(), - "==x.y.z" + .as_version_spec(), + Some(&VersionSpec::from_str("x.y.z", Lenient).unwrap()) ); assert_eq!( cuda_feature @@ -1836,6 +1850,7 @@ foo = "*" [feature.test.dependencies] bar = "*" "#; + let channel_config = default_channel_config(); let mut manifest = Manifest::from_str(Path::new("pixi.toml"), file_contents).unwrap(); manifest .add_dependency( @@ -1844,7 +1859,7 @@ bar = "*" &[], &FeatureName::Default, DependencyOverwriteBehavior::Overwrite, - &default_channel_config(), + &channel_config, ) .unwrap(); assert_eq!( @@ -1857,8 +1872,8 @@ bar = "*" .unwrap() .get(&PackageName::from_str("baz").unwrap()) .unwrap() - .to_string(), - ">=1.2.3".to_string() + .as_version_spec(), + Some(&VersionSpec::from_str(">=1.2.3", Strict).unwrap()) ); manifest .add_dependency( @@ -1867,7 +1882,7 @@ bar = "*" &[], &FeatureName::Named("test".to_string()), DependencyOverwriteBehavior::Overwrite, - &default_channel_config(), + &channel_config, ) .unwrap(); @@ -1882,6 +1897,8 @@ bar = "*" .unwrap() .get(&PackageName::from_str("bal").unwrap()) .unwrap() + .as_version_spec() + .unwrap() .to_string(), ">=2.3".to_string() ); @@ -1893,7 +1910,7 @@ bar = "*" &[Platform::Linux64], &FeatureName::Named("extra".to_string()), DependencyOverwriteBehavior::Overwrite, - &default_channel_config(), + &channel_config, ) .unwrap(); @@ -1909,6 +1926,8 @@ bar = "*" .unwrap() .get(&PackageName::from_str("boef").unwrap()) .unwrap() + .as_version_spec() + .unwrap() .to_string(), ">=2.3".to_string() ); @@ -1920,23 +1939,20 @@ bar = "*" &[Platform::Linux64], &FeatureName::Named("build".to_string()), DependencyOverwriteBehavior::Overwrite, - &default_channel_config(), + &channel_config, ) .unwrap(); assert_eq!( manifest .feature(&FeatureName::Named("build".to_string())) - .unwrap() - .targets - .for_target(&TargetSelector::Platform(Platform::Linux64)) - .unwrap() - .dependencies - .get(&SpecType::Build) - .unwrap() - .get(&PackageName::from_str("cmake").unwrap()) - .unwrap() - .to_string(), + .map(|f| &f.targets) + .and_then(|t| t.for_target(&TargetSelector::Platform(Platform::Linux64))) + .and_then(|t| t.dependencies.get(&SpecType::Build)) + .and_then(|deps| deps.get(&PackageName::from_str("cmake").unwrap())) + .and_then(|spec| spec.as_version_spec()) + .map(|spec| spec.to_string()) + .unwrap(), ">=2.3".to_string() ); diff --git a/crates/pixi_manifest/src/nameless_matchspec.rs b/crates/pixi_manifest/src/nameless_matchspec.rs deleted file mode 100644 index fa9239c6a..000000000 --- a/crates/pixi_manifest/src/nameless_matchspec.rs +++ /dev/null @@ -1,32 +0,0 @@ -use rattler_conda_types::NamelessMatchSpec; -use rattler_conda_types::ParseStrictness::{Lenient, Strict}; -use serde::de::DeserializeSeed; -use serde::{Deserialize, Deserializer}; - -pub(crate) struct NamelessMatchSpecWrapper {} - -impl<'de, 'a> DeserializeSeed<'de> for &'a NamelessMatchSpecWrapper { - type Value = NamelessMatchSpec; - - fn deserialize(self, deserializer: D) -> Result - where - D: Deserializer<'de>, - { - serde_untagged::UntaggedEnumVisitor::new() - .string(|str| { - match NamelessMatchSpec::from_str(str, Strict) { - Ok(spec) => Ok(spec), - Err(_) => { - let spec = NamelessMatchSpec::from_str(str, Lenient).map_err(serde::de::Error::custom)?; - tracing::warn!("Parsed '{str}' as '{spec}', in a future version this will become an error.", spec=&spec); - Ok(spec) - } - } - }) - .map(|map| { - NamelessMatchSpec::deserialize(serde::de::value::MapAccessDeserializer::new(map)) - }) - .expecting("either a map or a string") - .deserialize(deserializer) - } -} diff --git a/crates/pixi_manifest/src/parsed_manifest.rs b/crates/pixi_manifest/src/parsed_manifest.rs index 88fab25b8..c9b1e9505 100644 --- a/crates/pixi_manifest/src/parsed_manifest.rs +++ b/crates/pixi_manifest/src/parsed_manifest.rs @@ -1,33 +1,32 @@ -use crate::activation::Activation; -use crate::environment::{Environment, EnvironmentIdx, EnvironmentName, TomlEnvironmentMapOrSeq}; -use crate::environments::Environments; -use crate::error::TomlError; -use crate::feature::{Feature, FeatureName}; -use crate::metadata::ProjectMetadata; -use crate::pypi::pypi_options::PypiOptions; -use crate::pypi::pypi_requirement::PyPiRequirement; -use crate::pypi::pypi_requirement_types::PyPiPackageName; -use crate::solve_group::SolveGroups; -use crate::spec_type::SpecType; -use crate::system_requirements::SystemRequirements; -use crate::target::{Target, TargetSelector, Targets}; -use crate::task::{Task, TaskName}; -use crate::utils::PixiSpanned; -use crate::{consts, nameless_matchspec::NamelessMatchSpecWrapper}; -use indexmap::map::IndexMap; -use indexmap::Equivalent; -use rattler_conda_types::NamelessMatchSpec; +use std::{collections::HashMap, fmt, hash::Hash, iter::FromIterator, marker::PhantomData}; + +use indexmap::{map::IndexMap, Equivalent}; +use pixi_spec::PixiSpec; use rattler_conda_types::PackageName; use serde::de::{Deserialize, DeserializeSeed, Deserializer, MapAccess, Visitor}; -use serde_with::serde_as; -use serde_with::serde_derive::Deserialize; -use std::collections::HashMap; -use std::fmt; -use std::hash::Hash; -use std::iter::FromIterator; -use std::marker::PhantomData; +use serde_with::{serde_as, serde_derive::Deserialize}; use toml_edit::DocumentMut; +use crate::{ + activation::Activation, + consts, + environment::{Environment, EnvironmentIdx, EnvironmentName, TomlEnvironmentMapOrSeq}, + environments::Environments, + error::TomlError, + feature::{Feature, FeatureName}, + metadata::ProjectMetadata, + pypi::{ + pypi_options::PypiOptions, pypi_requirement::PyPiRequirement, + pypi_requirement_types::PyPiPackageName, + }, + solve_group::SolveGroups, + spec_type::SpecType, + system_requirements::SystemRequirements, + target::{Target, TargetSelector, Targets}, + task::{Task, TaskName}, + utils::PixiSpanned, +}; + /// Describes the contents of a parsed project manifest. #[derive(Debug, Clone)] pub struct ParsedManifest { @@ -123,13 +122,13 @@ impl<'de> Deserialize<'de> for ParsedManifest { // #[serde(flatten)] // default_target: Target, #[serde(default, deserialize_with = "deserialize_package_map")] - dependencies: IndexMap, + dependencies: IndexMap, #[serde(default, deserialize_with = "deserialize_opt_package_map")] - host_dependencies: Option>, + host_dependencies: Option>, #[serde(default, deserialize_with = "deserialize_opt_package_map")] - build_dependencies: Option>, + build_dependencies: Option>, #[serde(default)] pypi_dependencies: Option>, @@ -262,7 +261,7 @@ impl<'de> Deserialize<'de> for ParsedManifest { } } -struct PackageMap<'a>(&'a IndexMap); +struct PackageMap<'a>(&'a IndexMap); impl<'de, 'a> DeserializeSeed<'de> for PackageMap<'a> { type Value = PackageName; @@ -286,14 +285,14 @@ impl<'de, 'a> DeserializeSeed<'de> for PackageMap<'a> { pub fn deserialize_package_map<'de, D>( deserializer: D, -) -> Result, D::Error> +) -> Result, D::Error> where D: Deserializer<'de>, { struct PackageMapVisitor(PhantomData<()>); impl<'de> Visitor<'de> for PackageMapVisitor { - type Value = IndexMap; + type Value = IndexMap; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "a map") @@ -304,14 +303,16 @@ where A: MapAccess<'de>, { let mut result = IndexMap::new(); - let match_spec = NamelessMatchSpecWrapper {}; - while let Some((package_name, match_spec)) = map - .next_entry_seed::( - PackageMap(&result), - &match_spec, - )? + while let Some((package_name, spec)) = + map.next_entry_seed::(PackageMap(&result), PhantomData::)? { - result.insert(package_name, match_spec); + if spec.is_source() { + return Err(serde::de::Error::custom( + "source dependencies are not allowed yet", + )); + } + + result.insert(package_name, spec); } Ok(result) @@ -323,7 +324,7 @@ where pub fn deserialize_opt_package_map<'de, D>( deserializer: D, -) -> Result>, D::Error> +) -> Result>, D::Error> where D: Deserializer<'de>, { @@ -332,11 +333,11 @@ where #[cfg(test)] mod tests { - use crate::parsed_manifest::ParsedManifest; - use crate::TargetSelector; use insta::{assert_snapshot, assert_yaml_snapshot}; use itertools::Itertools; - use rattler_conda_types::{Channel, Platform}; + use rattler_conda_types::{NamedChannelOrUrl, Platform}; + + use crate::{parsed_manifest::ParsedManifest, TargetSelector}; const PROJECT_BOILERPLATE: &str = r#" [project] @@ -382,6 +383,8 @@ mod tests { .unwrap() .get("foo") .unwrap() + .as_version_spec() + .unwrap() .to_string(), "==3.4.5" ); @@ -391,6 +394,8 @@ mod tests { .unwrap() .get("foo") .unwrap() + .as_version_spec() + .unwrap() .to_string(), "==1.2.3" ); @@ -418,41 +423,69 @@ mod tests { .default() .run_dependencies() .unwrap(); - let test_map_spec = deps.get("test_map").unwrap(); + let test_map_spec = deps.get("test_map").unwrap().as_detailed().unwrap(); - assert_eq!(test_map_spec.to_string(), ">=1.2.3 py34_0"); assert_eq!( - test_map_spec - .channel - .as_deref() - .map(Channel::canonical_name), - Some(String::from("https://conda.anaconda.org/conda-forge/")) + test_map_spec.version.as_ref().unwrap().to_string(), + ">=1.2.3" + ); + assert_eq!(test_map_spec.build.as_ref().unwrap().to_string(), "py34_0"); + assert_eq!( + test_map_spec.channel, + Some(NamedChannelOrUrl::Name("conda-forge".to_string())) ); - assert_eq!(deps.get("test_build").unwrap().to_string(), "* bla"); + assert_eq!( + deps.get("test_build") + .unwrap() + .as_detailed() + .unwrap() + .build + .as_ref() + .unwrap() + .to_string(), + "bla" + ); - let test_channel = deps.get("test_channel").unwrap(); - assert_eq!(test_channel.to_string(), "*"); + let test_channel = deps.get("test_channel").unwrap().as_detailed().unwrap(); assert_eq!( - test_channel.channel.as_deref().map(Channel::canonical_name), - Some(String::from("https://conda.anaconda.org/conda-forge/")) + test_channel.channel, + Some(NamedChannelOrUrl::Name("conda-forge".to_string())) ); - let test_version = deps.get("test_version").unwrap(); - assert_eq!(test_version.to_string(), ">=1.2.3"); + let test_version = deps.get("test_version").unwrap().as_detailed().unwrap(); + assert_eq!( + test_version.version.as_ref().unwrap().to_string(), + ">=1.2.3" + ); - let test_version_channel = deps.get("test_version_channel").unwrap(); - assert_eq!(test_version_channel.to_string(), ">=1.2.3"); + let test_version_channel = deps + .get("test_version_channel") + .unwrap() + .as_detailed() + .unwrap(); + assert_eq!( + test_version_channel.version.as_ref().unwrap().to_string(), + ">=1.2.3" + ); assert_eq!( - test_version_channel - .channel - .as_deref() - .map(Channel::canonical_name), - Some(String::from("https://conda.anaconda.org/conda-forge/")) + test_version_channel.channel, + Some(NamedChannelOrUrl::Name("conda-forge".to_string())) ); - let test_version_build = deps.get("test_version_build").unwrap(); - assert_eq!(test_version_build.to_string(), ">=1.2.3 py34_0"); + let test_version_build = deps + .get("test_version_build") + .unwrap() + .as_detailed() + .unwrap(); + assert_eq!( + test_version_build.version.as_ref().unwrap().to_string(), + ">=1.2.3" + ); + assert_eq!( + test_version_build.build.as_ref().unwrap().to_string(), + "py34_0" + ); } #[test] @@ -478,11 +511,32 @@ mod tests { let host_dependencies = default_target.host_dependencies().unwrap(); assert_eq!( - run_dependencies.get("my-game").unwrap().to_string(), + run_dependencies + .get("my-game") + .unwrap() + .as_version_spec() + .unwrap() + .to_string(), "==1.0.0" ); - assert_eq!(build_dependencies.get("cmake").unwrap().to_string(), "*"); - assert_eq!(host_dependencies.get("sdl2").unwrap().to_string(), "*"); + assert_eq!( + build_dependencies + .get("cmake") + .unwrap() + .as_version_spec() + .unwrap() + .to_string(), + "*" + ); + assert_eq!( + host_dependencies + .get("sdl2") + .unwrap() + .as_version_spec() + .unwrap() + .to_string(), + "*" + ); } #[test] diff --git a/crates/pixi_manifest/src/pyproject.rs b/crates/pixi_manifest/src/pyproject.rs index 638b9beb1..faca72bb2 100644 --- a/crates/pixi_manifest/src/pyproject.rs +++ b/crates/pixi_manifest/src/pyproject.rs @@ -4,8 +4,9 @@ use indexmap::IndexMap; use miette::{IntoDiagnostic, Report, WrapErr}; use pep440_rs::VersionSpecifiers; use pep508_rs::Requirement; +use pixi_spec::PixiSpec; use pyproject_toml::{self, Project}; -use rattler_conda_types::{NamelessMatchSpec, PackageName, ParseStrictness::Lenient, VersionSpec}; +use rattler_conda_types::{PackageName, ParseStrictness::Lenient, VersionSpec}; use serde::Deserialize; use toml_edit::DocumentMut; @@ -184,7 +185,8 @@ impl PyProjectManifest { .and_then(|n| pep508_rs::PackageName::new(n).ok()) } - /// Returns optional dependencies from the `[project.optional-dependencies]` table + /// Returns optional dependencies from the `[project.optional-dependencies]` + /// table fn optional_dependencies(&self) -> Option>> { self.project().and_then(|p| p.optional_dependencies.clone()) } @@ -228,15 +230,17 @@ impl From for ParsedManifest { .clone(); // Set pixi project name, version, description and authors (if they are not set) - // with the ones from the `[project]` or `[tool.poetry]` tables of the `pyproject.toml`. + // with the ones from the `[project]` or `[tool.poetry]` tables of the + // `pyproject.toml`. manifest.project.name = item.name(); manifest.project.description = item.description(); manifest.project.version = item.version().and_then(|v| v.parse().ok()); manifest.project.authors = item.authors(); - // TODO: would be nice to add license, license-file, readme, homepage, repository, documentation, - // regarding the above, the types are a bit different than we expect, so the conversion is not straightforward - // we could change these types or we can convert. Let's decide when we make it. + // TODO: would be nice to add license, license-file, readme, homepage, + // repository, documentation, regarding the above, the types are a bit + // different than we expect, so the conversion is not straightforward we + // could change these types or we can convert. Let's decide when we make it. // etc. // Add python as dependency based on the `project.requires_python` property @@ -249,7 +253,7 @@ impl From for ParsedManifest { if !target.has_dependency(&python, Some(SpecType::Run), None) { target.add_dependency( &python, - &version_or_url_to_nameless_matchspec(&python_spec).unwrap(), + &version_or_url_to_spec(&python_spec).unwrap(), SpecType::Run, ); } else if let Some(_spec) = python_spec { @@ -297,22 +301,18 @@ impl From for ParsedManifest { /// Try to return a NamelessMatchSpec from a pep508_rs::VersionOrUrl /// This will only work if it is not URL and the VersionSpecifier can /// successfully be interpreted as a NamelessMatchSpec.version -fn version_or_url_to_nameless_matchspec( +fn version_or_url_to_spec( version: &Option, -) -> Result { +) -> Result { match version { // TODO: avoid going through string representation for conversion Some(v) => { let version_string = v.to_string(); // Double equals works a bit different in conda vs. python let version_string = version_string.strip_prefix("==").unwrap_or(&version_string); - - Ok(NamelessMatchSpec::from_str(version_string, Lenient)?) + Ok(VersionSpec::from_str(version_string, Lenient)?.into()) } - None => Ok(NamelessMatchSpec { - version: Some(VersionSpec::Any), - ..Default::default() - }), + None => Ok(PixiSpec::default()), } } @@ -577,9 +577,10 @@ mod tests { fn test_version_url_to_matchspec() { fn cmp(v1: &str, v2: &str) { let v = VersionSpecifiers::from_str(v1).unwrap(); - let matchspec = super::version_or_url_to_nameless_matchspec(&Some(v)).unwrap(); + let matchspec = super::version_or_url_to_spec(&Some(v)).unwrap(); + let version_spec = matchspec.as_version_spec().unwrap(); let vspec = VersionSpec::from_str(v2, ParseStrictness::Strict).unwrap(); - assert_eq!(matchspec.version, Some(vspec)); + assert_eq!(version_spec, &vspec); } // Check that we remove leading `==` for the conda version spec diff --git a/crates/pixi_manifest/src/snapshots/pixi_manifest__document__tests__nameless_to_toml.snap b/crates/pixi_manifest/src/snapshots/pixi_manifest__document__tests__nameless_to_toml.snap index afd948c5e..4dad7609d 100644 --- a/crates/pixi_manifest/src/snapshots/pixi_manifest__document__tests__nameless_to_toml.snap +++ b/crates/pixi_manifest/src/snapshots/pixi_manifest__document__tests__nameless_to_toml.snap @@ -1,10 +1,10 @@ --- source: crates/pixi_manifest/src/document.rs -assertion_line: 492 +assertion_line: 408 expression: table --- "rattler >=1" = ">=1" -"conda-forge::rattler" = { version = "*", channel = "conda-forge" } +"conda-forge::rattler" = { channel = "conda-forge" } "conda-forge::rattler[version=>3.0]" = { version = ">3.0", channel = "conda-forge" } "rattler 1 *cuda" = { version = "==1", build = "*cuda" } "rattler >=1 *cuda" = { version = ">=1", build = "*cuda" } diff --git a/crates/pixi_manifest/src/target.rs b/crates/pixi_manifest/src/target.rs index d3889c230..5ac6319a0 100644 --- a/crates/pixi_manifest/src/target.rs +++ b/crates/pixi_manifest/src/target.rs @@ -2,9 +2,10 @@ use std::{borrow::Cow, collections::HashMap, str::FromStr}; use indexmap::{map::Entry, IndexMap}; use itertools::Either; -use rattler_conda_types::{NamelessMatchSpec, PackageName, Platform}; +use pixi_spec::PixiSpec; +use rattler_conda_types::{PackageName, Platform}; use serde::{Deserialize, Deserializer}; -use serde_with::{serde_as, DisplayFromStr, PickFirst}; +use serde_with::serde_as; use super::error::DependencyError; use crate::{ @@ -21,7 +22,7 @@ use crate::{ #[derive(Default, Debug, Clone)] pub struct Target { /// Dependencies for this target. - pub dependencies: HashMap>, + pub dependencies: HashMap>, /// Specific python dependencies pub pypi_dependencies: Option>, @@ -35,17 +36,17 @@ pub struct Target { impl Target { /// Returns the run dependencies of the target - pub fn run_dependencies(&self) -> Option<&IndexMap> { + pub fn run_dependencies(&self) -> Option<&IndexMap> { self.dependencies.get(&SpecType::Run) } /// Returns the host dependencies of the target - pub fn host_dependencies(&self) -> Option<&IndexMap> { + pub fn host_dependencies(&self) -> Option<&IndexMap> { self.dependencies.get(&SpecType::Host) } /// Returns the build dependencies of the target - pub fn build_dependencies(&self) -> Option<&IndexMap> { + pub fn build_dependencies(&self) -> Option<&IndexMap> { self.dependencies.get(&SpecType::Build) } @@ -63,7 +64,7 @@ impl Target { pub fn dependencies( &self, spec_type: Option, - ) -> Option>> { + ) -> Option>> { if let Some(spec_type) = spec_type { self.dependencies.get(&spec_type).map(Cow::Borrowed) } else { @@ -81,7 +82,7 @@ impl Target { /// /// This function returns a `Cow` to avoid cloning the dependencies if they /// can be returned directly from the underlying map. - fn combined_dependencies(&self) -> Option>> { + fn combined_dependencies(&self) -> Option>> { let mut all_deps = None; for spec_type in [SpecType::Run, SpecType::Host, SpecType::Build] { let Some(specs) = self.dependencies.get(&spec_type) else { @@ -113,7 +114,7 @@ impl Target { &self, dep_name: &PackageName, spec_type: Option, - exact: Option<&NamelessMatchSpec>, + exact: Option<&PixiSpec>, ) -> bool { let current_dependency = self .dependencies(spec_type) @@ -133,7 +134,7 @@ impl Target { &mut self, dep_name: &PackageName, spec_type: SpecType, - ) -> Result<(PackageName, NamelessMatchSpec), DependencyError> { + ) -> Result<(PackageName, PixiSpec), DependencyError> { let Some(dependencies) = self.dependencies.get_mut(&spec_type) else { return Err(DependencyError::NoSpecType(spec_type.name().into())); }; @@ -145,12 +146,7 @@ impl Target { /// Adds a dependency to a target /// /// This will overwrite any existing dependency of the same name - pub fn add_dependency( - &mut self, - dep_name: &PackageName, - spec: &NamelessMatchSpec, - spec_type: SpecType, - ) { + pub fn add_dependency(&mut self, dep_name: &PackageName, spec: &PixiSpec, spec_type: SpecType) { self.dependencies .entry(spec_type) .or_default() @@ -164,13 +160,13 @@ impl Target { pub fn try_add_dependency( &mut self, dep_name: &PackageName, - spec: &NamelessMatchSpec, + spec: &PixiSpec, spec_type: SpecType, dependency_overwrite_behavior: DependencyOverwriteBehavior, ) -> Result { if self.has_dependency(dep_name, Some(spec_type), None) { match dependency_overwrite_behavior { - DependencyOverwriteBehavior::OverwriteIfExplicit if spec.version.is_none() => { + DependencyOverwriteBehavior::OverwriteIfExplicit if !spec.has_version_spec() => { return Ok(false) } DependencyOverwriteBehavior::IgnoreDuplicate => return Ok(false), @@ -342,16 +338,13 @@ impl<'de> Deserialize<'de> for Target { #[serde(deny_unknown_fields)] pub struct TomlTarget { #[serde(default)] - #[serde_as(as = "IndexMap<_, PickFirst<(DisplayFromStr, _)>>")] - dependencies: IndexMap, + dependencies: IndexMap, #[serde(default)] - #[serde_as(as = "Option>>")] - host_dependencies: Option>, + host_dependencies: Option>, #[serde(default)] - #[serde_as(as = "Option>>")] - build_dependencies: Option>, + build_dependencies: Option>, #[serde(default)] pypi_dependencies: Option>, @@ -595,7 +588,7 @@ mod tests { .dependencies(None) .unwrap_or_default() .iter() - .map(|(name, spec)| format!("{} = {}", name.as_source(), spec)) + .map(|(name, spec)| format!("{} = {}", name.as_source(), spec.as_version_spec().unwrap().to_string())) .join("\n"), @r###" run = ==2.0 host = ==2.0 diff --git a/crates/pixi_manifest/src/utils/mod.rs b/crates/pixi_manifest/src/utils/mod.rs index 6f92c907b..399224d7c 100644 --- a/crates/pixi_manifest/src/utils/mod.rs +++ b/crates/pixi_manifest/src/utils/mod.rs @@ -16,10 +16,3 @@ pub(crate) fn extract_directory_from_url(url: &Url) -> Option { .find_map(|fragment| fragment.strip_prefix("subdirectory="))?; Some(subdirectory.into()) } - -#[cfg(test)] -pub(crate) fn default_channel_config() -> rattler_conda_types::ChannelConfig { - rattler_conda_types::ChannelConfig::default_with_root_dir( - std::env::current_dir().expect("Could not retrieve the current directory"), - ) -} diff --git a/crates/pixi_spec/Cargo.toml b/crates/pixi_spec/Cargo.toml new file mode 100644 index 000000000..5c5c5c6e9 --- /dev/null +++ b/crates/pixi_spec/Cargo.toml @@ -0,0 +1,28 @@ +[package] +authors.workspace = true +description = "Provides a Rust representation of a Pixi spec" +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "pixi_spec" +readme.workspace = true +repository.workspace = true +version = "0.1.0" + +[dependencies] +dirs = { workspace = true } +file_url = { workspace = true } +rattler_conda_types = { workspace = true } +rattler_digest = { workspace = true, features = ["serde"] } +serde = { workspace = true } +serde-untagged = { workspace = true } +serde_with = { workspace = true } +thiserror = { workspace = true } +toml_edit = { workspace = true, optional = true } +typed-path = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +insta = { workspace = true, features = ["yaml", "filters"] } +serde_json = { workspace = true, features = ["preserve_order"] } +serde_yaml = { workspace = true } diff --git a/crates/pixi_spec/src/detailed.rs b/crates/pixi_spec/src/detailed.rs new file mode 100644 index 000000000..6ecd39414 --- /dev/null +++ b/crates/pixi_spec/src/detailed.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use rattler_conda_types::{ + BuildNumberSpec, ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, StringMatcher, + VersionSpec, +}; +use rattler_digest::{Md5Hash, Sha256Hash}; +use serde_with::{serde_as, skip_serializing_none}; +/// A specification for a package in a conda channel. +/// +/// This type maps closely to [`rattler_conda_types::NamelessMatchSpec`] but +/// does not represent a `url` field. To represent a `url` spec, use +/// [`crate::UrlSpec`] instead. +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Hash, Eq, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct DetailedSpec { + /// The version spec of the package (e.g. `1.2.3`, `>=1.2.3`, `1.2.*`) + #[serde_as(as = "Option")] + pub version: Option, + + /// The build string of the package (e.g. `py37_0`, `py37h6de7cb9_0`, `py*`) + #[serde_as(as = "Option")] + pub build: Option, + + /// The build number of the package + #[serde_as(as = "Option")] + pub build_number: Option, + + /// Match the specific filename of the package + pub file_name: Option, + + /// The channel of the package + pub channel: Option, + + /// The subdir of the channel + pub subdir: Option, + + /// The md5 hash of the package + #[serde_as(as = "Option>")] + pub md5: Option, + + /// The sha256 hash of the package + #[serde_as(as = "Option>")] + pub sha256: Option, +} + +impl DetailedSpec { + /// Converts this instance into a [`NamelessMatchSpec`]. + pub fn into_nameless_match_spec(self, channel_config: &ChannelConfig) -> NamelessMatchSpec { + NamelessMatchSpec { + version: self.version, + build: self.build, + build_number: self.build_number, + file_name: self.file_name, + channel: self + .channel + .map(|c| c.into_channel(channel_config)) + .map(Arc::new), + subdir: self.subdir, + namespace: None, + md5: self.md5, + sha256: self.sha256, + url: None, + } + } +} diff --git a/crates/pixi_spec/src/git.rs b/crates/pixi_spec/src/git.rs new file mode 100644 index 000000000..c6e4ca812 --- /dev/null +++ b/crates/pixi_spec/src/git.rs @@ -0,0 +1,27 @@ +use url::Url; + +/// A specification of a package from a git repository. +#[derive(Debug, Clone, Hash, Eq, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct GitSpec { + /// The git url of the package + pub git: Url, + + /// The git revision of the package + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub rev: Option, +} + +/// A reference to a specific commit in a git repository. +#[derive(Debug, Clone, Hash, Eq, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GitReference { + /// The HEAD commit of a branch. + Branch(String), + + /// A specific tag. + Tag(String), + + /// A specific commit. + Rev(String), +} diff --git a/crates/pixi_spec/src/lib.rs b/crates/pixi_spec/src/lib.rs new file mode 100644 index 000000000..25a5eb6b9 --- /dev/null +++ b/crates/pixi_spec/src/lib.rs @@ -0,0 +1,459 @@ +#![deny(missing_docs)] + +//! This crate defines the [`PixiSpec`] type which represents a package +//! specification for pixi. +//! +//! The `PixiSpec` type represents the user input for a package. It can +//! represent both source and binary packages. The `PixiSpec` type can +//! optionally be converted to a `NamelessMatchSpec` which is used to match +//! binary packages. + +mod detailed; +mod git; +mod path; +mod serde; +mod url; + +use std::{path::PathBuf, str::FromStr}; + +pub use detailed::DetailedSpec; +pub use git::{GitReference, GitSpec}; +pub use path::{PathSourceSpec, PathSpec}; +use rattler_conda_types::{ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, VersionSpec}; +use thiserror::Error; +pub use url::{UrlSourceSpec, UrlSpec}; + +/// An error that is returned when a spec cannot be converted into another spec +/// type. +#[derive(Debug, Error)] +pub enum SpecConversionError { + /// The root directory is not an absolute path + #[error("root directory from channel config is not an absolute path")] + NonAbsoluteRootDir(PathBuf), + + /// The root directory is not UTF-8 encoded. + #[error("root directory of channel config is not utf8 encoded")] + NotUtf8RootDir(PathBuf), + + /// Encountered an invalid path + #[error("invalid path '{0}'")] + InvalidPath(String), +} + +/// A package specification for pixi. +/// +/// This type can represent both source and binary packages. Use the +/// [`Self::try_into_nameless_match_spec`] method to convert this type into a +/// type that only represents binary packages. +#[derive(Debug, Clone, Hash, ::serde::Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum PixiSpec { + /// The spec is represented solely by a version string. The package should + /// be retrieved from a channel. + /// + /// This is similar to the `DetailedVersion` variant but with a simplified + /// version spec. + Version(VersionSpec), + + /// The spec is represented by a detailed version spec. The package should + /// be retrieved from a channel. + DetailedVersion(DetailedSpec), + + /// The spec is represented as an archive that can be downloaded from the + /// specified URL. The package should be retrieved from the URL and can + /// either represent a source or binary package depending on the archive + /// type. + Url(UrlSpec), + + /// The spec is represented as a git repository. The package represents a + /// source distribution of some kind. + Git(GitSpec), + + /// The spec is represented as a local path. The package should be retrieved + /// from the local filesystem. The package can be either a source or binary + /// package. + Path(PathSpec), +} + +impl Default for PixiSpec { + fn default() -> Self { + PixiSpec::Version(VersionSpec::Any) + } +} + +impl From for PixiSpec { + fn from(value: VersionSpec) -> Self { + Self::Version(value) + } +} + +impl PixiSpec { + /// Convert a [`NamelessMatchSpec`] into a [`PixiSpec`]. + pub fn from_nameless_matchspec( + spec: NamelessMatchSpec, + channel_config: &ChannelConfig, + ) -> Self { + if let Some(url) = spec.url { + Self::Url(UrlSpec { + url, + md5: spec.md5, + sha256: spec.sha256, + }) + } else if spec.build.is_none() + && spec.build_number.is_none() + && spec.file_name.is_none() + && spec.channel.is_none() + && spec.subdir.is_none() + && spec.md5.is_none() + && spec.sha256.is_none() + { + Self::Version(spec.version.unwrap_or(VersionSpec::Any)) + } else { + Self::DetailedVersion(DetailedSpec { + version: spec.version, + build: spec.build, + build_number: spec.build_number, + file_name: spec.file_name, + channel: spec.channel.map(|c| { + NamedChannelOrUrl::from_str(&channel_config.canonical_name(c.base_url())) + .unwrap() + }), + subdir: spec.subdir, + md5: spec.md5, + sha256: spec.sha256, + }) + } + } + + /// Returns true if this spec has a version spec. `*` does not count as a + /// valid version spec. + pub fn has_version_spec(&self) -> bool { + match self { + Self::Version(v) => v != &VersionSpec::Any, + Self::DetailedVersion(v) => v.version.as_ref().is_some_and(|v| v != &VersionSpec::Any), + _ => false, + } + } + + /// Returns a [`VersionSpec`] if this instance is a version spec. + pub fn as_version_spec(&self) -> Option<&VersionSpec> { + match self { + Self::Version(v) => Some(v), + Self::DetailedVersion(v) => v.version.as_ref(), + _ => None, + } + } + + /// Returns a [`DetailedSpec`] if this instance is a detailed version + /// spec. + pub fn as_detailed(&self) -> Option<&DetailedSpec> { + match self { + Self::DetailedVersion(v) => Some(v), + _ => None, + } + } + + /// Returns a [`UrlSpec`] if this instance is a detailed version spec. + pub fn as_url(&self) -> Option<&UrlSpec> { + match self { + Self::Url(v) => Some(v), + _ => None, + } + } + + /// Returns a [`GitSpec`] if this instance is a git spec. + pub fn as_git(&self) -> Option<&GitSpec> { + match self { + Self::Git(v) => Some(v), + _ => None, + } + } + + /// Returns a [`PathSpec`] if this instance is a path spec. + pub fn as_path(&self) -> Option<&PathSpec> { + match self { + Self::Path(v) => Some(v), + _ => None, + } + } + + /// Converts this instance into a [`VersionSpec`] if possible. + pub fn into_version(self) -> Option { + match self { + Self::Version(v) => Some(v), + Self::DetailedVersion(DetailedSpec { + version: Some(v), .. + }) => Some(v), + _ => None, + } + } + + /// Converts this instance into a [`DetailedSpec`] if possible. + pub fn into_detailed(self) -> Option { + match self { + Self::DetailedVersion(v) => Some(v), + Self::Version(v) => Some(DetailedSpec { + version: Some(v), + ..DetailedSpec::default() + }), + _ => None, + } + } + + /// Converts this instance into a [`UrlSpec`] if possible. + pub fn into_url(self) -> Option { + match self { + Self::Url(v) => Some(v), + _ => None, + } + } + + /// Converts this instance into a [`GitSpec`] if possible. + pub fn into_git(self) -> Option { + match self { + Self::Git(v) => Some(v), + _ => None, + } + } + + /// Converts this instance into a [`PathSpec`] if possible. + pub fn into_path(self) -> Option { + match self { + Self::Path(v) => Some(v), + _ => None, + } + } + + /// Convert this instance into a binary spec. + /// + /// A binary spec always refers to a binary package. + pub fn try_into_nameless_match_spec( + self, + channel_config: &ChannelConfig, + ) -> Result, SpecConversionError> { + let spec = match self { + PixiSpec::Version(version) => Some(NamelessMatchSpec { + version: Some(version), + ..NamelessMatchSpec::default() + }), + PixiSpec::DetailedVersion(spec) => Some(spec.into_nameless_match_spec(channel_config)), + PixiSpec::Url(url) => url.try_into_nameless_match_spec().ok(), + PixiSpec::Git(_) => None, + PixiSpec::Path(path) => path.try_into_nameless_match_spec(&channel_config.root_dir)?, + }; + + Ok(spec) + } + + /// Converts this instance into a source spec if this instance represents a + /// source package. + #[allow(clippy::result_large_err)] + pub fn try_into_source_spec(self) -> Result { + match self { + PixiSpec::Url(url) => url + .try_into_source_url() + .map(SourceSpec::from) + .map_err(PixiSpec::from), + PixiSpec::Git(git) => Ok(SourceSpec::Git(git)), + PixiSpec::Path(path) => path + .try_into_source_path() + .map(SourceSpec::from) + .map_err(PixiSpec::from), + _ => Err(self), + } + } + + /// Returns true if this spec represents a binary package. + pub fn is_binary(&self) -> bool { + match self { + Self::Version(_) => true, + Self::DetailedVersion(_) => true, + Self::Url(url) => url.is_binary(), + Self::Git(_) => false, + Self::Path(path) => path.is_binary(), + } + } + + /// Returns true if this spec represents a source package. + pub fn is_source(&self) -> bool { + !self.is_binary() + } + + #[cfg(feature = "toml_edit")] + /// Converts this instance into a [`toml_edit::Value`]. + pub fn to_toml_value(&self) -> toml_edit::Value { + ::serde::Serialize::serialize(self, toml_edit::ser::ValueSerializer::new()) + .expect("conversion to toml cannot fail") + } +} + +/// A specification for a source package. +/// +/// This type only represents source packages. Use [`PixiSpec`] to represent +/// both binary and source packages. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum SourceSpec { + /// The spec is represented as an archive that can be downloaded from the + /// specified URL. + Url(UrlSourceSpec), + + /// The spec is represented as a git repository. + Git(GitSpec), + + /// The spec is represented as a local directory or local file archive. + Path(PathSourceSpec), +} + +impl From for PixiSpec { + fn from(value: SourceSpec) -> Self { + match value { + SourceSpec::Url(url) => Self::Url(url.into()), + SourceSpec::Git(git) => Self::Git(git), + SourceSpec::Path(path) => Self::Path(path.into()), + } + } +} + +impl From for PixiSpec { + fn from(value: DetailedSpec) -> Self { + Self::DetailedVersion(value) + } +} + +impl From for PixiSpec { + fn from(value: UrlSpec) -> Self { + Self::Url(value) + } +} + +impl From for SourceSpec { + fn from(value: UrlSourceSpec) -> Self { + SourceSpec::Url(value) + } +} + +impl From for PixiSpec { + fn from(value: GitSpec) -> Self { + Self::Git(value) + } +} + +impl From for PixiSpec { + fn from(value: PathSpec) -> Self { + Self::Path(value) + } +} + +impl From for SourceSpec { + fn from(value: PathSourceSpec) -> Self { + Self::Path(value) + } +} + +#[cfg(feature = "toml_edit")] +impl From for toml_edit::Value { + fn from(value: PixiSpec) -> Self { + ::serde::Serialize::serialize(&value, toml_edit::ser::ValueSerializer::new()) + .expect("conversion to toml cannot fail") + } +} + +#[cfg(test)] +mod test { + use rattler_conda_types::ChannelConfig; + use serde::Serialize; + use serde_json::{json, Value}; + use url::Url; + + use crate::PixiSpec; + + #[test] + fn test_is_binary() { + let binary_packages = [ + json! { "1.2.3" }, + json!({ "version": "1.2.3" }), + json! { "*" }, + json!({ "version": "1.2.3", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "url": "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" }), + ]; + + for binary_package in binary_packages { + let spec: PixiSpec = serde_json::from_value(binary_package.clone()).unwrap(); + assert!( + spec.is_binary(), + "{binary_package} should be a binary package" + ); + assert!( + !spec.is_source(), + "{binary_package} should not be a source package" + ); + } + + let source_packages = [ + json!({ "path": "foobar" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "branch": "main" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "tag": "v1" }), + json!({ "url": "https://github.com/conda-forge/21cmfast-feedstock.zip" }), + ]; + + for source_package in source_packages { + let spec: PixiSpec = serde_json::from_value(source_package.clone()).unwrap(); + assert!(spec.is_source(), "{spec:?} should be a source package"); + assert!(!spec.is_binary(), "{spec:?} should not be a binary package"); + } + } + + #[test] + fn test_into_nameless_match_spec() { + let examples = [ + // Should be identified as binary packages. + json!({ "version": "1.2.3" }), + json!({ "version": "1.2.3", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "subdir": "linux-64" }), + json!({ "channel": "conda-forge", "subdir": "linux-64" }), + json!({ "channel": "conda-forge", "subdir": "linux-64" }), + json!({ "url": "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" }), + json!({ "url": "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "path": "21cmfast-3.3.1-py38h0db86a8_1.conda" }), + json!({ "path": "packages/foo/.././21cmfast-3.3.1-py38h0db86a8_1.conda" }), + json!({ "url": "file:///21cmfast-3.3.1-py38h0db86a8_1.conda" }), + json!({ "path": "~/foo/../21cmfast-3.3.1-py38h0db86a8_1.conda" }), + // Should not be binary packages. + json!({ "path": "foobar" }), + json!({ "path": "~/.cache" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "branch": "main" }), + json!({ "url": "http://github.com/conda-forge/21cmfast-feedstock/releases/21cmfast-3.3.1-py38h0db86a8_1.zip" }), + ]; + + #[derive(Serialize)] + struct Snapshot { + input: Value, + result: Value, + } + + let channel_config = ChannelConfig::default_with_root_dir(std::env::current_dir().unwrap()); + let mut snapshot = Vec::new(); + for input in examples { + let spec: PixiSpec = serde_json::from_value(input.clone()).unwrap(); + let result = match spec.try_into_nameless_match_spec(&channel_config) { + Ok(spec) => serde_json::to_value(spec).unwrap(), + Err(err) => { + json!({ "error": err.to_string() }) + } + }; + snapshot.push(Snapshot { input, result }); + } + + let path = Url::from_directory_path(channel_config.root_dir).unwrap(); + let home_dir = Url::from_directory_path(dirs::home_dir().unwrap()).unwrap(); + insta::with_settings!({filters => vec![ + (path.as_str(), "file:///"), + (home_dir.as_str(), "file:///"), + ]}, { + insta::assert_yaml_snapshot!(snapshot); + }); + } +} diff --git a/crates/pixi_spec/src/path.rs b/crates/pixi_spec/src/path.rs new file mode 100644 index 000000000..93abf6707 --- /dev/null +++ b/crates/pixi_spec/src/path.rs @@ -0,0 +1,95 @@ +use std::path::Path; + +use rattler_conda_types::{package::ArchiveIdentifier, NamelessMatchSpec}; +use typed_path::{Utf8NativePathBuf, Utf8TypedPathBuf}; + +use crate::SpecConversionError; + +/// A specification of a package from a git repository. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct PathSpec { + /// The path to the package + pub path: Utf8TypedPathBuf, +} + +impl PathSpec { + /// Converts this instance into a [`NamelessMatchSpec`] if the path points + /// to binary archive. + pub fn try_into_nameless_match_spec( + self, + root_dir: &Path, + ) -> Result, SpecConversionError> { + if !self.is_binary() { + // Not a binary package + return Ok(None); + } + + // Convert the path to an absolute path based on the root_dir + let path = if self.path.is_absolute() { + self.path + } else if let Ok(user_path) = self.path.strip_prefix("~/") { + let home_dir = dirs::home_dir() + .ok_or_else(|| SpecConversionError::InvalidPath(self.path.to_string()))?; + let Some(home_dir_str) = home_dir.to_str() else { + return Err(SpecConversionError::NotUtf8RootDir(home_dir)); + }; + Utf8TypedPathBuf::from(home_dir_str) + .join(user_path) + .normalize() + } else { + let Some(root_dir_str) = root_dir.to_str() else { + return Err(SpecConversionError::NotUtf8RootDir(root_dir.to_path_buf())); + }; + let native_root_dir = Utf8NativePathBuf::from(root_dir_str); + if !native_root_dir.is_absolute() { + return Err(SpecConversionError::NonAbsoluteRootDir( + root_dir.to_path_buf(), + )); + } + + native_root_dir.to_typed_path().join(self.path).normalize() + }; + + // Convert the absolute url to a file:// url + let local_file_url = + file_url::file_path_to_url(path.to_path()).expect("failed to convert path to file url"); + + Ok(Some(NamelessMatchSpec { + url: Some(local_file_url), + ..NamelessMatchSpec::default() + })) + } + + /// Converts this instance into a [`PathSourceSpec`] if the path points to a + /// source package. Otherwise, returns this instance unmodified. + #[allow(clippy::result_large_err)] + pub fn try_into_source_path(self) -> Result { + if self.is_binary() { + Err(self) + } else { + Ok(PathSourceSpec { path: self.path }) + } + } + + /// Returns true if this path points to a binary archive. + pub fn is_binary(&self) -> bool { + self.path + .file_name() + .and_then(ArchiveIdentifier::try_from_path) + .is_some() + } +} + +/// Path to a source package. Different from [`PathSpec`] in that this type only +/// refers to source packages. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct PathSourceSpec { + /// The path to the package. Either a directory or an archive. + pub path: Utf8TypedPathBuf, +} + +impl From for PathSpec { + fn from(value: PathSourceSpec) -> Self { + Self { path: value.path } + } +} diff --git a/crates/pixi_spec/src/serde.rs b/crates/pixi_spec/src/serde.rs new file mode 100644 index 000000000..ac077b50d --- /dev/null +++ b/crates/pixi_spec/src/serde.rs @@ -0,0 +1,369 @@ +use rattler_conda_types::{ + BuildNumberSpec, NamedChannelOrUrl, ParseStrictness::Strict, StringMatcher, VersionSpec, +}; +use rattler_digest::{Md5Hash, Sha256Hash}; +use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; +use serde_with::serde_as; +use std::fmt::Display; +use url::Url; + +use crate::{DetailedSpec, GitReference, GitSpec, PathSpec, PixiSpec, UrlSpec}; + +#[serde_as] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +struct RawSpec { + /// The version spec of the package (e.g. `1.2.3`, `>=1.2.3`, `1.2.*`) + #[serde_as(as = "Option")] + pub version: Option, + + /// The URL of the package + pub url: Option, + + /// The git url of the package + pub git: Option, + + /// The path to the package + pub path: Option, + + /// The git revision of the package + pub branch: Option, + + /// The git revision of the package + pub rev: Option, + + /// The git revision of the package + pub tag: Option, + + /// The build string of the package (e.g. `py37_0`, `py37h6de7cb9_0`, `py*`) + #[serde_as(as = "Option")] + pub build: Option, + + /// The build number of the package + #[serde_as(as = "Option")] + pub build_number: Option, + + /// Match the specific filename of the package + pub file_name: Option, + + /// The channel of the package + pub channel: Option, + + /// The subdir of the channel + pub subdir: Option, + + /// The md5 hash of the package + #[serde_as(as = "Option>")] + pub md5: Option, + + /// The sha256 hash of the package + #[serde_as(as = "Option>")] + pub sha256: Option, +} + +/// Returns a more helpful message when a version spec is used incorrectly. +fn version_spec_error>(input: T) -> Option { + let input = input.into(); + if input.starts_with('/') + || input.starts_with('.') + || input.starts_with('\\') + || input.starts_with("~/") + { + return Some(format!("it seems you're trying to add a path dependency, please specify as a table with a `path` key: '{{ path = \"{input}\" }}'")); + } + + if input.contains("git") { + return Some(format!("it seems you're trying to add a git dependency, please specify as a table with a `git` key: '{{ git = \"{input}\" }}'")); + } + + if input.contains("://") { + return Some(format!("it seems you're trying to add a url dependency, please specify as a table with a `url` key: '{{ url = \"{input}\" }}'")); + } + + if input.contains("subdir") { + return Some("it seems you're trying to add a detailed dependency, please specify as a table with a `subdir` key: '{ version = \"\", subdir = \"\" }'".to_string()); + } + + if input.contains("channel") || input.contains("::") { + return Some("it seems you're trying to add a detailed dependency, please specify as a table with a `channel` key: '{ version = \"\", channel = \"\" }'".to_string()); + } + + if input.contains("md5") { + return Some("it seems you're trying to add a detailed dependency, please specify as a table with a `md5` key: '{ version = \"\", md5 = \"\" }'".to_string()); + } + + if input.contains("sha256") { + return Some("it seems you're trying to add a detailed dependency, please specify as a table with a `sha256` key: '{ version = \"\", sha256 = \"\" }'".to_string()); + } + None +} + +impl<'de> Deserialize<'de> for PixiSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + serde_untagged::UntaggedEnumVisitor::new() + .expecting( + "a version string like \">=0.9.8\" or a detailed dependency like { version = \">=0.9.8\" }", + ) + .string(|str| { + + + VersionSpec::from_str(str, Strict) + .map_err(|err|{ + if let Some(msg) = version_spec_error(str) { + serde_untagged::de::Error::custom(msg) + } else { + serde_untagged::de::Error::custom(err) + } + }) + .map(PixiSpec::Version) + }) + .map(|map| { + let raw_spec: RawSpec = map.deserialize()?; + + if raw_spec.git.is_none() + && (raw_spec.branch.is_some() + || raw_spec.rev.is_some() + || raw_spec.tag.is_some()) + { + return Err(serde_untagged::de::Error::custom( + "`branch`, `rev`, and `tag` are only valid when `git` is specified", + )); + } + + let is_git = raw_spec.git.is_some(); + let is_path = raw_spec.path.is_some(); + let is_url = raw_spec.url.is_some(); + + + let git_key = is_git.then_some("`git`"); + let path_key = is_path.then_some("`path`"); + let url_key = is_url.then_some("`url`"); + let non_detailed_keys = [git_key, path_key, url_key] + .into_iter() + .flatten() + .collect::>() + .join(", "); + + if !non_detailed_keys.is_empty() && raw_spec.version.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`version` cannot be used with {non_detailed_keys}"), + )); + } + + if !non_detailed_keys.is_empty() && raw_spec.build.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`build` cannot be used with {non_detailed_keys}"), + )); + } + + if !non_detailed_keys.is_empty() && raw_spec.build_number.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`build` cannot be used with {non_detailed_keys}"), + )); + } + + if !non_detailed_keys.is_empty() && raw_spec.file_name.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`build` cannot be used with {non_detailed_keys}"), + )); + } + + if !non_detailed_keys.is_empty() && raw_spec.channel.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`build` cannot be used with {non_detailed_keys}"), + )); + } + + if !non_detailed_keys.is_empty() && raw_spec.subdir.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`build` cannot be used with {non_detailed_keys}"), + )); + } + + let non_url_keys = [git_key, path_key] + .into_iter() + .flatten() + .collect::>() + .join(", "); + if !non_url_keys.is_empty() && raw_spec.sha256.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`sha256` cannot be used with {non_url_keys}"), + )); + } + if !non_url_keys.is_empty() && raw_spec.md5.is_some() { + return Err(serde_untagged::de::Error::custom( + format!("`md5` cannot be used with {non_url_keys}"), + )); + } + + let spec = match (raw_spec.url, raw_spec.path, raw_spec.git) { + (Some(url), None, None) => PixiSpec::Url(UrlSpec { + url, + md5: raw_spec.md5, + sha256: raw_spec.sha256, + }), + (None, Some(path), None) => PixiSpec::Path(PathSpec { path: path.into() }), + (None, None, Some(git)) => { + let rev = match (raw_spec.branch, raw_spec.rev, raw_spec.tag) { + (Some(branch), None, None) => Some(GitReference::Branch(branch)), + (None, Some(rev), None) => Some(GitReference::Rev(rev)), + (None, None, Some(tag)) => Some(GitReference::Tag(tag)), + (None, None, None) => None, + _ => { + return Err(serde_untagged::de::Error::custom( + "only one of `branch`, `rev`, or `tag` can be specified", + )); + } + }; + PixiSpec::Git(GitSpec { git, rev }) + }, + (None, None, None) => { + let is_detailed = + raw_spec.version.is_some() || + raw_spec.build.is_some() || + raw_spec.build_number.is_some() || + raw_spec.file_name.is_some() || + raw_spec.channel.is_some() || + raw_spec.subdir.is_some() || + raw_spec.md5.is_some() || + raw_spec.sha256.is_some(); + if !is_detailed { + return Err(serde_untagged::de::Error::custom( + "one of `version`, `build`, `build-number`, `file-name`, `channel`, `subdir`, `md5`, `sha256`, `git`, `url`, or `path` must be specified", + )) + } + + PixiSpec::DetailedVersion(DetailedSpec { + version: raw_spec.version, + build: raw_spec.build, + build_number: raw_spec.build_number, + file_name: raw_spec.file_name, + channel: raw_spec.channel, + subdir: raw_spec.subdir, + md5: raw_spec.md5, + sha256: raw_spec.sha256, + }) + } + (_, _, _) => { + return Err(serde_untagged::de::Error::custom( + "only one of `url`, `path`, or `git` can be specified", + )) + } + }; + + Ok(spec) + }) + .deserialize(deserializer) + } +} + +impl<'de> Deserialize<'de> for PathSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + path: String, + } + + Raw::deserialize(deserializer).map(|raw| PathSpec { + path: raw.path.into(), + }) + } +} + +impl Serialize for PathSpec { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct Raw { + path: String, + } + + Raw { + path: self.path.to_string(), + } + .serialize(serializer) + } +} + +#[cfg(test)] +mod test { + use serde::Serialize; + use serde_json::{json, Value}; + + use super::*; + + #[test] + fn test_round_trip() { + let examples = [ + json! { "1.2.3" }, + json!({ "version": "1.2.3" }), + json!({ "version": "1.2.3", "build-number": ">=3" }), + json! { "*" }, + json!({ "path": "foobar" }), + json!({ "path": "~/.cache" }), + json!({ "subdir": "linux-64" }), + json!({ "channel": "conda-forge", "subdir": "linux-64" }), + json!({ "channel": "conda-forge", "subdir": "linux-64" }), + json!({ "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "version": "1.2.3", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "url": "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" }), + json!({ "url": "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "branch": "main" }), + // Errors: + json!({ "ver": "1.2.3" }), + json!({ "path": "foobar", "version": "1.2.3" }), + json!({ "version": "//" }), + json!({ "path": "foobar", "version": "//" }), + json!({ "path": "foobar", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "branch": "main", "tag": "v1" }), + json!({ "git": "https://github.com/conda-forge/21cmfast-feedstock", "sha256": "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3" }), + json! { "/path/style"}, + json! { "./path/style"}, + json! { "\\path\\style"}, + json! { "~/path/style"}, + json! { "https://example.com"}, + json! { "https://github.com/conda-forge/21cmfast-feedstock"}, + json! { "1.2.3[subdir=linux-64]"}, + json! { "1.2.3[channel=conda-forge]"}, + json! { "conda-forge::1.2.3"}, + json! { "1.2.3[md5=315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3]"}, + json! { "1.2.3[sha256=315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3]"}, + ]; + + #[derive(Serialize)] + struct Snapshot { + input: Value, + result: Value, + } + + let mut snapshot = Vec::new(); + for input in examples { + let spec: Result = serde_json::from_value(input.clone()); + let result = match spec { + Ok(spec) => { + let spec = PixiSpec::from(spec); + serde_json::to_value(&spec).unwrap() + } + Err(e) => { + json!({ + "error": format!("ERROR: {e}") + }) + } + }; + + snapshot.push(Snapshot { input, result }); + } + + insta::assert_yaml_snapshot!(snapshot); + } +} diff --git a/crates/pixi_spec/src/snapshots/pixi_spec__serde__test__round_trip.snap b/crates/pixi_spec/src/snapshots/pixi_spec__serde__test__round_trip.snap new file mode 100644 index 000000000..5acdc8ced --- /dev/null +++ b/crates/pixi_spec/src/snapshots/pixi_spec__serde__test__round_trip.snap @@ -0,0 +1,139 @@ +--- +source: crates/pixi_spec/src/serde.rs +expression: snapshot +--- +- input: 1.2.3 + result: "==1.2.3" +- input: + version: 1.2.3 + result: + version: "==1.2.3" +- input: + version: 1.2.3 + build-number: ">=3" + result: + version: "==1.2.3" + build-number: ">=3" +- input: "*" + result: "*" +- input: + path: foobar + result: + path: foobar +- input: + path: ~/.cache + result: + path: ~/.cache +- input: + subdir: linux-64 + result: + subdir: linux-64 +- input: + channel: conda-forge + subdir: linux-64 + result: + channel: conda-forge + subdir: linux-64 +- input: + channel: conda-forge + subdir: linux-64 + result: + channel: conda-forge + subdir: linux-64 +- input: + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 +- input: + version: 1.2.3 + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + version: "==1.2.3" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 +- input: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" + result: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + result: + git: "https://github.com/conda-forge/21cmfast-feedstock" +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + branch: main + result: + git: "https://github.com/conda-forge/21cmfast-feedstock" + branch: main +- input: + ver: 1.2.3 + result: + error: "ERROR: unknown field `ver`, expected one of `version`, `url`, `git`, `path`, `branch`, `rev`, `tag`, `build`, `build-number`, `file-name`, `channel`, `subdir`, `md5`, `sha256`" +- input: + path: foobar + version: 1.2.3 + result: + error: "ERROR: `version` cannot be used with `path`" +- input: + version: // + result: + error: "ERROR: invalid version tree: 0: at line 1, in Tag:\n//\n^\n\n1: at line 1, in Alt:\n//\n^\n\n2: at line 1, in version:\n//\n^\n\n" +- input: + path: foobar + version: // + result: + error: "ERROR: invalid version tree: 0: at line 1, in Tag:\n//\n^\n\n1: at line 1, in Alt:\n//\n^\n\n2: at line 1, in version:\n//\n^\n\n" +- input: + path: foobar + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + error: "ERROR: `sha256` cannot be used with `path`" +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + branch: main + tag: v1 + result: + error: "ERROR: only one of `branch`, `rev`, or `tag` can be specified" +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + error: "ERROR: `sha256` cannot be used with `git`" +- input: /path/style + result: + error: "ERROR: it seems you're trying to add a path dependency, please specify as a table with a `path` key: '{ path = \"/path/style\" }'" +- input: "./path/style" + result: + error: "ERROR: it seems you're trying to add a path dependency, please specify as a table with a `path` key: '{ path = \"./path/style\" }'" +- input: "\\path\\style" + result: + error: "ERROR: it seems you're trying to add a path dependency, please specify as a table with a `path` key: '{ path = \"\\path\\style\" }'" +- input: ~/path/style + result: + error: "ERROR: it seems you're trying to add a path dependency, please specify as a table with a `path` key: '{ path = \"~/path/style\" }'" +- input: "https://example.com" + result: + error: "ERROR: it seems you're trying to add a url dependency, please specify as a table with a `url` key: '{ url = \"https://example.com\" }'" +- input: "https://github.com/conda-forge/21cmfast-feedstock" + result: + error: "ERROR: it seems you're trying to add a git dependency, please specify as a table with a `git` key: '{ git = \"https://github.com/conda-forge/21cmfast-feedstock\" }'" +- input: "1.2.3[subdir=linux-64]" + result: + error: "ERROR: it seems you're trying to add a detailed dependency, please specify as a table with a `subdir` key: '{ version = \"\", subdir = \"\" }'" +- input: "1.2.3[channel=conda-forge]" + result: + error: "ERROR: it seems you're trying to add a detailed dependency, please specify as a table with a `channel` key: '{ version = \"\", channel = \"\" }'" +- input: "conda-forge::1.2.3" + result: + error: "ERROR: it seems you're trying to add a detailed dependency, please specify as a table with a `channel` key: '{ version = \"\", channel = \"\" }'" +- input: "1.2.3[md5=315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3]" + result: + error: "ERROR: it seems you're trying to add a detailed dependency, please specify as a table with a `md5` key: '{ version = \"\", md5 = \"\" }'" +- input: "1.2.3[sha256=315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3]" + result: + error: "ERROR: it seems you're trying to add a detailed dependency, please specify as a table with a `sha256` key: '{ version = \"\", sha256 = \"\" }'" diff --git a/crates/pixi_spec/src/snapshots/pixi_spec__test__into_nameless_match_spec.snap b/crates/pixi_spec/src/snapshots/pixi_spec__test__into_nameless_match_spec.snap new file mode 100644 index 000000000..d688c740d --- /dev/null +++ b/crates/pixi_spec/src/snapshots/pixi_spec__test__into_nameless_match_spec.snap @@ -0,0 +1,80 @@ +--- +source: crates/pixi_spec/src/lib.rs +expression: snapshot +--- +- input: + version: 1.2.3 + result: + version: "==1.2.3" +- input: + version: 1.2.3 + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + version: "==1.2.3" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 +- input: + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 +- input: + subdir: linux-64 + result: + subdir: linux-64 +- input: + channel: conda-forge + subdir: linux-64 + result: + channel: + base_url: "https://conda.anaconda.org/conda-forge/" + name: conda-forge + subdir: linux-64 +- input: + channel: conda-forge + subdir: linux-64 + result: + channel: + base_url: "https://conda.anaconda.org/conda-forge/" + name: conda-forge + subdir: linux-64 +- input: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" + result: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + result: + sha256: 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 + url: "https://conda.anaconda.org/conda-forge/linux-64/21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + path: 21cmfast-3.3.1-py38h0db86a8_1.conda + result: + url: "file:///21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + path: packages/foo/.././21cmfast-3.3.1-py38h0db86a8_1.conda + result: + url: "file:///packages/21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + url: "file:///21cmfast-3.3.1-py38h0db86a8_1.conda" + result: + url: "file:///21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + path: ~/foo/../21cmfast-3.3.1-py38h0db86a8_1.conda + result: + url: "file:///21cmfast-3.3.1-py38h0db86a8_1.conda" +- input: + path: foobar + result: ~ +- input: + path: ~/.cache + result: ~ +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + result: ~ +- input: + git: "https://github.com/conda-forge/21cmfast-feedstock" + branch: main + result: ~ +- input: + url: "http://github.com/conda-forge/21cmfast-feedstock/releases/21cmfast-3.3.1-py38h0db86a8_1.zip" + result: ~ diff --git a/crates/pixi_spec/src/url.rs b/crates/pixi_spec/src/url.rs new file mode 100644 index 000000000..cd4162ab8 --- /dev/null +++ b/crates/pixi_spec/src/url.rs @@ -0,0 +1,84 @@ +use rattler_conda_types::{package::ArchiveIdentifier, NamelessMatchSpec}; +use rattler_digest::{Md5Hash, Sha256Hash}; +use serde_with::serde_as; +use url::Url; + +/// A specification of a package from a URL. This is used to represent both +/// source and binary packages. +#[serde_as] +#[derive(Debug, Clone, Hash, Eq, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +pub struct UrlSpec { + /// The URL of the package + pub url: Url, + + /// The md5 hash of the package + #[serde(skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option>")] + pub md5: Option, + + /// The sha256 hash of the package + #[serde(skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option>")] + pub sha256: Option, +} + +impl UrlSpec { + /// Converts this instance into a [`NamelessMatchSpec`] if the URL points to + /// a binary package. + #[allow(clippy::result_large_err)] + pub fn try_into_nameless_match_spec(self) -> Result { + if self.is_binary() { + Ok(NamelessMatchSpec { + url: Some(self.url), + md5: self.md5, + sha256: self.sha256, + ..NamelessMatchSpec::default() + }) + } else { + Err(self) + } + } + + /// Converts this instance into a [`UrlSourceSpec`] if the URL points to a + /// source package. Otherwise, returns this instance unmodified. + #[allow(clippy::result_large_err)] + pub fn try_into_source_url(self) -> Result { + if self.is_binary() { + Err(self) + } else { + Ok(UrlSourceSpec { + url: self.url, + md5: self.md5, + sha256: self.sha256, + }) + } + } + + /// Returns true if the URL points to a binary package. + pub fn is_binary(&self) -> bool { + ArchiveIdentifier::try_from_url(&self.url).is_some() + } +} + +/// A specification of a source archive from a URL. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct UrlSourceSpec { + /// The URL of the package + pub url: Url, + + /// The md5 hash of the archive + pub md5: Option, + + /// The sha256 hash of the archive + pub sha256: Option, +} + +impl From for UrlSpec { + fn from(value: UrlSourceSpec) -> Self { + Self { + url: value.url, + md5: value.md5, + sha256: value.sha256, + } + } +} diff --git a/schema/examples/valid/full.toml b/schema/examples/valid/full.toml index 64948eb26..e813cf0bc 100644 --- a/schema/examples/valid/full.toml +++ b/schema/examples/valid/full.toml @@ -17,11 +17,29 @@ repository = "https://github.com/author/project" version = "0.1.0" [dependencies] -package1 = { version = ">=1.2.3", build = "py34_0" } -pytorch-cpu = { version = "~=1.1", channel = "pytorch" } +detailed = { version = ">=1.2.3" } +detailed-full = { version = ">=1.2.3", build = "py34_0", channel = "pytorch", subdir = "linux-64", md5 = "6f5902ac237024bdd0c176cb93063dc4", sha256 = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" } +detailed2 = { version = ">=1.2.3", build = "py34_0" } +detailed3 = { version = ">=1.2.3", build-number = ">=1" } +detailed4 = { version = ">=1.2.3", file-name = "package-1.2.3-py34_0.tar.bz2" } +detailed5 = { version = ">=1.2.3", channel = "pytorch" } +detailed6 = { version = ">=1.2.3", subdir = "linux-64" } +detailed7 = { version = ">=1.2.3", md5 = "6f5902ac237024bdd0c176cb93063dc4" } +detailed8 = { version = ">=1.2.3", sha256 = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" } test = "*" test1 = "*" +md5 = { url = "https://example.com/package-1.2.3-asdf2.conda", md5 = "6f5902ac237024bdd0c176cb93063dc4" } +package_path = { path = "path/to/package-1.2.3-abc1.conda" } +sha256 = { url = "https://example.com/package-1.2.3-asdf2.conda", sha256 = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" } + +# In the future we'll add source dependencies +#git = { git = "https://github.com/prefix-dev/pixi", branch = "main" } +#git2 = { git = "https://github.com/prefix-dev/rattler-build", tag = "v0.1.0" } +#git3 = { git = "https://github.com/prefix-dev/rattler", rev = "v0.1.0" } +#path = { path = "~/path/to/package" } +#path2 = { path = "path/to/package" } + [pypi-dependencies] requests = { version = ">= 2.8.1, ==2.8.*", extras = [ "security", @@ -30,6 +48,7 @@ requests = { version = ">= 2.8.1, ==2.8.*", extras = [ testpypi = "*" testpypi1 = "*" + [host-dependencies] package1 = { version = ">=1.2.3", build = "py34_0" } pytorch-cpu = { version = "~=1.1", channel = "pytorch" } diff --git a/schema/model.py b/schema/model.py index f30d0d8bc..4675f91b5 100644 --- a/schema/model.py +++ b/schema/model.py @@ -26,6 +26,8 @@ NonEmptyStr = Annotated[str, StringConstraints(min_length=1)] +Md5Sum = Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{32}$")] +Sha256Sum = Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{64}$")] PathNoBackslash = Annotated[str, StringConstraints(pattern=r"^[^\\]+$")] Glob = NonEmptyStr UnsignedInt = Annotated[int, Field(strict=True, ge=0)] @@ -147,21 +149,37 @@ class MatchspecTable(StrictBaseModel): description="The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", ) build: NonEmptyStr | None = Field(None, description="The build string of the package") + build_number: NonEmptyStr | None = Field( + None, + alias="build-number", + description="The build number of the package, can be a spec like `>=1` or `<=10` or `1`", + ) + file_name: NonEmptyStr | None = Field( + None, alias="file-name", description="The file name of the package" + ) channel: NonEmptyStr | None = Field( None, description="The channel the packages needs to be fetched from", examples=["conda-forge", "pytorch", "https://repo.prefix.dev/conda-forge"], ) + subdir: NonEmptyStr | None = Field( + None, description="The subdir of the package, also known as platform" + ) + path: NonEmptyStr | None = Field(None, description="The path to the package") -MatchSpec = NonEmptyStr | MatchspecTable -CondaPackageName = NonEmptyStr + url: NonEmptyStr | None = Field(None, description="The URL to the package") + md5: Md5Sum | None = Field(None, description="The md5 hash of the package") + sha256: Sha256Sum | None = Field(None, description="The sha256 hash of the package") + + git: NonEmptyStr | None = Field(None, description="The git URL to the repo") + rev: NonEmptyStr | None = Field(None, description="A git SHA revision to use") + tag: NonEmptyStr | None = Field(None, description="A git tag to use") + branch: NonEmptyStr | None = Field(None, description="A git branch to use") -# { version = "sdfds" extras = ["sdf"] } -# { git = "sfds", rev = "fssd" } -# { path = "asfdsf" } -# { url = "asdfs" } +MatchSpec = NonEmptyStr | MatchspecTable +CondaPackageName = NonEmptyStr class _PyPIRequirement(StrictBaseModel): diff --git a/schema/schema.json b/schema/schema.json index 45488b633..3d713d7ce 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -541,12 +541,24 @@ "type": "object", "additionalProperties": false, "properties": { + "branch": { + "title": "Branch", + "description": "A git branch to use", + "type": "string", + "minLength": 1 + }, "build": { "title": "Build", "description": "The build string of the package", "type": "string", "minLength": 1 }, + "build-number": { + "title": "Build-Number", + "description": "The build number of the package, can be a spec like `>=1` or `<=10` or `1`", + "type": "string", + "minLength": 1 + }, "channel": { "title": "Channel", "description": "The channel the packages needs to be fetched from", @@ -558,6 +570,60 @@ "https://repo.prefix.dev/conda-forge" ] }, + "file-name": { + "title": "File-Name", + "description": "The file name of the package", + "type": "string", + "minLength": 1 + }, + "git": { + "title": "Git", + "description": "The git URL to the repo", + "type": "string", + "minLength": 1 + }, + "md5": { + "title": "Md5", + "description": "The md5 hash of the package", + "type": "string", + "pattern": "^[a-fA-F0-9]{32}$" + }, + "path": { + "title": "Path", + "description": "The path to the package", + "type": "string", + "minLength": 1 + }, + "rev": { + "title": "Rev", + "description": "A git SHA revision to use", + "type": "string", + "minLength": 1 + }, + "sha256": { + "title": "Sha256", + "description": "The sha256 hash of the package", + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "subdir": { + "title": "Subdir", + "description": "The subdir of the package, also known as platform", + "type": "string", + "minLength": 1 + }, + "tag": { + "title": "Tag", + "description": "A git tag to use", + "type": "string", + "minLength": 1 + }, + "url": { + "title": "Url", + "description": "The URL to the package", + "type": "string", + "minLength": 1 + }, "version": { "title": "Version", "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", diff --git a/src/cli/add.rs b/src/cli/add.rs index 832b16061..43d316fb2 100644 --- a/src/cli/add.rs +++ b/src/cli/add.rs @@ -8,19 +8,21 @@ use indexmap::IndexMap; use itertools::Itertools; use pep440_rs::VersionSpecifiers; use pep508_rs::{Requirement, VersionOrUrl::VersionSpecifier}; -use pixi_manifest::{pypi::PyPiPackageName, DependencyOverwriteBehavior, FeatureName, SpecType}; +use pixi_manifest::{ + pypi::PyPiPackageName, DependencyOverwriteBehavior, FeatureName, FeaturesExt, HasFeaturesIter, + SpecType, +}; use rattler_conda_types::{MatchSpec, PackageName, Platform, Version}; use rattler_lock::{LockFile, Package}; use super::has_specs::HasSpecs; -use crate::cli::cli_config::{DependencyConfig, PrefixUpdateConfig, ProjectConfig}; use crate::{ + cli::cli_config::{DependencyConfig, PrefixUpdateConfig, ProjectConfig}, environment::verify_prefix_location_unchanged, load_lock_file, lock_file::{filter_lock_file, LockFileDerivedData, UpdateContext}, project::{grouped_environment::GroupedEnvironment, DependencyType, Project}, }; -use pixi_manifest::{FeaturesExt, HasFeaturesIter}; /// Adds dependencies to the project /// @@ -94,9 +96,6 @@ pub async fn execute(args: Args) -> miette::Result<()> { // Sanity check of prefix location verify_prefix_location_unchanged(project.default_environment().dir().as_path()).await?; - // Load the current lock-file - let lock_file = load_lock_file(&project).await?; - // Add the platform if it is not already present project .manifest @@ -148,6 +147,20 @@ pub async fn execute(args: Args) -> miette::Result<()> { } } + // If the lock-file should not be updated we only need to save the project. + if prefix_update_config.no_lockfile_update { + project.save()?; + + // Notify the user we succeeded. + dependency_config.display_success("Added", HashMap::default()); + + Project::warn_on_discovered_from_env(project_config.manifest_path.as_deref()); + return Ok(()); + } + + // Load the current lock-file + let lock_file = load_lock_file(&project).await?; + // Determine the environments that are affected by the change. let feature_name = dependency_config.feature_name(); let affected_environments = project diff --git a/src/lock_file/satisfiability.rs b/src/lock_file/satisfiability.rs index bb0a8cd04..97bdb34e1 100644 --- a/src/lock_file/satisfiability.rs +++ b/src/lock_file/satisfiability.rs @@ -7,22 +7,20 @@ use std::{ str::FromStr, }; -use super::{PypiRecord, PypiRecordsByName, RepoDataRecordsByName}; -use crate::project::HasProjectRef; -use crate::project::{grouped_environment::GroupedEnvironment, Environment}; use itertools::Itertools; use miette::Diagnostic; use pep440_rs::VersionSpecifiers; use pep508_rs::{VerbatimUrl, VersionOrUrl}; use pixi_manifest::FeaturesExt; +use pixi_spec::{PixiSpec, SpecConversionError}; use pixi_uv_conversions::{as_uv_req, AsPep508Error}; use pypi_modifiers::pypi_marker_env::determine_marker_environment; use pypi_types::{ ParsedGitUrl, ParsedPathUrl, ParsedUrl, ParsedUrlError, RequirementSource, VerbatimParsedUrl, }; use rattler_conda_types::{ - GenericVirtualPackage, MatchSpec, Matches, NamedChannelOrUrl, ParseMatchSpecError, - ParseStrictness::Lenient, Platform, RepoDataRecord, + GenericVirtualPackage, MatchSpec, Matches, NamedChannelOrUrl, ParseChannelError, + ParseMatchSpecError, ParseStrictness::Lenient, Platform, RepoDataRecord, }; use rattler_lock::{ ConversionError, Package, PypiIndexes, PypiPackageData, PypiSourceTreeHashable, UrlOrPath, @@ -32,6 +30,9 @@ use url::Url; use uv_git::GitReference; use uv_normalize::{ExtraName, PackageName}; +use super::{PypiRecord, PypiRecordsByName, RepoDataRecordsByName}; +use crate::project::{grouped_environment::GroupedEnvironment, Environment, HasProjectRef}; + #[derive(Debug, Error, Diagnostic)] pub enum EnvironmentUnsat { #[error("the channels in the lock-file do not match the environments channels")] @@ -351,6 +352,11 @@ pub fn verify_platform_satisfiability( } enum Dependency { + Input( + rattler_conda_types::PackageName, + PixiSpec, + Cow<'static, str>, + ), Conda(MatchSpec, Cow<'static, str>), PyPi(pypi_types::Requirement, Cow<'static, str>), } @@ -487,11 +493,13 @@ pub fn verify_package_platform_satisfiability( platform: Platform, project_root: &Path, ) -> Result<(), PlatformUnsat> { + let channel_config = environment.project().channel_config(); + // Determine the dependencies requested by the environment let conda_specs = environment .dependencies(None, Some(platform)) - .into_match_specs() - .map(|spec| Dependency::Conda(spec, "".into())) + .into_specs() + .map(|(package_name, spec)| Dependency::Input(package_name, spec, "".into())) .collect_vec(); if conda_specs.is_empty() && !locked_conda_packages.is_empty() { @@ -575,74 +583,41 @@ pub fn verify_package_platform_satisfiability( let mut pypi_queue = pypi_requirements; let mut expected_editable_pypi_packages = HashSet::new(); while let Some(package) = conda_queue.pop().or_else(|| pypi_queue.pop()) { - enum FoundPackage { - Conda(usize), - PyPi(usize, Vec), - } - // Determine the package that matches the requirement of matchspec. let found_package = match package { - Dependency::Conda(spec, source) => { - match &spec.name { - None => { - // No name means we have to find any package that matches the spec. - match locked_conda_packages - .records - .iter() - .position(|record| record.matches(&spec)) - { - None => { - // No records match the spec. - return Err(PlatformUnsat::UnsatisfiableMatchSpec( - spec, - source.into_owned(), - )); + Dependency::Input(name, spec, source) => { + let spec = match spec.try_into_nameless_match_spec(&channel_config) { + Ok(Some(spec)) => MatchSpec::from_nameless(spec, Some(name)), + Ok(None) => unimplemented!("source dependencies are not yet implemented"), + Err(e) => { + let parse_channel_err: ParseMatchSpecError = match e { + SpecConversionError::NonAbsoluteRootDir(p) => { + ParseChannelError::NonAbsoluteRootDir(p).into() } - Some(idx) => FoundPackage::Conda(idx), - } - } - Some(name) => { - match locked_conda_packages - .index_by_name(name) - .map(|idx| (idx, &locked_conda_packages.records[idx])) - { - Some((idx, record)) if record.matches(&spec) => { - FoundPackage::Conda(idx) + SpecConversionError::NotUtf8RootDir(p) => { + ParseChannelError::NotUtf8RootDir(p).into() } - Some(_) => { - // The record does not match the spec, the lock-file is - // inconsistent. - return Err(PlatformUnsat::UnsatisfiableMatchSpec( - spec, - source.into_owned(), - )); + SpecConversionError::InvalidPath(p) => { + ParseChannelError::InvalidPath(p).into() } - None => { - // Check if there is a virtual package by that name - if let Some(vpkg) = virtual_packages.get(name.as_normalized()) { - if vpkg.matches(&spec) { - // The matchspec matches a virtual package. No need to - // propagate the dependencies. - continue; - } else { - // The record does not match the spec, the lock-file is - // inconsistent. - return Err(PlatformUnsat::UnsatisfiableMatchSpec( - spec, - source.into_owned(), - )); - } - } else { - // The record does not match the spec, the lock-file is - // inconsistent. - return Err(PlatformUnsat::UnsatisfiableMatchSpec( - spec, - source.into_owned(), - )); - } - } - } + }; + return Err(PlatformUnsat::FailedToParseMatchSpec( + name.as_source().to_string(), + parse_channel_err, + )); } + }; + match find_matching_package(locked_conda_packages, &virtual_packages, spec, source)? + { + Some(pkg) => pkg, + None => continue, + } + } + Dependency::Conda(spec, source) => { + match find_matching_package(locked_conda_packages, &virtual_packages, spec, source)? + { + Some(pkg) => pkg, + None => continue, } } Dependency::PyPi(requirement, source) => { @@ -853,6 +828,80 @@ pub fn verify_package_platform_satisfiability( Ok(()) } +enum FoundPackage { + Conda(usize), + PyPi(usize, Vec), +} + +fn find_matching_package( + locked_conda_packages: &RepoDataRecordsByName, + virtual_packages: &HashMap, + spec: MatchSpec, + source: Cow, +) -> Result, PlatformUnsat> { + let found_package = match &spec.name { + None => { + // No name means we have to find any package that matches the spec. + match locked_conda_packages + .records + .iter() + .position(|record| record.matches(&spec)) + { + None => { + // No records match the spec. + return Err(PlatformUnsat::UnsatisfiableMatchSpec( + spec, + source.into_owned(), + )); + } + Some(idx) => FoundPackage::Conda(idx), + } + } + Some(name) => { + match locked_conda_packages + .index_by_name(name) + .map(|idx| (idx, &locked_conda_packages.records[idx])) + { + Some((idx, record)) if record.matches(&spec) => FoundPackage::Conda(idx), + Some(_) => { + // The record does not match the spec, the lock-file is + // inconsistent. + return Err(PlatformUnsat::UnsatisfiableMatchSpec( + spec, + source.into_owned(), + )); + } + None => { + // Check if there is a virtual package by that name + if let Some(vpkg) = virtual_packages.get(name.as_normalized()) { + if vpkg.matches(&spec) { + // The matchspec matches a virtual package. No need to + // propagate the dependencies. + return Ok(None); + } else { + // The record does not match the spec, the lock-file is + // inconsistent. + return Err(PlatformUnsat::UnsatisfiableMatchSpec( + spec, + source.into_owned(), + )); + } + } else { + // The record does not match the spec, the lock-file is + // inconsistent. + return Err(PlatformUnsat::UnsatisfiableMatchSpec( + spec, + source.into_owned(), + )); + } + } + } + } + }; + + Ok(Some(found_package)) +} + trait MatchesMatchspec { fn matches(&self, spec: &MatchSpec) -> bool; } diff --git a/src/lock_file/update.rs b/src/lock_file/update.rs index ba8767208..31c488811 100644 --- a/src/lock_file/update.rs +++ b/src/lock_file/update.rs @@ -13,6 +13,7 @@ use std::{ }; use barrier_cell::BarrierCell; +use fancy_display::FancyDisplay; use futures::{future::Either, stream::FuturesUnordered, FutureExt, StreamExt, TryFutureExt}; use indexmap::IndexSet; use indicatif::{HumanBytes, ProgressBar, ProgressState}; @@ -20,9 +21,10 @@ use itertools::Itertools; use miette::{IntoDiagnostic, LabeledSpan, MietteDiagnostic, WrapErr}; use parking_lot::Mutex; use pixi_consts::consts; -use pixi_manifest::{EnvironmentName, HasFeaturesIter}; -use pypi_modifiers::pypi_marker_env::determine_marker_environment; -use pypi_modifiers::pypi_tags::is_python_record; +use pixi_manifest::{EnvironmentName, FeaturesExt, HasFeaturesIter}; +use pixi_progress::global_multi_progress; +use pypi_mapping::{self, Reporter}; +use pypi_modifiers::{pypi_marker_env::determine_marker_environment, pypi_tags::is_python_record}; use rattler::package_cache::PackageCache; use rattler_conda_types::{Arch, MatchSpec, Platform, RepoDataRecord}; use rattler_lock::{LockFile, PypiIndexes, PypiPackageData, PypiPackageEnvironmentData}; @@ -33,7 +35,6 @@ use tracing::Instrument; use url::Url; use uv_normalize::ExtraName; -use crate::project::HasProjectRef; use crate::{ activation::CurrentEnvVarBehavior, environment::{ @@ -48,14 +49,10 @@ use crate::{ prefix::Prefix, project::{ grouped_environment::{GroupedEnvironment, GroupedEnvironmentName}, - Environment, + Environment, HasProjectRef, }, Project, }; -use fancy_display::FancyDisplay; -use pixi_manifest::FeaturesExt; -use pixi_progress::global_multi_progress; -use pypi_mapping::{self, Reporter}; impl Project { /// Ensures that the lock-file is up-to-date with the project information. @@ -1479,7 +1476,12 @@ async fn spawn_solve_conda_environment_task( let match_specs = dependencies .iter_specs() .map(|(name, constraint)| { - MatchSpec::from_nameless(constraint.clone(), Some(name.clone())) + let nameless = constraint + .clone() + .try_into_nameless_match_spec(&channel_config) + .unwrap() + .expect("only binaries are supported at the moment"); + MatchSpec::from_nameless(nameless, Some(name.clone())) }) .collect_vec(); @@ -1492,7 +1494,7 @@ async fn spawn_solve_conda_environment_task( .into_iter() .map(|c| c.into_channel(&channel_config)), [platform, Platform::NoArch], - dependencies.clone().into_match_specs(), + match_specs.clone(), ) .recursive(true) .with_reporter(GatewayProgressReporter::new(pb.clone())) diff --git a/src/project/environment.rs b/src/project/environment.rs index ef21e74cb..ce8c1b208 100644 --- a/src/project/environment.rs +++ b/src/project/environment.rs @@ -8,6 +8,7 @@ use std::{ }; use itertools::Either; +use pixi_consts::consts; use pixi_manifest::{ self as manifest, EnvironmentName, Feature, FeatureName, FeaturesExt, HasFeaturesIter, HasManifestRef, Manifest, SystemRequirements, Task, TaskName, @@ -19,7 +20,6 @@ use super::{ SolveGroup, }; use crate::{project::HasProjectRef, Project}; -use pixi_consts::consts; /// Describes a single environment from a project manifest. This is used to /// describe environments that can be installed and activated. @@ -319,9 +319,9 @@ mod tests { use insta::assert_snapshot; use itertools::Itertools; + use pixi_manifest::CondaDependencies; use super::*; - use pixi_manifest::CondaDependencies; #[test] fn test_default_channels() { @@ -433,7 +433,7 @@ mod tests { fn format_dependencies(dependencies: CondaDependencies) -> String { dependencies .into_specs() - .map(|(name, spec)| format!("{} = {}", name.as_source(), spec)) + .map(|(name, spec)| format!("{} = {}", name.as_source(), spec.to_toml_value())) .join("\n") } diff --git a/src/project/mod.rs b/src/project/mod.rs index 1aa26bb69..79b184179 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -19,12 +19,18 @@ use std::{ use async_once_cell::OnceCell as AsyncCell; pub use environment::Environment; +pub use has_project_ref::HasProjectRef; use indexmap::Equivalent; use miette::{IntoDiagnostic, NamedSource}; use once_cell::sync::OnceCell; +use pixi_config::Config; +use pixi_consts::consts; use pixi_manifest::{ - pyproject::PyProjectManifest, EnvironmentName, Environments, Manifest, ParsedManifest, SpecType, + pyproject::PyProjectManifest, EnvironmentName, Environments, HasManifestRef, Manifest, + ParsedManifest, SpecType, }; +use pixi_utils::reqwest::build_reqwest_clients; +use pypi_mapping::{ChannelName, CustomMapping, MappingLocation, MappingSource}; use rattler_conda_types::{ChannelConfig, Version}; use rattler_repodata_gateway::Gateway; use reqwest_middleware::ClientWithMiddleware; @@ -36,12 +42,6 @@ use crate::{ activation::{initialize_env_variables, CurrentEnvVarBehavior}, project::grouped_environment::GroupedEnvironment, }; -use pixi_config::Config; -use pixi_consts::consts; -use pixi_utils::reqwest::build_reqwest_clients; -use pypi_mapping::{ChannelName, CustomMapping, MappingLocation, MappingSource}; - -pub use has_project_ref::HasProjectRef; static CUSTOM_TARGET_DIR_WARN: OnceCell<()> = OnceCell::new(); @@ -612,6 +612,22 @@ impl Project { .expect("mapping source should be ok") }) } + + /// Returns the manifest of the project + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + + /// Convert the project into its manifest + pub fn into_manifest(self) -> Manifest { + self.manifest + } +} + +impl<'source> HasManifestRef<'source> for &'source Project { + fn manifest(&self) -> &'source Manifest { + Project::manifest(self) + } } /// Iterates over the current directory and all its parent directories and @@ -722,12 +738,11 @@ mod tests { use insta::{assert_debug_snapshot, assert_snapshot}; use itertools::Itertools; - use pixi_manifest::FeatureName; + use pixi_manifest::{FeatureName, FeaturesExt}; use rattler_conda_types::Platform; use rattler_virtual_packages::{LibC, VirtualPackage}; use super::*; - use pixi_manifest::FeaturesExt; const PROJECT_BOILERPLATE: &str = r#" [project] @@ -780,7 +795,7 @@ mod tests { fn format_dependencies(deps: pixi_manifest::CondaDependencies) -> String { deps.iter_specs() - .map(|(name, spec)| format!("{} = \"{}\"", name.as_source(), spec)) + .map(|(name, spec)| format!("{} = {}", name.as_source(), spec.to_toml_value())) .join("\n") } diff --git a/src/project/snapshots/pixi__project__environment__tests__dependencies.snap b/src/project/snapshots/pixi__project__environment__tests__dependencies.snap index 37ebe7988..42562bc8d 100644 --- a/src/project/snapshots/pixi__project__environment__tests__dependencies.snap +++ b/src/project/snapshots/pixi__project__environment__tests__dependencies.snap @@ -1,8 +1,9 @@ --- source: src/project/environment.rs +assertion_line: 475 expression: format_dependencies(deps) --- -foo = >=1.0 -foo = <2.0 -foo = <4.0 -bar = >=1.0 +foo = ">=1.0" +foo = "<2.0" +foo = "<4.0" +bar = ">=1.0" diff --git a/tests/add_tests.rs b/tests/add_tests.rs index 7a67ac9b4..7ced7f6a1 100644 --- a/tests/add_tests.rs +++ b/tests/add_tests.rs @@ -1,20 +1,21 @@ mod common; -use crate::common::builders::{HasDependencyConfig, HasPrefixUpdateConfig}; -use crate::common::package_database::{Package, PackageDatabase}; -use crate::common::LockFileExt; -use crate::common::PixiControl; +use std::str::FromStr; + use pixi::{DependencyType, Project}; use pixi_consts::consts; -use pixi_manifest::pypi::PyPiPackageName; -use pixi_manifest::FeaturesExt; -use pixi_manifest::SpecType; +use pixi_manifest::{pypi::PyPiPackageName, FeaturesExt, SpecType}; use rattler_conda_types::{PackageName, Platform}; use serial_test::serial; -use std::str::FromStr; use tempfile::TempDir; use uv_normalize::ExtraName; +use crate::common::{ + builders::{HasDependencyConfig, HasPrefixUpdateConfig}, + package_database::{Package, PackageDatabase}, + LockFileExt, PixiControl, +}; + /// Test add functionality for different types of packages. /// Run, dev, build #[tokio::test] @@ -90,6 +91,11 @@ async fn add_with_channel() { .await .unwrap(); + pixi.add("https://prefix.dev/conda-forge::_r-mutex") + .without_lockfile_update() + .await + .unwrap(); + let project = Project::from_path(pixi.manifest_path().as_path()).unwrap(); let mut specs = project .default_environment() @@ -98,10 +104,21 @@ async fn add_with_channel() { let (name, spec) = specs.next().unwrap(); assert_eq!(name, PackageName::try_from("py_rattler").unwrap()); - assert_eq!(spec.channel.unwrap().name(), "conda-forge"); + assert_eq!( + spec.into_detailed().unwrap().channel.unwrap().as_str(), + "conda-forge" + ); + + let (name, spec) = specs.next().unwrap(); + assert_eq!(name, PackageName::try_from("_r-mutex").unwrap()); + assert_eq!( + spec.into_detailed().unwrap().channel.unwrap().as_str(), + "https://prefix.dev/conda-forge" + ); } -/// Test that we get the union of all packages in the lockfile for the run, build and host +/// Test that we get the union of all packages in the lockfile for the run, +/// build and host #[tokio::test] async fn add_functionality_union() { let mut package_database = PackageDatabase::default(); @@ -295,7 +312,8 @@ async fn add_pypi_functionality() { Platform::Linux64, pep508_rs::Requirement::from_str("pytest").unwrap(), )); - // Test that the dev extras are added, mock is a test dependency of `pytest==8.3.2` + // Test that the dev extras are added, mock is a test dependency of + // `pytest==8.3.2` assert!(lock.contains_pep508_requirement( consts::DEFAULT_ENVIRONMENT_NAME, Platform::Linux64, @@ -365,3 +383,53 @@ async fn add_sdist_functionality() { .await .unwrap(); } + +#[rstest::rstest] +#[tokio::test] +async fn add_unconstrainted_dependency() { + // Create a channel with a single package + let mut package_database = PackageDatabase::default(); + package_database.add_package(Package::build("foobar", "1").finish()); + package_database.add_package(Package::build("bar", "1").finish()); + let local_channel = package_database.into_channel().await.unwrap(); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::new().unwrap(); + pixi.init().with_channel(local_channel.url()).await.unwrap(); + + // Add the `packages` to the project + pixi.add("foobar").await.unwrap(); + pixi.add("bar").with_feature("unreferenced").await.unwrap(); + + let project = pixi.project().unwrap(); + + // Get the specs for the `foobar` package + let foo_spec = project + .manifest() + .default_feature() + .dependencies(None, None) + .unwrap_or_default() + .get("foobar") + .cloned() + .unwrap() + .to_toml_value() + .to_string(); + + // Get the specs for the `bar` package + let bar_spec = project + .manifest() + .feature("unreferenced") + .expect("feature 'unreferenced' is missing") + .dependencies(None, None) + .unwrap_or_default() + .get("bar") + .cloned() + .unwrap() + .to_toml_value() + .to_string(); + + insta::assert_snapshot!(format!("foobar = {foo_spec}\nbar = {bar_spec}"), @r###" + foobar = ">=1,<2" + bar = "*" + "###); +}