From 7f18db9d7a4d59d43d900f21c4a21da120c428ad Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 5 May 2026 12:11:03 +0200 Subject: [PATCH 1/3] implement v3 repodata/matchspec support in pixi spec Adds new fields to PixiSpec types (extras, flags, license_family, condition, track_features) and switches MatchSpec parsing to v3-aware ParseMatchSpecOptions across pixi crates. The repodata reporter now surfaces a deduplicated warning when a backend reports an unsupported repodata revision. This is the pixi-side half of v3 support; the pixi-build backend changes follow in a separate PR. --- .../src/project_model.rs | 3 + crates/pixi_cli/src/global/global_specs.rs | 18 ++- crates/pixi_cli/src/has_specs.rs | 9 +- crates/pixi_cli/src/match_spec_or_path.rs | 8 +- .../src/build/conversion.rs | 8 ++ .../src/build/dependencies.rs | 26 +++-- .../src/keys/solve_conda.rs | 7 +- .../src/keys/solve_pixi_environment.rs | 10 +- .../src/lock_file/satisfiability/platform.rs | 9 +- crates/pixi_global/src/install.rs | 9 +- crates/pixi_global/src/project/global_spec.rs | 9 +- .../pixi_reporters/src/repodata_reporter.rs | 23 +++- crates/pixi_spec/src/detailed.rs | 57 +++++++-- crates/pixi_spec/src/lib.rs | 85 +++++++++++++- crates/pixi_spec/src/toml.rs | 110 ++++++++++++++++-- 15 files changed, 339 insertions(+), 52 deletions(-) diff --git a/crates/pixi_build_type_conversions/src/project_model.rs b/crates/pixi_build_type_conversions/src/project_model.rs index bbd7f9144f..905df3e5a3 100644 --- a/crates/pixi_build_type_conversions/src/project_model.rs +++ b/crates/pixi_build_type_conversions/src/project_model.rs @@ -31,10 +31,13 @@ fn to_pixi_spec_v1( build, build_number, extras: None, + flags: None, subdir, namespace: None, license, + license_family: None, condition: None, + track_features: None, } = source else { unimplemented!( diff --git a/crates/pixi_cli/src/global/global_specs.rs b/crates/pixi_cli/src/global/global_specs.rs index 92659d3ef4..40856302eb 100644 --- a/crates/pixi_cli/src/global/global_specs.rs +++ b/crates/pixi_cli/src/global/global_specs.rs @@ -9,7 +9,9 @@ use pixi_config::pixi_home; use pixi_consts::consts; use pixi_global::project::FromMatchSpecError; use pixi_spec::{PixiSpec, Subdirectory, SubdirectoryError}; -use rattler_conda_types::{ChannelConfig, MatchSpec, ParseMatchSpecError, ParseStrictness}; +use rattler_conda_types::{ + ChannelConfig, MatchSpec, ParseMatchSpecError, ParseMatchSpecOptions, RepodataRevision, +}; use typed_path::Utf8NativePathBuf; use crate::has_specs::HasSpecs; @@ -185,11 +187,15 @@ impl GlobalSpecs { self.specs .iter() .map(|spec_str| { - let name = MatchSpec::from_str(spec_str, ParseStrictness::Lenient)? - .name - .as_exact() - .cloned() - .ok_or(GlobalSpecsConversionError::NameRequired)?; + let name = MatchSpec::from_str( + spec_str, + ParseMatchSpecOptions::lenient() + .with_repodata_revision(RepodataRevision::V3), + )? + .name + .as_exact() + .cloned() + .ok_or(GlobalSpecsConversionError::NameRequired)?; Ok(pixi_global::project::GlobalSpec::new( name, pixi_spec.clone(), diff --git a/crates/pixi_cli/src/has_specs.rs b/crates/pixi_cli/src/has_specs.rs index 6abc56a6a0..c239813262 100644 --- a/crates/pixi_cli/src/has_specs.rs +++ b/crates/pixi_cli/src/has_specs.rs @@ -3,7 +3,7 @@ use miette::IntoDiagnostic; use pep508_rs::Requirement; use pixi_core::Workspace; use pixi_pypi_spec::PypiPackageName; -use rattler_conda_types::{MatchSpec, PackageName, ParseStrictness}; +use rattler_conda_types::{MatchSpec, PackageName, ParseMatchSpecOptions, RepodataRevision}; /// A trait to facilitate extraction of packages data from arguments pub(crate) trait HasSpecs { @@ -14,8 +14,11 @@ pub(crate) trait HasSpecs { self.packages() .iter() .map(|package| { - let spec = - MatchSpec::from_str(package, ParseStrictness::Lenient).into_diagnostic()?; + let spec = MatchSpec::from_str( + package, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) + .into_diagnostic()?; let name = spec.name.as_exact().cloned().ok_or_else(|| { miette::miette!("could not find exact package name in MatchSpec {}", spec) })?; diff --git a/crates/pixi_cli/src/match_spec_or_path.rs b/crates/pixi_cli/src/match_spec_or_path.rs index 7c64cbc46a..5fd502a5d9 100644 --- a/crates/pixi_cli/src/match_spec_or_path.rs +++ b/crates/pixi_cli/src/match_spec_or_path.rs @@ -7,7 +7,8 @@ use std::{ use dunce::canonicalize; use pixi_spec::PathSpec; use rattler_conda_types::{ - MatchSpec, PackageName, ParseStrictness, package::CondaArchiveIdentifier, + MatchSpec, PackageName, ParseMatchSpecOptions, RepodataRevision, + package::CondaArchiveIdentifier, }; /// Represents either a regular conda MatchSpec or a filesystem path to a conda artifact. @@ -78,7 +79,10 @@ impl FromStr for MatchSpecOrPath { }))); } - match MatchSpec::from_str(value, ParseStrictness::Lenient) { + match MatchSpec::from_str( + value, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) { Ok(spec) => Ok(Self::MatchSpec(Box::new(spec))), Err(parse_err) => { if looks_like_path(value) { diff --git a/crates/pixi_command_dispatcher/src/build/conversion.rs b/crates/pixi_command_dispatcher/src/build/conversion.rs index d99a366434..bc39e27af2 100644 --- a/crates/pixi_command_dispatcher/src/build/conversion.rs +++ b/crates/pixi_command_dispatcher/src/build/conversion.rs @@ -21,8 +21,11 @@ pub fn from_source_spec_v1(source: SourcePackageSpec) -> pixi_spec::SourceSpec { subdir, license, extras: None, + flags: None, namespace: None, + license_family: None, condition: None, + track_features: None, } } @@ -111,9 +114,14 @@ pub fn from_binary_spec_v1(spec: BinaryPackageSpec) -> pixi_spec::BinarySpec { build, build_number, file_name, + extras: None, + flags: None, channel: channel.map(NamedChannelOrUrl::Url), subdir, license, + license_family: None, + condition: None, + track_features: None, md5, sha256, })), diff --git a/crates/pixi_command_dispatcher/src/build/dependencies.rs b/crates/pixi_command_dispatcher/src/build/dependencies.rs index 3319ff5879..f1bd3de8e2 100644 --- a/crates/pixi_command_dispatcher/src/build/dependencies.rs +++ b/crates/pixi_command_dispatcher/src/build/dependencies.rs @@ -16,7 +16,7 @@ use pixi_spec::{BinarySpec, DetailedSpec, PixiSpec, SourceAnchor, UrlBinarySpec} use pixi_spec_containers::DependencyMap; use rattler_conda_types::{ InvalidPackageNameError, MatchSpec, NamedChannelOrUrl, NamelessMatchSpec, PackageName, - ParseStrictness, Platform, VersionSpec, + ParseMatchSpecOptions, Platform, RepodataRevision, VersionSpec, }; use rattler_repodata_gateway::{Gateway, RunExportExtractorError, RunExportsReporter}; use serde::Serialize; @@ -308,9 +308,12 @@ pub fn filter_match_specs + Clone + Hash + Eq + PartialEq>( specs .iter() .filter_map(move |spec| { - let (name_matcher, spec) = MatchSpec::from_str(spec, ParseStrictness::Lenient) - .ok()? - .into_nameless(); + let (name_matcher, spec) = MatchSpec::from_str( + spec, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) + .ok()? + .into_nameless(); let name = name_matcher.as_exact().cloned()?; if ignore.by_name.contains(&name) { return None; @@ -346,32 +349,37 @@ pub fn filter_match_specs + Clone + Hash + Eq + PartialEq>( build, build_number, file_name, + extras, + flags, channel, subdir, md5, sha256, license, + license_family, + condition, + track_features, // Caught in the above case url: _, // Explicitly ignored namespace: _, - extras: _, - condition: _, - track_features: _, - flags: _, - license_family: _, } => BinarySpec::DetailedVersion(Box::new(DetailedSpec { version, build, build_number, file_name, + extras, + flags, channel: channel.map(|c| NamedChannelOrUrl::Url(c.base_url.clone().into())), subdir, md5, sha256, license, + license_family, + condition, + track_features, })), }; diff --git a/crates/pixi_command_dispatcher/src/keys/solve_conda.rs b/crates/pixi_command_dispatcher/src/keys/solve_conda.rs index 6b8a3e87c6..5d94357722 100644 --- a/crates/pixi_command_dispatcher/src/keys/solve_conda.rs +++ b/crates/pixi_command_dispatcher/src/keys/solve_conda.rs @@ -21,7 +21,7 @@ use pixi_spec::{BinarySpec, ResolvedExcludeNewer, SourceSpec, SpecConversionErro use pixi_spec_containers::DependencyMap; use rattler_conda_types::{ Channel, ChannelConfig, ChannelUrl, GenericVirtualPackage, MatchSpec, PackageName, - PackageNameMatcher, ParseStrictness, Platform, + PackageNameMatcher, ParseMatchSpecOptions, Platform, RepodataRevision, }; use rattler_repodata_gateway::GatewayError; use rattler_solve::{ChannelPriority, SolveError, SolveStrategy}; @@ -285,7 +285,10 @@ fn derive_fetch_specs_from_source_repodata(spec: &SolveCondaSpec) -> Vec Result { - let match_spec = MatchSpec::from_str(spec_str, ParseStrictness::Lenient)?; + let match_spec = MatchSpec::from_str( + spec_str, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + )?; GlobalSpec::try_from_matchspec_with_name(match_spec, channel_config) } diff --git a/crates/pixi_reporters/src/repodata_reporter.rs b/crates/pixi_reporters/src/repodata_reporter.rs index f0beab5a4b..e6f74ca356 100644 --- a/crates/pixi_reporters/src/repodata_reporter.rs +++ b/crates/pixi_reporters/src/repodata_reporter.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, fmt::Write, sync::Arc, time::{Duration, Instant}, @@ -7,7 +8,7 @@ use std::{ use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle, style::ProgressTracker}; use parking_lot::RwLock; use pixi_progress::ProgressBarPlacement; -use rattler_repodata_gateway::DownloadReporter; +use rattler_repodata_gateway::{DownloadReporter, UnsupportedRepodataRevision}; use url::Url; #[derive(Clone)] @@ -19,6 +20,11 @@ impl rattler_repodata_gateway::Reporter for RepodataReporter { fn download_reporter(&self) -> Option<&dyn DownloadReporter> { Some(self) } + + fn on_unsupported_repodata_revision(&self, message: &UnsupportedRepodataRevision) { + let mut inner = self.inner.write(); + inner.on_unsupported_repodata_revision(message); + } } impl RepodataReporter { @@ -31,6 +37,7 @@ struct RepodataReporterInner { pb: ProgressBar, title: Option, downloads: Arc>>, + unsupported_revision_warnings: HashSet, } struct TrackedDownload { @@ -52,6 +59,7 @@ impl RepodataReporter { pb, title: Some(title), downloads: Arc::new(RwLock::new(Vec::new())), + unsupported_revision_warnings: HashSet::new(), })), } } @@ -63,6 +71,19 @@ impl RepodataReporterInner { self.downloads.write().clear(); } + fn on_unsupported_repodata_revision(&mut self, message: &UnsupportedRepodataRevision) { + let message = message.to_string(); + if self.unsupported_revision_warnings.insert(message.clone()) { + pixi_progress::println!( + "{}", + console::style(format!( + "warning: {message}. Update pixi to read those records." + )) + .yellow() + ); + } + } + pub fn update(&mut self) { let downloads = self.downloads.read(); if !downloads.iter().any(|d| d.bytes_downloaded > 0) { diff --git a/crates/pixi_spec/src/detailed.rs b/crates/pixi_spec/src/detailed.rs index 96c9598681..0ea7d9fce5 100644 --- a/crates/pixi_spec/src/detailed.rs +++ b/crates/pixi_spec/src/detailed.rs @@ -1,8 +1,8 @@ use std::{fmt::Display, sync::Arc}; use rattler_conda_types::{ - BuildNumberSpec, ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, StringMatcher, - VersionSpec, + BuildNumberSpec, ChannelConfig, MatchSpecCondition, NamedChannelOrUrl, NamelessMatchSpec, + StringMatcher, VersionSpec, }; use rattler_digest::{Md5Hash, Sha256Hash}; use serde_with::{serde_as, skip_serializing_none}; @@ -34,6 +34,13 @@ pub struct DetailedSpec { /// Match the specific filename of the package pub file_name: Option, + /// Optional extra dependencies to select for the package. + pub extras: Option>, + + /// Plain string flags used to select package variants. + #[serde_as(as = "Option>")] + pub flags: Option>, + /// The channel of the package pub channel: Option, @@ -43,6 +50,15 @@ pub struct DetailedSpec { /// The license pub license: Option, + /// The license family + pub license_family: Option, + + /// The condition under which this match spec applies. + pub condition: Option, + + /// The track features of the package + pub track_features: Option>, + /// The md5 hash of the package #[serde_as(as = "Option>")] pub md5: Option, @@ -63,6 +79,8 @@ impl DetailedSpec { build: self.build, build_number: self.build_number, file_name: self.file_name, + extras: self.extras, + flags: self.flags, channel: self .channel .map(|c| { @@ -77,12 +95,10 @@ impl DetailedSpec { md5: self.md5, sha256: self.sha256, license: self.license, + license_family: self.license_family, url: None, - extras: Default::default(), - condition: None, - track_features: None, - flags: None, - license_family: None, + condition: self.condition, + track_features: self.track_features, }) } } @@ -113,6 +129,21 @@ impl Display for DetailedSpec { parts.push(format!("file_name={file_name}")); } + if let Some(extras) = &self.extras { + parts.push(format!("extras=[{}]", extras.join(", "))); + } + + if let Some(flags) = &self.flags { + parts.push(format!( + "flags=[{}]", + flags + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + } + if let Some(channel) = &self.channel { parts.push(format!("channel={channel}")); } @@ -125,6 +156,18 @@ impl Display for DetailedSpec { parts.push(format!("license={license}")); } + if let Some(license_family) = &self.license_family { + parts.push(format!("license_family={license_family}")); + } + + if let Some(condition) = &self.condition { + parts.push(format!("condition={condition}")); + } + + if let Some(track_features) = &self.track_features { + parts.push(format!("track_features=[{}]", track_features.join(", "))); + } + if let Some(md5) = &self.md5 { parts.push(format!("md5={md5:x}")); } diff --git a/crates/pixi_spec/src/lib.rs b/crates/pixi_spec/src/lib.rs index a9ab1143d5..50e0b0d106 100644 --- a/crates/pixi_spec/src/lib.rs +++ b/crates/pixi_spec/src/lib.rs @@ -146,11 +146,16 @@ impl PixiSpec { } else if spec.build.is_none() && spec.build_number.is_none() && spec.file_name.is_none() + && spec.extras.is_none() + && spec.flags.is_none() && spec.channel.is_none() && spec.subdir.is_none() && spec.md5.is_none() && spec.sha256.is_none() && spec.license.is_none() + && spec.license_family.is_none() + && spec.condition.is_none() + && spec.track_features.is_none() { Self::Version(spec.version.unwrap_or(VersionSpec::Any)) } else { @@ -159,6 +164,8 @@ impl PixiSpec { build: spec.build, build_number: spec.build_number, file_name: spec.file_name, + extras: spec.extras, + flags: spec.flags, channel: spec.channel.map(|c| { NamedChannelOrUrl::from_str(&channel_config.canonical_name(c.base_url.url())) .unwrap() @@ -167,6 +174,9 @@ impl PixiSpec { md5: spec.md5, sha256: spec.sha256, license: spec.license, + license_family: spec.license_family, + condition: spec.condition, + track_features: spec.track_features, })) } } @@ -415,6 +425,9 @@ pub struct SourceSpec { /// Optional extra dependencies to select for the package pub extras: Option>, + /// Plain string flags used to select package variants. + pub flags: Option>, + /// The subdir of the channel pub subdir: Option, @@ -424,8 +437,14 @@ pub struct SourceSpec { /// The license of the package pub license: Option, + /// The license family of the package + pub license_family: Option, + /// The condition under which this match spec applies. pub condition: Option, + + /// The track features of the package + pub track_features: Option>, } impl From for SourceSpec { @@ -436,10 +455,13 @@ impl From for SourceSpec { build: None, build_number: None, extras: None, + flags: None, subdir: None, namespace: None, license: None, + license_family: None, condition: None, + track_features: None, } } } @@ -484,6 +506,7 @@ impl SourceSpec { build_number, file_name: _, extras, + flags, channel: _, subdir, namespace, @@ -492,9 +515,8 @@ impl SourceSpec { url: _, license, condition, - track_features: _, - flags: _, - license_family: _, + track_features, + license_family, } = spec; Self { location, @@ -502,10 +524,13 @@ impl SourceSpec { build, build_number, extras, + flags, subdir, namespace, license, + license_family, condition, + track_features, } } @@ -517,20 +542,26 @@ impl SourceSpec { build, build_number, extras, + flags, subdir, namespace, license, + license_family, condition, + track_features, } = self; NamelessMatchSpec { version: version.clone(), build: build.clone(), build_number: build_number.clone(), extras: extras.clone(), + flags: flags.clone(), subdir: subdir.clone(), namespace: namespace.clone(), license: license.clone(), + license_family: license_family.clone(), condition: condition.clone(), + track_features: track_features.clone(), ..NamelessMatchSpec::default() } } @@ -801,7 +832,13 @@ impl From for rattler_lock::source::PathSourceLocation { #[cfg(test)] mod test { - use rattler_conda_types::{ChannelConfig, PackageName, ParseStrictness::Lenient, VersionSpec}; + use std::str::FromStr; + + use rattler_conda_types::{ + ChannelConfig, MatchSpec, MatchSpecCondition, NamelessMatchSpec, PackageName, + ParseMatchSpecOptions, ParseStrictness::Lenient, RepodataRevision, StringMatcher, + VersionSpec, + }; use serde::Serialize; use serde_json::{Value, json}; use url::Url; @@ -913,6 +950,46 @@ mod test { assert_eq!(match_spec.to_string(), "numpy >=1.0"); } + #[test] + fn test_v3_nameless_match_spec_fields_roundtrip() { + let channel_config = ChannelConfig::default_with_root_dir(std::env::current_dir().unwrap()); + let condition = MatchSpecCondition::MatchSpec(Box::new( + MatchSpec::from_str( + "python >=3.12", + ParseMatchSpecOptions::lenient() + .with_repodata_revision(RepodataRevision::V3) + .with_experimental_conditionals(true), + ) + .unwrap(), + )); + let spec = NamelessMatchSpec { + version: Some(VersionSpec::from_str(">=1.0", Lenient).unwrap()), + extras: Some(vec!["cuda".to_string(), "mkl".to_string()]), + flags: Some(vec![ + StringMatcher::from_str("cuda").unwrap(), + StringMatcher::from_str("blas:*").unwrap(), + ]), + license_family: Some("BSD".to_string()), + condition: Some(condition.clone()), + track_features: Some(vec!["legacy".to_string()]), + ..NamelessMatchSpec::default() + }; + + let pixi_spec = PixiSpec::from_nameless_matchspec(spec.clone(), &channel_config); + assert!(matches!(pixi_spec, PixiSpec::DetailedVersion(_))); + + let roundtrip = pixi_spec + .try_into_nameless_match_spec(&channel_config) + .unwrap() + .unwrap(); + assert_eq!(roundtrip.version, spec.version); + assert_eq!(roundtrip.extras, spec.extras); + assert_eq!(roundtrip.flags, spec.flags); + assert_eq!(roundtrip.license_family, spec.license_family); + assert_eq!(roundtrip.condition, spec.condition); + assert_eq!(roundtrip.track_features, spec.track_features); + } + #[test] fn test_pixi_spec_to_match_spec_source_path() { // A path-based source spec carries no version material, so the diff --git a/crates/pixi_spec/src/toml.rs b/crates/pixi_spec/src/toml.rs index 8c80169e25..14f913c9ef 100644 --- a/crates/pixi_spec/src/toml.rs +++ b/crates/pixi_spec/src/toml.rs @@ -1,11 +1,11 @@ use std::{borrow::Cow, fmt::Display, path::PathBuf}; use itertools::Either; -use pixi_toml::{TomlDigest, TomlFromStr}; +use pixi_toml::{TomlDigest, TomlFromStr, TomlWith}; use rattler_conda_types::{ - BuildNumberSpec, ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, + BuildNumberSpec, ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, ParseMatchSpecOptions, ParseStrictness::{Lenient, Strict}, - StringMatcher, VersionSpec, + RepodataRevision, StringMatcher, VersionSpec, version_spec::{ParseConstraintError, ParseVersionSpecError}, }; use rattler_digest::{Md5Hash, Sha256Hash}; @@ -49,6 +49,13 @@ pub struct TomlSpec { /// Match the specific filename of the package pub file_name: Option, + /// Optional extra dependencies to select for the package. + pub extras: Option>, + + /// Plain string flags used to select package variants. + #[serde_as(as = "Option>")] + pub flags: Option>, + /// The channel of the package pub channel: Option, @@ -57,6 +64,12 @@ pub struct TomlSpec { /// The license pub license: Option, + + /// The license family + pub license_family: Option, + + /// The track features of the package + pub track_features: Option>, } /// A TOML representation of a package source location specification. @@ -120,7 +133,10 @@ fn version_spec_error>(input: T) -> Option { )); } - if let Ok(match_spec) = NamelessMatchSpec::from_str(&input, Lenient) { + if let Ok(match_spec) = NamelessMatchSpec::from_str( + &input, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) { let spec = PixiSpec::from_nameless_matchspec( match_spec, &ChannelConfig::default_with_root_dir(PathBuf::default()), @@ -240,8 +256,11 @@ impl TomlSpec { ("`build`", self.build.is_some()), ("`build_number`", self.build_number.is_some()), ("`file_name`", self.file_name.is_some()), + ("`extras`", self.extras.is_some()), + ("`flags`", self.flags.is_some()), ("`channel`", self.channel.is_some()), ("`subdir`", self.subdir.is_some()), + ("`track_features`", self.track_features.is_some()), ] { if is_some { return Err(SpecError::InvalidCombination( @@ -321,11 +340,15 @@ impl TomlSpec { || self.build.is_some() || self.build_number.is_some() || self.file_name.is_some() + || self.extras.is_some() + || self.flags.is_some() || self.channel.is_some() || self.subdir.is_some() || loc.md5.is_some() || loc.sha256.is_some() - || self.license.is_some(); + || self.license.is_some() + || self.license_family.is_some() + || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); } @@ -335,11 +358,16 @@ impl TomlSpec { build: self.build, build_number: self.build_number, file_name: self.file_name, + extras: self.extras, + flags: self.flags, channel: self.channel, subdir: self.subdir, md5: loc.md5, sha256: loc.sha256, license: self.license, + license_family: self.license_family, + condition: None, + track_features: self.track_features, })) } (_, _, _) => return Err(SpecError::MultipleIdentifiers), @@ -349,9 +377,13 @@ impl TomlSpec { || self.build.is_some() || self.build_number.is_some() || self.file_name.is_some() + || self.extras.is_some() + || self.flags.is_some() || self.channel.is_some() || self.subdir.is_some() - || self.license.is_some(); + || self.license.is_some() + || self.license_family.is_some() + || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); } @@ -361,11 +393,16 @@ impl TomlSpec { build: self.build, build_number: self.build_number, file_name: self.file_name, + extras: self.extras, + flags: self.flags, channel: self.channel, subdir: self.subdir, md5: None, sha256: None, license: self.license, + license_family: self.license_family, + condition: None, + track_features: self.track_features, })); } @@ -412,11 +449,15 @@ impl TomlSpec { || self.build.is_some() || self.build_number.is_some() || self.file_name.is_some() + || self.extras.is_some() + || self.flags.is_some() || self.channel.is_some() || self.subdir.is_some() || loc.md5.is_some() || loc.sha256.is_some() - || self.license.is_some(); + || self.license.is_some() + || self.license_family.is_some() + || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); } @@ -426,11 +467,16 @@ impl TomlSpec { build: self.build, build_number: self.build_number, file_name: self.file_name, + extras: self.extras, + flags: self.flags, channel: self.channel, subdir: self.subdir, md5: loc.md5, sha256: loc.sha256, license: self.license, + license_family: self.license_family, + condition: None, + track_features: self.track_features, })) } (_, _, _) => return Err(SpecError::MultipleIdentifiers), @@ -440,9 +486,13 @@ impl TomlSpec { || self.build.is_some() || self.build_number.is_some() || self.file_name.is_some() + || self.extras.is_some() + || self.flags.is_some() || self.channel.is_some() || self.subdir.is_some() - || self.license.is_some(); + || self.license.is_some() + || self.license_family.is_some() + || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); } @@ -452,11 +502,16 @@ impl TomlSpec { build: self.build, build_number: self.build_number, file_name: self.file_name, + extras: self.extras, + flags: self.flags, channel: self.channel, subdir: self.subdir, md5: None, sha256: None, license: self.license, + license_family: self.license_family, + condition: None, + track_features: self.track_features, })); }; Ok(spec) @@ -597,9 +652,15 @@ impl<'de> toml_span::Deserialize<'de> for TomlSpec { .optional::>("build-number") .map(TomlFromStr::into_inner); let file_name = th.optional("file-name"); + let extras = th.optional::>("extras"); + let flags = th + .optional::>>>("flags") + .map(TomlWith::into_inner); let channel = th.optional("channel").map(TomlFromStr::into_inner); let subdir = th.optional("subdir"); let license = th.optional("license"); + let license_family = th.optional("license-family"); + let track_features = th.optional::>("track-features"); let md5 = th .optional::>("md5") .map(TomlDigest::into_inner); @@ -625,9 +686,13 @@ impl<'de> toml_span::Deserialize<'de> for TomlSpec { build, build_number, file_name, + extras, + flags, channel, subdir, license, + license_family, + track_features, }) } } @@ -860,4 +925,33 @@ mod test { insta::assert_yaml_snapshot!(snapshot); } + + #[test] + fn test_v3_detailed_fields() { + let spec: PixiSpec = serde_json::from_value(json!({ + "version": ">=1.0", + "extras": ["cuda"], + "flags": ["cuda", "blas:*"], + "license-family": "BSD", + "track-features": ["legacy"], + })) + .unwrap(); + let detailed = spec.as_detailed().unwrap(); + assert_eq!(detailed.extras, Some(vec!["cuda".to_string()])); + assert_eq!( + detailed + .flags + .as_ref() + .unwrap() + .iter() + .map(ToString::to_string) + .collect::>(), + vec!["cuda".to_string(), "blas:*".to_string()] + ); + assert_eq!(detailed.license_family.as_deref(), Some("BSD")); + assert_eq!(detailed.track_features, Some(vec!["legacy".to_string()])); + + let err = serde_json::from_value::(json!("1.2.3[flags=[cuda]]")).unwrap_err(); + assert!(err.to_string().contains("flags")); + } } From da2c84adff657dda92420ba793de3d0263a170c0 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Thu, 14 May 2026 07:38:05 +0200 Subject: [PATCH 2/3] implement `any`, `all` for TOML when --- crates/pixi_build_backend/src/dependencies.rs | 1 + .../src/specs_conversion.rs | 27 +- .../src/traits/package_spec.rs | 7 +- crates/pixi_build_backend/src/variants.rs | 2 + .../tests/integration/common/model.rs | 1 + .../pixi_build_backend_passthrough/src/lib.rs | 1 + .../src/project_model.rs | 3 +- crates/pixi_build_types/src/project_model.rs | 12 +- .../src/build/conversion.rs | 4 +- crates/pixi_spec/src/toml.rs | 349 +++++++++++++++++- docs/build/dependency_types.md | 39 ++ docs/concepts/package_specifications.md | 14 + .../pixi_tomls/package_specifications.toml | 4 + .../pixi_tomls/pixi-package-manifest.toml | 1 + schema/model.py | 43 +++ schema/pixi_build_api.json | 3 + schema/pyproject/partial-pixi.json | 201 ++++++++++ schema/pyproject/schema.json | 201 ++++++++++ schema/schema.json | 201 ++++++++++ 19 files changed, 1104 insertions(+), 10 deletions(-) diff --git a/crates/pixi_build_backend/src/dependencies.rs b/crates/pixi_build_backend/src/dependencies.rs index 27209bc3ff..c81a780f6e 100644 --- a/crates/pixi_build_backend/src/dependencies.rs +++ b/crates/pixi_build_backend/src/dependencies.rs @@ -175,6 +175,7 @@ fn convert_nameless_matchspec(spec: NamelessMatchSpec) -> pbt::BinaryPackageSpec sha256: spec.sha256, url: spec.url, license: spec.license, + condition: spec.condition, } } diff --git a/crates/pixi_build_backend/src/specs_conversion.rs b/crates/pixi_build_backend/src/specs_conversion.rs index 503e2d097d..50d3421950 100644 --- a/crates/pixi_build_backend/src/specs_conversion.rs +++ b/crates/pixi_build_backend/src/specs_conversion.rs @@ -211,6 +211,7 @@ fn binary_package_spec_to_package_dependency( sha256, url, license, + condition, } = binary_spec; // If the version is "*", we treat it as None @@ -231,7 +232,7 @@ fn binary_package_spec_to_package_dependency( sha256, url, license, - condition: None, + condition, track_features: None, flags: None, license_family: None, @@ -441,4 +442,28 @@ mod test { let match_spec = binary_package_spec_to_package_dependency(name, spec); assert_eq!(match_spec.to_string(), "python"); } + + #[test] + fn test_binary_package_conversion_preserves_condition() { + use rattler_conda_types::{MatchSpecCondition, ParseMatchSpecOptions, RepodataRevision}; + + let name = PackageName::new_unchecked("numpy"); + let condition = MatchSpecCondition::MatchSpec(Box::new( + MatchSpec::from_str( + "python >=3.10", + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) + .unwrap(), + )); + let spec = BinaryPackageSpec { + version: Some("*".parse().unwrap()), + condition: Some(condition.clone()), + ..BinaryPackageSpec::default() + }; + let match_spec = binary_package_spec_to_package_dependency(name, spec); + let PackageDependency::Binary(match_spec) = match_spec else { + panic!("expected binary dependency"); + }; + assert_eq!(match_spec.condition, Some(condition)); + } } diff --git a/crates/pixi_build_backend/src/traits/package_spec.rs b/crates/pixi_build_backend/src/traits/package_spec.rs index 95bd7bcc86..03e75cf243 100644 --- a/crates/pixi_build_backend/src/traits/package_spec.rs +++ b/crates/pixi_build_backend/src/traits/package_spec.rs @@ -63,6 +63,7 @@ impl PackageSpec for pbt::PackageSpec { sha256, url, license, + condition, } = spec; version == &Some(rattler_conda_types::VersionSpec::Any) @@ -75,6 +76,7 @@ impl PackageSpec for pbt::PackageSpec { && sha256.is_none() && url.is_none() && license.is_none() + && condition.is_none() } _ => false, } @@ -131,7 +133,7 @@ impl BinarySpecExt for pbt::BinaryPackageSpec { license: self.license.clone(), extras: None, namespace: None, - condition: None, + condition: self.condition.clone(), track_features: None, flags: None, license_family: None, @@ -166,6 +168,7 @@ mod tests { sha256: None, url: None, license: None, + condition: None, }; let package_spec = pbt::PackageSpec::Binary(binary_spec); @@ -199,6 +202,7 @@ mod tests { sha256: None, url: None, license: None, + condition: None, }; let package_spec = pbt::PackageSpec::Binary(binary_spec); @@ -230,6 +234,7 @@ mod tests { sha256: None, url: None, license: None, + condition: None, }; let package_spec = pbt::PackageSpec::Binary(binary_spec); diff --git a/crates/pixi_build_backend/src/variants.rs b/crates/pixi_build_backend/src/variants.rs index 4e3d0007e5..7f5a0327df 100644 --- a/crates/pixi_build_backend/src/variants.rs +++ b/crates/pixi_build_backend/src/variants.rs @@ -21,6 +21,7 @@ pub fn can_be_used_as_variant(spec: &pbt::PackageSpec) -> bool { sha256, url, license, + condition, } = spec; version == &Some(VersionSpec::Any) @@ -33,6 +34,7 @@ pub fn can_be_used_as_variant(spec: &pbt::PackageSpec) -> bool { && sha256.is_none() && url.is_none() && license.is_none() + && condition.is_none() } _ => false, } diff --git a/crates/pixi_build_backend/tests/integration/common/model.rs b/crates/pixi_build_backend/tests/integration/common/model.rs index 47d079924c..474daebc82 100644 --- a/crates/pixi_build_backend/tests/integration/common/model.rs +++ b/crates/pixi_build_backend/tests/integration/common/model.rs @@ -205,6 +205,7 @@ fn convert_package_spec_to_v1(spec: &PackageSpec) -> PbtPackageSpec { sha256: None, url: None, license: None, + condition: None, }) } PackageSpec::Source(source_spec) => { diff --git a/crates/pixi_build_backend_passthrough/src/lib.rs b/crates/pixi_build_backend_passthrough/src/lib.rs index 8e2daad2fb..6a0156f2a4 100644 --- a/crates/pixi_build_backend_passthrough/src/lib.rs +++ b/crates/pixi_build_backend_passthrough/src/lib.rs @@ -405,6 +405,7 @@ fn is_star_requirement(spec: &PackageSpec) -> bool { sha256: None, url: None, license: None, + condition: None, } => version .as_ref() .is_none_or(|v| matches!(v, VersionSpec::Any)), diff --git a/crates/pixi_build_type_conversions/src/project_model.rs b/crates/pixi_build_type_conversions/src/project_model.rs index 905df3e5a3..283c6226d3 100644 --- a/crates/pixi_build_type_conversions/src/project_model.rs +++ b/crates/pixi_build_type_conversions/src/project_model.rs @@ -106,7 +106,7 @@ fn to_pixi_spec_v1( // These are currently explicitly ignored in the conversion namespace: _, extras: _, - condition: _, + condition, track_features: _, flags: _, license_family: _, @@ -122,6 +122,7 @@ fn to_pixi_spec_v1( sha256, url, license, + condition, }) } }; diff --git a/crates/pixi_build_types/src/project_model.rs b/crates/pixi_build_types/src/project_model.rs index aca7677d59..36883dcdaa 100644 --- a/crates/pixi_build_types/src/project_model.rs +++ b/crates/pixi_build_types/src/project_model.rs @@ -10,7 +10,9 @@ //! older pixi TOMLs keep loading, we can send them to the backend. use ordermap::OrderMap; use pixi_stable_hash::{IsDefault, StableHashBuilder}; -use rattler_conda_types::{BuildNumber, BuildNumberSpec, StringMatcher, Version, VersionSpec}; +use rattler_conda_types::{ + BuildNumber, BuildNumberSpec, MatchSpecCondition, StringMatcher, Version, VersionSpec, +}; use rattler_digest::{Md5, Md5Hash, Sha256, Sha256Hash, serde::SerializableHash}; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, DisplayFromStr, SerializeDisplay, serde_as}; @@ -469,6 +471,9 @@ pub struct BinaryPackageSpec { pub url: Option, /// The license of the package pub license: Option, + /// The condition under which this match spec applies. + #[cfg_attr(feature = "schemars", schemars(with = "Option"))] + pub condition: Option, } impl From for BinaryPackageSpec { @@ -517,6 +522,9 @@ impl std::fmt::Debug for BinaryPackageSpec { if let Some(sha256) = &self.sha256 { debug_struct.field("sha256", &format!("{sha256:x}")); } + if let Some(condition) = &self.condition { + debug_struct.field("condition", condition); + } debug_struct.finish() } @@ -817,12 +825,14 @@ impl Hash for BinaryPackageSpec { /// field configurations produce different hashes while maintaining /// forward/backward compatibility. fn hash(&self, state: &mut H) { + let condition = self.condition.as_ref().map(ToString::to_string); StableHashBuilder::::new() .field("build", &self.build) .field("build_number", &self.build_number) .field("channel", &self.channel) .field("file_name", &self.file_name) .field("license", &self.license) + .field("condition", &condition) .field("md5", &self.md5) .field("sha256", &self.sha256) .field("subdir", &self.subdir) diff --git a/crates/pixi_command_dispatcher/src/build/conversion.rs b/crates/pixi_command_dispatcher/src/build/conversion.rs index bc39e27af2..eb3783abd2 100644 --- a/crates/pixi_command_dispatcher/src/build/conversion.rs +++ b/crates/pixi_command_dispatcher/src/build/conversion.rs @@ -96,6 +96,7 @@ pub fn from_binary_spec_v1(spec: BinaryPackageSpec) -> pixi_spec::BinarySpec { md5: None, sha256: None, license: None, + condition: None, url: _, } => BinarySpec::Version(version), BinaryPackageSpec { @@ -108,6 +109,7 @@ pub fn from_binary_spec_v1(spec: BinaryPackageSpec) -> pixi_spec::BinarySpec { md5, sha256, license, + condition, url: _, } => BinarySpec::DetailedVersion(Box::new(DetailedSpec { version, @@ -120,7 +122,7 @@ pub fn from_binary_spec_v1(spec: BinaryPackageSpec) -> pixi_spec::BinarySpec { subdir, license, license_family: None, - condition: None, + condition, track_features: None, md5, sha256, diff --git a/crates/pixi_spec/src/toml.rs b/crates/pixi_spec/src/toml.rs index 14f913c9ef..060068535e 100644 --- a/crates/pixi_spec/src/toml.rs +++ b/crates/pixi_spec/src/toml.rs @@ -3,7 +3,8 @@ use std::{borrow::Cow, fmt::Display, path::PathBuf}; use itertools::Either; use pixi_toml::{TomlDigest, TomlFromStr, TomlWith}; use rattler_conda_types::{ - BuildNumberSpec, ChannelConfig, NamedChannelOrUrl, NamelessMatchSpec, ParseMatchSpecOptions, + BuildNumberSpec, ChannelConfig, MatchSpec, MatchSpecCondition, NamedChannelOrUrl, + NamelessMatchSpec, PackageName, PackageNameMatcher, ParseMatchSpecOptions, ParseStrictness::{Lenient, Strict}, RepodataRevision, StringMatcher, VersionSpec, version_spec::{ParseConstraintError, ParseVersionSpecError}, @@ -68,10 +69,47 @@ pub struct TomlSpec { /// The license family pub license_family: Option, + /// The condition under which this match spec applies. + pub when: Option, + /// The track features of the package pub track_features: Option>, } +/// A TOML representation of a package condition. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(untagged)] +pub enum TomlWhen { + /// A package matchspec without bracket or build-string shorthand syntax. + MatchSpec(String), + /// All conditions must apply. + All { + /// Conditions to combine with a logical AND. + all: Vec, + }, + /// Any condition may apply. + Any { + /// Conditions to combine with a logical OR. + any: Vec, + }, + /// Expanded package matchspec syntax. Required when matching a build string. + Expanded(TomlWhenPackage), +} + +/// The expanded package condition syntax. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TomlWhenPackage { + /// The package name to match. + pub package: PackageName, + /// Optional version constraint. + #[serde(default)] + pub version: Option, + /// Optional build string matcher. + #[serde(default)] + pub build: Option, +} + /// A TOML representation of a package source location specification. #[serde_as] #[derive(Debug, serde::Deserialize)] @@ -185,6 +223,9 @@ pub enum SpecError { #[error("{0} cannot be used with {1}")] InvalidCombination(Cow<'static, str>, Cow<'static, str>), + #[error("{0}")] + InvalidWhen(String), + #[error(transparent)] NotABinary(NotBinary), @@ -260,6 +301,7 @@ impl TomlSpec { ("`flags`", self.flags.is_some()), ("`channel`", self.channel.is_some()), ("`subdir`", self.subdir.is_some()), + ("`when`", self.when.is_some()), ("`track_features`", self.track_features.is_some()), ] { if is_some { @@ -348,6 +390,7 @@ impl TomlSpec { || loc.sha256.is_some() || self.license.is_some() || self.license_family.is_some() + || self.when.is_some() || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); @@ -366,7 +409,7 @@ impl TomlSpec { sha256: loc.sha256, license: self.license, license_family: self.license_family, - condition: None, + condition: self.when.map(TomlWhen::into_condition).transpose()?, track_features: self.track_features, })) } @@ -383,6 +426,7 @@ impl TomlSpec { || self.subdir.is_some() || self.license.is_some() || self.license_family.is_some() + || self.when.is_some() || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); @@ -401,7 +445,7 @@ impl TomlSpec { sha256: None, license: self.license, license_family: self.license_family, - condition: None, + condition: self.when.map(TomlWhen::into_condition).transpose()?, track_features: self.track_features, })); } @@ -457,6 +501,7 @@ impl TomlSpec { || loc.sha256.is_some() || self.license.is_some() || self.license_family.is_some() + || self.when.is_some() || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); @@ -475,7 +520,7 @@ impl TomlSpec { sha256: loc.sha256, license: self.license, license_family: self.license_family, - condition: None, + condition: self.when.map(TomlWhen::into_condition).transpose()?, track_features: self.track_features, })) } @@ -492,6 +537,7 @@ impl TomlSpec { || self.subdir.is_some() || self.license.is_some() || self.license_family.is_some() + || self.when.is_some() || self.track_features.is_some(); if !is_detailed { return Err(SpecError::MissingDetailedIdentifier); @@ -510,7 +556,7 @@ impl TomlSpec { sha256: None, license: self.license, license_family: self.license_family, - condition: None, + condition: self.when.map(TomlWhen::into_condition).transpose()?, track_features: self.track_features, })); }; @@ -518,6 +564,94 @@ impl TomlSpec { } } +impl TomlWhen { + fn into_condition(self) -> Result { + match self { + TomlWhen::MatchSpec(spec) => parse_when_matchspec(&spec), + TomlWhen::All { all } => fold_when_conditions(all, MatchSpecCondition::And, "all"), + TomlWhen::Any { any } => fold_when_conditions(any, MatchSpecCondition::Or, "any"), + TomlWhen::Expanded(expanded) => Ok(MatchSpecCondition::MatchSpec(Box::new( + expanded.into_match_spec(), + ))), + } + } +} + +impl TomlWhenPackage { + fn into_match_spec(self) -> MatchSpec { + MatchSpec { + name: PackageNameMatcher::Exact(self.package), + version: self.version.map(TomlVersionSpecStr::into_inner), + build: self.build, + ..MatchSpec::default() + } + } +} + +fn parse_when_matchspec(input: &str) -> Result { + if input.contains(['[', ']']) { + return Err(SpecError::InvalidWhen( + "`when` strings do not support bracket matchspec syntax; use the expanded `{ package = ..., version = ..., build = ... }` form".to_string(), + )); + } + + let match_spec = MatchSpec::from_str( + input, + ParseMatchSpecOptions::lenient().with_repodata_revision(RepodataRevision::V3), + ) + .map_err(|err| SpecError::InvalidWhen(format!("invalid `when` matchspec: {err}")))?; + + if match_spec.name.as_exact().is_none() { + return Err(SpecError::InvalidWhen( + "`when` strings must name an exact package".to_string(), + )); + } + + if match_spec.build.is_some() { + return Err(SpecError::InvalidWhen( + "`when` strings do not support build-string shorthand; use `{ package = ..., version = ..., build = ... }`".to_string(), + )); + } + + if match_spec.build_number.is_some() + || match_spec.file_name.is_some() + || match_spec.channel.is_some() + || match_spec.subdir.is_some() + || match_spec.md5.is_some() + || match_spec.sha256.is_some() + || match_spec.url.is_some() + || match_spec.license.is_some() + || match_spec.license_family.is_some() + || match_spec.extras.is_some() + || match_spec.flags.is_some() + || match_spec.condition.is_some() + || match_spec.track_features.is_some() + { + return Err(SpecError::InvalidWhen( + "`when` strings only support package names with optional version constraints; use the expanded form for additional matchspec fields".to_string(), + )); + } + + Ok(MatchSpecCondition::MatchSpec(Box::new(match_spec))) +} + +fn fold_when_conditions( + conditions: Vec, + combine: fn(Box, Box) -> MatchSpecCondition, + field: &'static str, +) -> Result { + let mut conditions = conditions.into_iter().map(TomlWhen::into_condition); + let first = conditions.next().ok_or_else(|| { + SpecError::InvalidWhen(format!( + "`when.{field}` must contain at least one condition" + )) + })??; + + conditions.try_fold(first, |left, right| { + Ok(combine(Box::new(left), Box::new(right?))) + }) +} + impl TomlLocationSpec { fn validate_field_combinations(&self) -> Result<(), SourceLocationSpecError> { let (is_git, is_path, is_url) = { @@ -603,6 +737,7 @@ impl TomlLocationSpec { /// A TOML representation wrapper of a [`VersionSpec`] /// Used to add custom deserialization for the version spec string +#[derive(Debug, Clone)] pub struct TomlVersionSpecStr(VersionSpec); impl TomlVersionSpecStr { @@ -612,6 +747,18 @@ impl TomlVersionSpecStr { } } +impl<'de> serde::Deserialize<'de> for TomlVersionSpecStr { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let str = String::deserialize(deserializer)?; + parse_version_string(&str) + .map(Self) + .map_err(serde::de::Error::custom) + } +} + impl<'de> toml_span::Deserialize<'de> for TomlVersionSpecStr { fn deserialize(value: &mut Value<'de>) -> Result { let str = value.take_string("a version specifier string".into())?; @@ -660,6 +807,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlSpec { let subdir = th.optional("subdir"); let license = th.optional("license"); let license_family = th.optional("license-family"); + let when = th.optional("when"); let track_features = th.optional::>("track-features"); let md5 = th .optional::>("md5") @@ -692,11 +840,62 @@ impl<'de> toml_span::Deserialize<'de> for TomlSpec { subdir, license, license_family, + when, track_features, }) } } +impl<'de> toml_span::Deserialize<'de> for TomlWhen { + fn deserialize(value: &mut Value<'de>) -> Result { + match value.take() { + ValueInner::String(str) => Ok(TomlWhen::MatchSpec(str.to_string())), + ValueInner::Array(_) => Err(DeserError::from(toml_span::Error { + kind: ErrorKind::Custom( + "`when` must be a string or a table with `all`, `any`, or `package`; top-level arrays are not allowed".into(), + ), + span: value.span, + line_info: None, + })), + inner @ ValueInner::Table(_) => { + let mut table_value = Value::with_span(inner, value.span); + let mut th = TableHelper::new(&mut table_value)?; + + let all = th.optional::>("all"); + let any = th.optional::>("any"); + let package = th.optional::>("package"); + let version = th.optional::("version"); + let build = th + .optional::>("build") + .map(TomlFromStr::into_inner); + + th.finalize(None)?; + + match (all, any, package, version, build) { + (Some(all), None, None, None, None) => Ok(TomlWhen::All { all }), + (None, Some(any), None, None, None) => Ok(TomlWhen::Any { any }), + (None, None, Some(package), version, build) => { + Ok(TomlWhen::Expanded(TomlWhenPackage { + package: package.into_inner(), + version, + build, + })) + } + _ => Err(DeserError::from(toml_span::Error { + kind: ErrorKind::Custom( + "`when` tables must contain exactly one of `all`, `any`, or `package`" + .into(), + ), + span: table_value.span, + line_info: None, + })), + } + } + inner => Err(expected("a string or a table", inner, value.span).into()), + } + } +} + impl<'de> toml_span::Deserialize<'de> for TomlLocationSpec { fn deserialize(value: &mut Value<'de>) -> Result { let mut th = TableHelper::new(value)?; @@ -954,4 +1153,144 @@ mod test { let err = serde_json::from_value::(json!("1.2.3[flags=[cuda]]")).unwrap_err(); assert!(err.to_string().contains("flags")); } + + #[test] + fn test_when_condition_syntax() { + use rattler_conda_types::MatchSpecCondition; + + let spec: PixiSpec = serde_json::from_value(json!({ + "version": "*", + "when": "__unix" + })) + .unwrap(); + assert_eq!( + spec.as_detailed() + .unwrap() + .condition + .as_ref() + .unwrap() + .to_string(), + "__unix" + ); + + let spec: PixiSpec = serde_json::from_value(json!({ + "version": "*", + "when": { "package": "python", "version": ">=3.10", "build": "*cuda" } + })) + .unwrap(); + assert_eq!( + spec.as_detailed() + .unwrap() + .condition + .as_ref() + .unwrap() + .to_string(), + "python >=3.10 *cuda" + ); + + let spec: PixiSpec = serde_json::from_value(json!({ + "version": "*", + "when": { + "all": [ + "__unix", + "python >=3.10", + { "package": "numpy", "version": ">=2", "build": "*cuda" } + ] + } + })) + .unwrap(); + + let condition = spec.as_detailed().unwrap().condition.as_ref().unwrap(); + let MatchSpecCondition::And(left, right) = condition else { + panic!("expected top-level AND condition"); + }; + let MatchSpecCondition::And(left_left, left_right) = left.as_ref() else { + panic!("expected nested AND condition"); + }; + assert_eq!(left_left.to_string(), "__unix"); + assert_eq!(left_right.to_string(), "python >=3.10"); + assert_eq!(right.to_string(), "numpy >=2 *cuda"); + + let spec: PixiSpec = serde_json::from_value(json!({ + "version": "*", + "when": { "any": ["__linux", "__osx"] } + })) + .unwrap(); + assert_eq!( + spec.as_detailed() + .unwrap() + .condition + .as_ref() + .unwrap() + .to_string(), + "(__linux or __osx)" + ); + + let spec: PixiSpec = serde_json::from_value(json!({ + "version": "*", + "when": { "all": ["__unix", { "any": ["__linux", "__osx"] }] } + })) + .unwrap(); + assert_eq!( + spec.as_detailed() + .unwrap() + .condition + .as_ref() + .unwrap() + .to_string(), + "(__unix and (__linux or __osx))" + ); + + let mut value = toml_span::parse( + r#" + version = "*" + when = { all = ["__unix", { package = "python", version = ">=3.10", build = "*cuda" }] } + "#, + ) + .unwrap(); + let spec = ::deserialize(&mut value).unwrap(); + assert_eq!( + spec.as_detailed() + .unwrap() + .condition + .as_ref() + .unwrap() + .to_string(), + "(__unix and python >=3.10 *cuda)" + ); + } + + #[test] + fn test_when_rejects_unsupported_shorthand() { + serde_json::from_value::( + json!({ "version": "*", "when": ["__unix", "python >=3.10"] }), + ) + .unwrap_err(); + + for input in [ + json!({ "version": "*", "when": "python[version='>=3.10']" }), + json!({ "version": "*", "when": "python >=3.10 *cuda" }), + ] { + let err = serde_json::from_value::(input).unwrap_err(); + let err = err.to_string(); + assert!(err.contains("when"), "expected `when` error, got: {err}"); + } + + for input in [ + r#"version = "*" + when = ["__unix"]"#, + r#"version = "*" + when = { all = ["__unix"], any = ["__linux"] }"#, + r#"version = "*" + when = { all = [] }"#, + r#"version = "*" + when = { any = [] }"#, + ] { + let mut value = toml_span::parse(input).unwrap(); + let err = ::deserialize(&mut value) + .unwrap_err() + .to_string(); + assert!(err.contains("when"), "expected `when` error, got: {err}"); + } + } } diff --git a/docs/build/dependency_types.md b/docs/build/dependency_types.md index 17e8dbef58..76874ee038 100644 --- a/docs/build/dependency_types.md +++ b/docs/build/dependency_types.md @@ -12,6 +12,45 @@ Each dependency is used at a different step of the package building process. Let's delve deeper into the various types of package dependencies and their specific roles in the build process. +### Dependency Metadata + +Package dependency tables accept the same package specification fields as regular conda dependencies, including `version`, `build`, `build-number`, `channel`, `extras`, and `flags`. +The `extras` field selects optional dependencies exposed by package metadata, while `flags` selects metadata-backed variants. + +```toml +[package.host-dependencies] +v3-package = { version = ">=1.0", extras = ["test"], flags = ["cuda"] } +``` + +Pixi build package dependencies also support `when`, which makes a dependency conditional on one or more package conditions. +Use a string for a simple package condition: + +```toml +[package.run-dependencies] +unix-helper = { version = "*", when = "__unix" } +``` + +Use `all` for logical AND and `any` for logical OR: + +```toml +[package.run-dependencies] +openssl = { version = "*", when = { all = ["__unix", "python >=3.10"] } } +fallback = { version = "*", when = { any = ["__linux", "__osx"] } } +``` + +When the condition needs a build string, use the expanded form: + +```toml +[package.run-dependencies] +cuda-helper = { version = "*", when = { all = [ + "__unix", + { package = "python", version = ">=3.10", build = "*cuda" }, +] } } +``` + +Top-level arrays are not valid for `when`, and string conditions only accept package names with optional version constraints. +Use the expanded `{ package = ..., version = ..., build = ... }` form whenever a condition needs to match a build string. + ### [Build Dependencies](../reference/pixi_manifest.md#build-dependencies) !!! note "pixi-build-cmake" When using the `pixi-build-cmake` backend you do not need to specify `cmake` or the compiler as a dependency. diff --git a/docs/concepts/package_specifications.md b/docs/concepts/package_specifications.md index 79836266d6..7c00a68463 100644 --- a/docs/concepts/package_specifications.md +++ b/docs/concepts/package_specifications.md @@ -72,10 +72,24 @@ This syntax allows you to specify: - **build**: Build string pattern (see [build strings](#build-strings)) - **build-number**: Build number constraint (e.g., `">=1"`, `"0"`) (see [build number](#build-number)) - **channel**: Specific channel name or full URL (see [channel](#channel)) +- **extras**: Optional extra dependencies exposed by the package metadata +- **flags**: Variant-selection flags exposed by package metadata - **sha256/md5**: Package checksums for verification (see [checksums](#checksums-sha256md5)) - **license**: Expected license type (see [license](#license)) - **file-name**: Specific package file name (see [file name](#file-name)) +### Extras And Flags + +Some conda packages expose optional dependency groups or variant flags in their package metadata. +Use `extras` to request optional dependencies and `flags` to select variants that are represented as package metadata instead of a separate package name. + +```toml title="pixi.toml" +[dependencies.v3-package] +version = ">=1.0" +extras = ["test"] +flags = ["cuda", "blas:*"] +``` + ### Version Operators Pixi supports various version operators: diff --git a/docs/source_files/pixi_tomls/package_specifications.toml b/docs/source_files/pixi_tomls/package_specifications.toml index c99a3f08a2..e117030453 100644 --- a/docs/source_files/pixi_tomls/package_specifications.toml +++ b/docs/source_files/pixi_tomls/package_specifications.toml @@ -24,6 +24,10 @@ version = "2.0.*" build = "cuda*" # Build number constraint build-number = ">=1" +# Optional dependency groups exposed by package metadata +extras = ["test"] +# Variant-selection flags exposed by package metadata +flags = ["cuda", "blas:*"] # Specific channel channel = "https://prefix.dev/my-channel" # Checksums diff --git a/docs/source_files/pixi_tomls/pixi-package-manifest.toml b/docs/source_files/pixi_tomls/pixi-package-manifest.toml index 4a207d8d0f..e487e8b7ff 100644 --- a/docs/source_files/pixi_tomls/pixi-package-manifest.toml +++ b/docs/source_files/pixi_tomls/pixi-package-manifest.toml @@ -38,6 +38,7 @@ python = "*" # --8<-- [start:run-dependencies] [package.run-dependencies] rich = ">=13.9.4,<14" +unix-helper = { version = "*", when = "__unix" } # --8<-- [end:run-dependencies] diff --git a/schema/model.py b/schema/model.py index fcabdda013..c7f63d5b04 100644 --- a/schema/model.py +++ b/schema/model.py @@ -269,7 +269,25 @@ class MatchspecTable(StrictBaseModel): subdir: NonEmptyStr | None = Field( None, description="The subdir of the package, also known as platform" ) + extras: list[NonEmptyStr] | None = Field( + None, + description="Optional extra dependencies to select for the package", + ) + flags: list[NonEmptyStr] | None = Field( + None, + description="Plain string flags used to select package variants", + ) license: NonEmptyStr | None = Field(None, description="The license of the package") + license_family: NonEmptyStr | None = Field( + None, description="The license family of the package" + ) + when: When | None = Field( + None, + description="The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + ) + track_features: list[NonEmptyStr] | None = Field( + None, description="The track features of the package" + ) path: NonEmptyStr | None = Field(None, description="The path to the package") @@ -300,6 +318,31 @@ class SourceSpecTable(StrictBaseModel): subdirectory: NonEmptyStr | None = Field(None, description="A subdirectory to use in the repo") +class WhenAll(StrictBaseModel): + """All conditions must apply.""" + + all: list[When] = Field( + ..., min_length=1, description="Conditions to combine with a logical AND" + ) + + +class WhenAny(StrictBaseModel): + """Any condition may apply.""" + + any: list[When] = Field( + ..., min_length=1, description="Conditions to combine with a logical OR" + ) + + +class WhenPackage(StrictBaseModel): + """Expanded package condition syntax.""" + + package: NonEmptyStr = Field(description="The package name to match") + version: NonEmptyStr | None = Field(None, description="Optional version constraint") + build: NonEmptyStr | None = Field(None, description="Optional build string matcher") + + +When = NonEmptyStr | WhenAll | WhenAny | WhenPackage MatchSpec = NonEmptyStr | MatchspecTable CondaPackageName = NonEmptyStr diff --git a/schema/pixi_build_api.json b/schema/pixi_build_api.json index 14fd1e4603..ac7e58cca3 100644 --- a/schema/pixi_build_api.json +++ b/schema/pixi_build_api.json @@ -291,6 +291,9 @@ "string", "null" ] + }, + "condition": { + "description": "The condition under which this match spec applies." } } }, diff --git a/schema/pyproject/partial-pixi.json b/schema/pyproject/partial-pixi.json index 10b065e488..c0caf98e5b 100644 --- a/schema/pyproject/partial-pixi.json +++ b/schema/pyproject/partial-pixi.json @@ -477,12 +477,30 @@ ] } }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -495,6 +513,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -543,6 +567,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -554,6 +587,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -1040,12 +1092,30 @@ "https://prefix.dev/conda-forge" ] }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -1058,6 +1128,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -1100,6 +1176,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -1111,6 +1196,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -2485,6 +2589,103 @@ } } }, + "WhenAll": { + "title": "WhenAll", + "description": "All conditions must apply.", + "type": "object", + "required": [ + "all" + ], + "additionalProperties": false, + "properties": { + "all": { + "title": "All", + "description": "Conditions to combine with a logical AND", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenAny": { + "title": "WhenAny", + "description": "Any condition may apply.", + "type": "object", + "required": [ + "any" + ], + "additionalProperties": false, + "properties": { + "any": { + "title": "Any", + "description": "Conditions to combine with a logical OR", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenPackage": { + "title": "WhenPackage", + "description": "Expanded package condition syntax.", + "type": "object", + "required": [ + "package" + ], + "additionalProperties": false, + "properties": { + "build": { + "title": "Build", + "description": "Optional build string matcher", + "type": "string", + "minLength": 1 + }, + "package": { + "title": "Package", + "description": "The package name to match", + "type": "string", + "minLength": 1 + }, + "version": { + "title": "Version", + "description": "Optional version constraint", + "type": "string", + "minLength": 1 + } + } + }, "Workspace": { "title": "Workspace", "description": "The project's metadata information.", diff --git a/schema/pyproject/schema.json b/schema/pyproject/schema.json index 3040e5a08c..95b8aca63c 100644 --- a/schema/pyproject/schema.json +++ b/schema/pyproject/schema.json @@ -220,12 +220,30 @@ ] } }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -238,6 +256,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -286,6 +310,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -297,6 +330,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -783,12 +835,30 @@ "https://prefix.dev/conda-forge" ] }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -801,6 +871,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -843,6 +919,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -854,6 +939,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -2523,6 +2627,103 @@ } } }, + "WhenAll": { + "title": "WhenAll", + "description": "All conditions must apply.", + "type": "object", + "required": [ + "all" + ], + "additionalProperties": false, + "properties": { + "all": { + "title": "All", + "description": "Conditions to combine with a logical AND", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenAny": { + "title": "WhenAny", + "description": "Any condition may apply.", + "type": "object", + "required": [ + "any" + ], + "additionalProperties": false, + "properties": { + "any": { + "title": "Any", + "description": "Conditions to combine with a logical OR", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenPackage": { + "title": "WhenPackage", + "description": "Expanded package condition syntax.", + "type": "object", + "required": [ + "package" + ], + "additionalProperties": false, + "properties": { + "build": { + "title": "Build", + "description": "Optional build string matcher", + "type": "string", + "minLength": 1 + }, + "package": { + "title": "Package", + "description": "The package name to match", + "type": "string", + "minLength": 1 + }, + "version": { + "title": "Version", + "description": "Optional version constraint", + "type": "string", + "minLength": 1 + } + } + }, "Workspace": { "title": "Workspace", "description": "The project's metadata information.", diff --git a/schema/schema.json b/schema/schema.json index 7396388457..f6e0cc5e0b 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -501,12 +501,30 @@ ] } }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -519,6 +537,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -567,6 +591,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -578,6 +611,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -1064,12 +1116,30 @@ "https://prefix.dev/conda-forge" ] }, + "extras": { + "title": "Extras", + "description": "Optional extra dependencies to select for the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "file-name": { "title": "File-Name", "description": "The file name of the package", "type": "string", "minLength": 1 }, + "flags": { + "title": "Flags", + "description": "Plain string flags used to select package variants", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "git": { "title": "Git", "description": "The git URL to the repo", @@ -1082,6 +1152,12 @@ "type": "string", "minLength": 1 }, + "license-family": { + "title": "License-Family", + "description": "The license family of the package", + "type": "string", + "minLength": 1 + }, "md5": { "title": "Md5", "description": "The md5 hash of the package", @@ -1124,6 +1200,15 @@ "type": "string", "minLength": 1 }, + "track-features": { + "title": "Track-Features", + "description": "The track features of the package", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "url": { "title": "Url", "description": "The URL to the package", @@ -1135,6 +1220,25 @@ "description": "The version of the package in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", "type": "string", "minLength": 1 + }, + "when": { + "title": "When", + "description": "The condition under which this match spec applies. Use a package string, `{ all = [...] }`, `{ any = [...] }`, or `{ package = ..., version = ..., build = ... }`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] } } }, @@ -2509,6 +2613,103 @@ } } }, + "WhenAll": { + "title": "WhenAll", + "description": "All conditions must apply.", + "type": "object", + "required": [ + "all" + ], + "additionalProperties": false, + "properties": { + "all": { + "title": "All", + "description": "Conditions to combine with a logical AND", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenAny": { + "title": "WhenAny", + "description": "Any condition may apply.", + "type": "object", + "required": [ + "any" + ], + "additionalProperties": false, + "properties": { + "any": { + "title": "Any", + "description": "Conditions to combine with a logical OR", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/WhenAll" + }, + { + "$ref": "#/$defs/WhenAny" + }, + { + "$ref": "#/$defs/WhenPackage" + } + ] + }, + "minItems": 1 + } + } + }, + "WhenPackage": { + "title": "WhenPackage", + "description": "Expanded package condition syntax.", + "type": "object", + "required": [ + "package" + ], + "additionalProperties": false, + "properties": { + "build": { + "title": "Build", + "description": "Optional build string matcher", + "type": "string", + "minLength": 1 + }, + "package": { + "title": "Package", + "description": "The package name to match", + "type": "string", + "minLength": 1 + }, + "version": { + "title": "Version", + "description": "Optional version constraint", + "type": "string", + "minLength": 1 + } + } + }, "Workspace": { "title": "Workspace", "description": "The project's metadata information.", From a99147db861377e419702eecfe790e4f10452d35 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Thu, 14 May 2026 09:47:54 +0200 Subject: [PATCH 3/3] Add when condition parsing snapshots --- crates/pixi_build_types/src/project_model.rs | 1 + ...test__when_condition_parsing_snapshot.snap | 25 +++ ...__toml__test__when_rejection_snapshot.snap | 25 +++ crates/pixi_spec/src/toml.rs | 155 ++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_condition_parsing_snapshot.snap create mode 100644 crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_rejection_snapshot.snap diff --git a/crates/pixi_build_types/src/project_model.rs b/crates/pixi_build_types/src/project_model.rs index 27d88e812c..9c38df0310 100644 --- a/crates/pixi_build_types/src/project_model.rs +++ b/crates/pixi_build_types/src/project_model.rs @@ -1008,6 +1008,7 @@ mod tests { sha256: None, url: None, license: None, + condition: None, }; let hash2 = calculate_hash(&spec2); diff --git a/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_condition_parsing_snapshot.snap b/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_condition_parsing_snapshot.snap new file mode 100644 index 0000000000..0b3562ca07 --- /dev/null +++ b/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_condition_parsing_snapshot.snap @@ -0,0 +1,25 @@ +--- +source: crates/pixi_spec/src/toml.rs +expression: snapshot +--- +- label: json_string_matchspec + input: "{\n \"version\": \"*\",\n \"when\": \"__unix\"\n}" + condition: __unix +- label: json_all + input: "{\n \"version\": \"*\",\n \"when\": {\n \"all\": [\n \"__unix\",\n \"python >=3.10\"\n ]\n }\n}" + condition: (__unix and python >=3.10) +- label: json_any + input: "{\n \"version\": \"*\",\n \"when\": {\n \"any\": [\n \"__linux\",\n \"__osx\"\n ]\n }\n}" + condition: (__linux or __osx) +- label: json_nested_all_any + input: "{\n \"version\": \"*\",\n \"when\": {\n \"all\": [\n \"__unix\",\n {\n \"any\": [\n \"__linux\",\n \"__osx\"\n ]\n }\n ]\n }\n}" + condition: (__unix and (__linux or __osx)) +- label: json_expanded_build_match + input: "{\n \"version\": \"*\",\n \"when\": {\n \"package\": \"python\",\n \"version\": \">=3.10\",\n \"build\": \"*cuda\"\n }\n}" + condition: python >=3.10 *cuda +- label: toml_all_with_expanded_build_match + input: "version = \"*\"\nwhen = { all = [\"__unix\", { package = \"python\", version = \">=3.10\", build = \"*cuda\" }] }" + condition: (__unix and python >=3.10 *cuda) +- label: toml_any + input: "version = \"*\"\nwhen = { any = [\"__linux\", \"__osx\"] }" + condition: (__linux or __osx) diff --git a/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_rejection_snapshot.snap b/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_rejection_snapshot.snap new file mode 100644 index 0000000000..646f75cc51 --- /dev/null +++ b/crates/pixi_spec/src/snapshots/pixi_spec__toml__test__when_rejection_snapshot.snap @@ -0,0 +1,25 @@ +--- +source: crates/pixi_spec/src/toml.rs +expression: snapshot +--- +- label: json_top_level_array + input: "{\n \"version\": \"*\",\n \"when\": [\n \"__unix\",\n \"python >=3.10\"\n ]\n}" + error: data did not match any variant of untagged enum TomlWhen +- label: json_bracket_matchspec + input: "{\n \"version\": \"*\",\n \"when\": \"python[version='>=3.10']\"\n}" + error: "`when` strings do not support bracket matchspec syntax; use the expanded `{ package = ..., version = ..., build = ... }` form" +- label: json_build_shorthand + input: "{\n \"version\": \"*\",\n \"when\": \"python >=3.10 *cuda\"\n}" + error: "`when` strings do not support build-string shorthand; use `{ package = ..., version = ..., build = ... }`" +- label: toml_top_level_array + input: "version = \"*\"\nwhen = [\"__unix\"]" + error: "`when` must be a string or a table with `all`, `any`, or `package`; top-level arrays are not allowed\n" +- label: toml_all_and_any + input: "version = \"*\"\nwhen = { all = [\"__unix\"], any = [\"__linux\"] }" + error: "`when` tables must contain exactly one of `all`, `any`, or `package`\n" +- label: toml_empty_all + input: "version = \"*\"\nwhen = { all = [] }" + error: "`when.all` must contain at least one condition\n" +- label: toml_empty_any + input: "version = \"*\"\nwhen = { any = [] }" + error: "`when.any` must contain at least one condition\n" diff --git a/crates/pixi_spec/src/toml.rs b/crates/pixi_spec/src/toml.rs index 060068535e..0712c3b33b 100644 --- a/crates/pixi_spec/src/toml.rs +++ b/crates/pixi_spec/src/toml.rs @@ -1062,6 +1062,28 @@ mod test { use super::*; + fn condition_string(spec: PixiSpec) -> String { + spec.as_detailed() + .expect("when is only accepted on detailed specs") + .condition + .as_ref() + .expect("expected parsed when condition") + .to_string() + } + + fn parse_json_condition(input: Value) -> Result { + serde_json::from_value::(input) + .map(condition_string) + .map_err(|err| err.to_string()) + } + + fn parse_toml_condition(input: &str) -> Result { + let mut value = toml_span::parse(input).map_err(|err| err.to_string())?; + ::deserialize(&mut value) + .map(condition_string) + .map_err(|err| err.to_string()) + } + #[test] fn test_round_trip() { let examples = [ @@ -1260,6 +1282,71 @@ mod test { ); } + #[test] + fn test_when_condition_parsing_snapshot() { + #[derive(Serialize)] + struct Snapshot { + label: &'static str, + input: String, + condition: String, + } + + let json_cases = [ + ( + "json_string_matchspec", + json!({ "version": "*", "when": "__unix" }), + ), + ( + "json_all", + json!({ "version": "*", "when": { "all": ["__unix", "python >=3.10"] } }), + ), + ( + "json_any", + json!({ "version": "*", "when": { "any": ["__linux", "__osx"] } }), + ), + ( + "json_nested_all_any", + json!({ "version": "*", "when": { "all": ["__unix", { "any": ["__linux", "__osx"] }] } }), + ), + ( + "json_expanded_build_match", + json!({ "version": "*", "when": { "package": "python", "version": ">=3.10", "build": "*cuda" } }), + ), + ]; + + let toml_cases = [ + ( + "toml_all_with_expanded_build_match", + r#"version = "*" +when = { all = ["__unix", { package = "python", version = ">=3.10", build = "*cuda" }] }"#, + ), + ( + "toml_any", + r#"version = "*" +when = { any = ["__linux", "__osx"] }"#, + ), + ]; + + let mut snapshot = Vec::new(); + for (label, input) in json_cases { + snapshot.push(Snapshot { + label, + input: serde_json::to_string_pretty(&input).unwrap(), + condition: parse_json_condition(input).unwrap(), + }); + } + + for (label, input) in toml_cases { + snapshot.push(Snapshot { + label, + input: input.trim().to_string(), + condition: parse_toml_condition(input).unwrap(), + }); + } + + insta::assert_yaml_snapshot!(snapshot); + } + #[test] fn test_when_rejects_unsupported_shorthand() { serde_json::from_value::( @@ -1293,4 +1380,72 @@ mod test { assert!(err.contains("when"), "expected `when` error, got: {err}"); } } + + #[test] + fn test_when_rejection_snapshot() { + #[derive(Serialize)] + struct Snapshot { + label: &'static str, + input: String, + error: String, + } + + let json_cases = [ + ( + "json_top_level_array", + json!({ "version": "*", "when": ["__unix", "python >=3.10"] }), + ), + ( + "json_bracket_matchspec", + json!({ "version": "*", "when": "python[version='>=3.10']" }), + ), + ( + "json_build_shorthand", + json!({ "version": "*", "when": "python >=3.10 *cuda" }), + ), + ]; + + let toml_cases = [ + ( + "toml_top_level_array", + r#"version = "*" +when = ["__unix"]"#, + ), + ( + "toml_all_and_any", + r#"version = "*" +when = { all = ["__unix"], any = ["__linux"] }"#, + ), + ( + "toml_empty_all", + r#"version = "*" +when = { all = [] }"#, + ), + ( + "toml_empty_any", + r#"version = "*" +when = { any = [] }"#, + ), + ]; + + let mut snapshot = Vec::new(); + for (label, input) in json_cases { + let error = parse_json_condition(input.clone()).unwrap_err(); + snapshot.push(Snapshot { + label, + input: serde_json::to_string_pretty(&input).unwrap(), + error, + }); + } + + for (label, input) in toml_cases { + snapshot.push(Snapshot { + label, + input: input.trim().to_string(), + error: parse_toml_condition(input).unwrap_err(), + }); + } + + insta::assert_yaml_snapshot!(snapshot); + } }