From b4b1de9d7685ca2a71db2947512a15fa53984bf4 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Thu, 2 Jul 2026 07:53:12 +0000 Subject: [PATCH 1/7] refactor(manifest): turn FeatureName into an enum --- .../pixi/tests/integration_rust/add_tests.rs | 4 +- crates/pixi_api/src/workspace/add/mod.rs | 4 +- crates/pixi_api/src/workspace/remove/mod.rs | 2 +- crates/pixi_api/src/workspace/task/mod.rs | 4 +- crates/pixi_cli/src/remove/error.rs | 14 +-- crates/pixi_core/src/workspace/environment.rs | 2 +- crates/pixi_core/src/workspace/mod.rs | 6 +- crates/pixi_manifest/src/environment.rs | 4 +- crates/pixi_manifest/src/feature.rs | 65 +++++----- .../pixi_manifest/src/manifests/document.rs | 4 +- .../pixi_manifest/src/manifests/table_name.rs | 4 +- .../pixi_manifest/src/manifests/workspace.rs | 114 +++++++++++------- crates/pixi_manifest/src/toml/manifest.rs | 17 ++- 13 files changed, 140 insertions(+), 104 deletions(-) diff --git a/crates/pixi/tests/integration_rust/add_tests.rs b/crates/pixi/tests/integration_rust/add_tests.rs index 10bfb79087..c421d6c327 100644 --- a/crates/pixi/tests/integration_rust/add_tests.rs +++ b/crates/pixi/tests/integration_rust/add_tests.rs @@ -4,7 +4,7 @@ use pep508_rs::MarkerTree; use pixi_cli::cli_config::GitRev; use pixi_consts::consts; use pixi_core::DependencyType; -use pixi_manifest::{FeaturesExt, SpecType}; +use pixi_manifest::{FeatureName, FeaturesExt, SpecType}; use pixi_pypi_spec::{PixiPypiSource, PixiPypiSpec, PypiPackageName, VersionOrStar}; use rattler_conda_types::{PackageName, Platform}; use tempfile::TempDir; @@ -633,7 +633,7 @@ async fn add_unconstrained_dependency() { let bar_spec = project .workspace .value - .feature("unreferenced") + .feature(&FeatureName::from("unreferenced")) .expect("feature 'unreferenced' is missing") .combined_dependencies(None) .unwrap_or_default() diff --git a/crates/pixi_api/src/workspace/add/mod.rs b/crates/pixi_api/src/workspace/add/mod.rs index dc44e2d5f2..023eda6526 100644 --- a/crates/pixi_api/src/workspace/add/mod.rs +++ b/crates/pixi_api/src/workspace/add/mod.rs @@ -36,7 +36,7 @@ pub async fn add_conda_dep( let pixi_platforms = resolve_platforms(&workspace_platforms, &dep_options.platforms)?; workspace .manifest() - .add_platforms(pixi_platforms.iter(), &FeatureName::DEFAULT)?; + .add_platforms(pixi_platforms.iter(), &FeatureName::Default)?; let mut match_specs = IndexMap::default(); let mut source_specs = IndexMap::default(); @@ -137,7 +137,7 @@ pub async fn add_pypi_dep( let pixi_platforms = resolve_platforms(&workspace_platforms, &options.platforms)?; workspace .manifest() - .add_platforms(pixi_platforms.iter(), &FeatureName::DEFAULT)?; + .add_platforms(pixi_platforms.iter(), &FeatureName::Default)?; // TODO: add dry_run logic to add let dry_run = false; diff --git a/crates/pixi_api/src/workspace/remove/mod.rs b/crates/pixi_api/src/workspace/remove/mod.rs index bb154a7e41..33e551183b 100644 --- a/crates/pixi_api/src/workspace/remove/mod.rs +++ b/crates/pixi_api/src/workspace/remove/mod.rs @@ -167,7 +167,7 @@ mod tests { fn options() -> DependencyOptions { DependencyOptions { - feature: FeatureName::DEFAULT, + feature: FeatureName::Default, platforms: vec![], no_install: true, lock_file_usage: LockFileUsage::Frozen, diff --git a/crates/pixi_api/src/workspace/task/mod.rs b/crates/pixi_api/src/workspace/task/mod.rs index 25c0642e60..735efabcb6 100644 --- a/crates/pixi_api/src/workspace/task/mod.rs +++ b/crates/pixi_api/src/workspace/task/mod.rs @@ -53,7 +53,7 @@ fn declare_platform_and_add_task( if let Some(p) = &pixi_platform { workspace .manifest() - .add_platforms(std::slice::from_ref(p).iter(), &FeatureName::DEFAULT)?; + .add_platforms(std::slice::from_ref(p).iter(), &FeatureName::Default)?; } workspace .manifest() @@ -151,7 +151,7 @@ pub async fn alias_task( &mut workspace, &name, &task, - &FeatureName::DEFAULT, + &FeatureName::Default, platform.as_ref(), )?; workspace.save().await.into_diagnostic()?; diff --git a/crates/pixi_cli/src/remove/error.rs b/crates/pixi_cli/src/remove/error.rs index 1fab420ab1..a85d5042f5 100644 --- a/crates/pixi_cli/src/remove/error.rs +++ b/crates/pixi_cli/src/remove/error.rs @@ -322,7 +322,7 @@ pandas = "*" &manifest, "polars", DependencyType::CondaDependency(SpecType::Run), - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -341,7 +341,7 @@ pandas = "*" &manifest, "ruff", DependencyType::PypiDependency, - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -360,7 +360,7 @@ pandas = "*" &manifest, "openssl", DependencyType::CondaDependency(SpecType::Run), - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -379,7 +379,7 @@ pandas = "*" &manifest, "cmake", DependencyType::CondaDependency(SpecType::Run), - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -399,7 +399,7 @@ pandas = "*" &manifest, "numpy", DependencyType::CondaDependency(SpecType::Run), - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -419,7 +419,7 @@ pandas = "*" &manifest, "polrs", DependencyType::PypiDependency, - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @r" @@ -438,7 +438,7 @@ pandas = "*" &manifest, "fizzbuzz", DependencyType::CondaDependency(SpecType::Run), - &FeatureName::DEFAULT, + &FeatureName::Default, &[], ), @" × dependency `fizzbuzz` was not found in dependencies" diff --git a/crates/pixi_core/src/workspace/environment.rs b/crates/pixi_core/src/workspace/environment.rs index 9d4a7236b6..3fabe58991 100644 --- a/crates/pixi_core/src/workspace/environment.rs +++ b/crates/pixi_core/src/workspace/environment.rs @@ -475,7 +475,7 @@ impl<'p> HasFeaturesIter<'p> for Environment<'p> { let environment_features = self.environment.features.iter().map(|feature_name| { manifest .features - .get(&FeatureName::from(feature_name.clone())) + .get(feature_name) .expect("feature usage should have been validated upfront") }); diff --git a/crates/pixi_core/src/workspace/mod.rs b/crates/pixi_core/src/workspace/mod.rs index 0d028e6112..5380012876 100644 --- a/crates/pixi_core/src/workspace/mod.rs +++ b/crates/pixi_core/src/workspace/mod.rs @@ -1572,21 +1572,21 @@ mod tests { workspace .workspace .value - .tasks(Some(&osx64), &FeatureName::DEFAULT) + .tasks(Some(&osx64), &FeatureName::Default) .unwrap() ); assert_debug_snapshot!( workspace .workspace .value - .tasks(Some(&win64), &FeatureName::DEFAULT) + .tasks(Some(&win64), &FeatureName::Default) .unwrap() ); assert_debug_snapshot!( workspace .workspace .value - .tasks(Some(&linux64), &FeatureName::DEFAULT) + .tasks(Some(&linux64), &FeatureName::Default) .unwrap() ); } diff --git a/crates/pixi_manifest/src/environment.rs b/crates/pixi_manifest/src/environment.rs index a51c9f5b22..9c5d806be7 100644 --- a/crates/pixi_manifest/src/environment.rs +++ b/crates/pixi_manifest/src/environment.rs @@ -11,7 +11,7 @@ use regex::Regex; use serde::{self, Deserialize, Deserializer, Serialize}; use thiserror::Error; -use crate::{consts::DEFAULT_ENVIRONMENT_NAME, solve_group::SolveGroupIdx}; +use crate::{FeatureName, consts::DEFAULT_ENVIRONMENT_NAME, solve_group::SolveGroupIdx}; #[derive(Debug, Clone, Error, Diagnostic, PartialEq)] #[error( @@ -163,7 +163,7 @@ pub struct Environment { /// /// Note that the default feature is always added to the set of features /// that make up the environment. - pub features: Vec, + pub features: Vec, /// An optional solver-group. Multiple environments can share the same /// solve-group. All the dependencies of the environment that share the diff --git a/crates/pixi_manifest/src/feature.rs b/crates/pixi_manifest/src/feature.rs index edb9dee2a3..222cce2457 100644 --- a/crates/pixi_manifest/src/feature.rs +++ b/crates/pixi_manifest/src/feature.rs @@ -12,23 +12,16 @@ use rattler_conda_types::PackageName; use serde::{Deserialize, Serialize}; use std::ops::Not; use std::{ - borrow::{Borrow, Cow}, - collections::HashSet, - convert::Infallible, - fmt, - hash::{Hash, Hasher}, - str::FromStr, + borrow::Cow, collections::HashSet, convert::Infallible, fmt, hash::Hash, str::FromStr, }; -/// The name of a feature. This is either a string or default for the default +/// The name of a feature. This is either a name or default for the default /// feature. -#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -pub struct FeatureName(Cow<'static, str>); - -impl Default for FeatureName { - fn default() -> Self { - FeatureName::DEFAULT.clone() - } +#[derive(Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash)] +pub enum FeatureName { + #[default] + Default, + Named(String), } impl Serialize for FeatureName { @@ -37,12 +30,6 @@ impl Serialize for FeatureName { } } -impl Hash for FeatureName { - fn hash(&self, state: &mut H) { - self.as_str().hash(state) - } -} - impl<'de> Deserialize<'de> for FeatureName { fn deserialize(deserializer: D) -> Result where @@ -54,27 +41,36 @@ impl<'de> Deserialize<'de> for FeatureName { impl<'s> From<&'s str> for FeatureName { fn from(value: &'s str) -> Self { - FeatureName(Cow::Owned(value.to_owned())) + if value == consts::DEFAULT_FEATURE_NAME { + FeatureName::Default + } else { + FeatureName::Named(value.to_owned()) + } } } impl From for FeatureName { fn from(value: String) -> Self { - Self(Cow::Owned(value)) + if value == consts::DEFAULT_FEATURE_NAME { + FeatureName::Default + } else { + FeatureName::Named(value) + } } } impl FeatureName { - pub const DEFAULT: Self = FeatureName(Cow::Borrowed(consts::DEFAULT_FEATURE_NAME)); - /// Returns the string representation of the feature. pub fn as_str(&self) -> &str { - &self.0 + match self { + FeatureName::Default => consts::DEFAULT_FEATURE_NAME, + FeatureName::Named(name) => name, + } } /// Returns true if the feature is the default feature. pub fn is_default(&self) -> bool { - self == &Self::DEFAULT + matches!(self, FeatureName::Default) } /// Returns the name of the feature if it is not default. @@ -83,9 +79,15 @@ impl FeatureName { } } -impl Borrow for FeatureName { - fn borrow(&self) -> &str { - self.as_str() +impl PartialEq for FeatureName { + fn eq(&self, other: &str) -> bool { + self.as_str() == other + } +} + +impl PartialEq<&str> for FeatureName { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other } } @@ -99,7 +101,10 @@ impl FromStr for FeatureName { impl From for String { fn from(name: FeatureName) -> Self { - name.0.into_owned() + match name { + FeatureName::Default => consts::DEFAULT_FEATURE_NAME.to_owned(), + FeatureName::Named(name) => name, + } } } impl<'a> From<&'a FeatureName> for String { diff --git a/crates/pixi_manifest/src/manifests/document.rs b/crates/pixi_manifest/src/manifests/document.rs index 21090585c1..8cb510e8a8 100644 --- a/crates/pixi_manifest/src/manifests/document.rs +++ b/crates/pixi_manifest/src/manifests/document.rs @@ -661,7 +661,7 @@ impl ManifestDocument { let env_table = TableName::new() .with_prefix(self.table_prefix()) - .with_feature_name(Some(&FeatureName::DEFAULT)) + .with_feature_name(Some(&FeatureName::Default)) .with_table(Some("environments")); // Insert into the environment table @@ -677,7 +677,7 @@ impl ManifestDocument { pub fn remove_environment(&mut self, name: &str) -> Result { let env_table = TableName::new() .with_prefix(self.table_prefix()) - .with_feature_name(Some(&FeatureName::DEFAULT)) + .with_feature_name(Some(&FeatureName::Default)) .with_table(Some("environments")); Ok(self diff --git a/crates/pixi_manifest/src/manifests/table_name.rs b/crates/pixi_manifest/src/manifests/table_name.rs index cfa8c105eb..d29c6ea567 100644 --- a/crates/pixi_manifest/src/manifests/table_name.rs +++ b/crates/pixi_manifest/src/manifests/table_name.rs @@ -142,7 +142,7 @@ mod tests { assert_eq!( "dependencies".to_string(), TableName::new() - .with_feature_name(Some(&FeatureName::DEFAULT)) + .with_feature_name(Some(&FeatureName::Default)) .with_table(Some("dependencies")) .to_string() ); @@ -150,7 +150,7 @@ mod tests { assert_eq!( "target.linux-64.dependencies".to_string(), TableName::new() - .with_feature_name(Some(&FeatureName::DEFAULT)) + .with_feature_name(Some(&FeatureName::Default)) .with_target(Some(TargetSelector::Subdir( rattler_conda_types::Platform::Linux64, ))) diff --git a/crates/pixi_manifest/src/manifests/workspace.rs b/crates/pixi_manifest/src/manifests/workspace.rs index 86983b28be..7769065102 100644 --- a/crates/pixi_manifest/src/manifests/workspace.rs +++ b/crates/pixi_manifest/src/manifests/workspace.rs @@ -102,7 +102,7 @@ impl WorkspaceManifest { let mut features: Vec<&Feature> = environment .features .iter() - .filter_map(|name| self.features.get(&FeatureName::from(name.clone()))) + .filter_map(|name| self.features.get(name)) .collect(); if !environment.no_default_feature { features.push(self.default_feature()); @@ -130,14 +130,14 @@ impl WorkspaceManifest { /// of the project manifest. pub fn default_feature(&self) -> &Feature { self.features - .get(&FeatureName::DEFAULT) + .get(&FeatureName::Default) .expect("default feature should always exist") } /// Returns a mutable reference to the default feature. pub(crate) fn default_feature_mut(&mut self) -> &mut Feature { self.features - .get_mut(&FeatureName::DEFAULT) + .get_mut(&FeatureName::Default) .expect("default feature should always exist") } @@ -362,7 +362,12 @@ impl WorkspaceManifestMut<'_> { ) -> miette::Result<()> { // Make sure the features exist for feature in features.iter().flatten() { - if self.workspace.features.get(feature.as_str()).is_none() { + if self + .workspace + .features + .get(&FeatureName::from(feature.as_str())) + .is_none() + { return Err(UnknownFeature::new(feature.to_string(), &*self.workspace).into()); } } @@ -376,7 +381,11 @@ impl WorkspaceManifestMut<'_> { let environment_idx = self.workspace.environments.add(Environment { name: EnvironmentName::Named(name), - features: features.unwrap_or_default(), + features: features + .unwrap_or_default() + .into_iter() + .map(FeatureName::from) + .collect(), solve_group: None, no_default_feature, }); @@ -444,15 +453,15 @@ impl WorkspaceManifestMut<'_> { .workspace .environments .iter() - .filter(|env| env.features.contains(&feature_name.to_string())) + .filter(|env| env.features.contains(feature_name)) .cloned() .collect(); for env in &environments_using_feature { - let updated_features: Vec = env + let updated_features: Vec = env .features .iter() - .filter(|f| f.as_str() != feature_name.to_string()) + .filter(|f| *f != feature_name) .cloned() .collect(); @@ -463,7 +472,12 @@ impl WorkspaceManifestMut<'_> { // Update the environment, minus the removed feature self.document.add_environment( env.name.to_string(), - Some(updated_features.clone()), + Some( + updated_features + .iter() + .map(|f| f.as_str().to_owned()) + .collect(), + ), solve_group.clone(), env.no_default_feature, )?; @@ -944,7 +958,7 @@ impl WorkspaceManifestMut<'_> { // clear-and-rebuild) so we preserve the user's quoting and spacing // for the entries that survive. self.document - .get_array_mut("platforms", &FeatureName::DEFAULT)? + .get_array_mut("platforms", &FeatureName::Default)? .retain(|item| { let entry_name = if let Some(s) = item.as_str() { Some(s) @@ -1682,7 +1696,7 @@ start = "python -m flask run --port=5050" .add_pep508_dependency( (&requirement, None), &[], - &FeatureName::DEFAULT, + &FeatureName::Default, None, DependencyOverwriteBehavior::Overwrite, None, @@ -1741,7 +1755,7 @@ start = "python -m flask run --port=5050" // Remove flask from pyproject let name = PypiPackageName::from_str("flask").unwrap(); manifest - .remove_pypi_dependency(&name, &[], &FeatureName::DEFAULT) + .remove_pypi_dependency(&name, &[], &FeatureName::Default) .unwrap(); assert!( @@ -2460,21 +2474,21 @@ foo = "1.0" "baz", SpecType::Build, &[Platform::Linux64], - &FeatureName::DEFAULT, + &FeatureName::Default, ); test_remove( file_contents, "bar", SpecType::Run, &[Platform::Win64], - &FeatureName::DEFAULT, + &FeatureName::Default, ); test_remove( file_contents, "fooz", SpecType::Run, &[], - &FeatureName::DEFAULT, + &FeatureName::Default, ); } @@ -2507,7 +2521,7 @@ foo = "1.0" &PackageName::new_unchecked("fooz"), SpecType::Run, &[], - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -2767,7 +2781,7 @@ feature_target_dep = "*" ); manifest - .add_platforms([pp(Platform::OsxArm64)].iter(), &FeatureName::DEFAULT) + .add_platforms([pp(Platform::OsxArm64)].iter(), &FeatureName::Default) .unwrap(); assert_eq!( @@ -2853,7 +2867,7 @@ platforms = [ editable .add_platforms( [PixiPlatform::from_subdir(Platform::OsxArm64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -3074,7 +3088,7 @@ platforms = ["linux-64-cuda-12-9"] ); manifest - .remove_platforms([pp(Platform::Linux64)].iter(), &FeatureName::DEFAULT) + .remove_platforms([pp(Platform::Linux64)].iter(), &FeatureName::Default) .unwrap(); assert_eq!( @@ -3164,7 +3178,7 @@ platforms = ["linux-64-cuda-12-9"] // Workspace-level remove of OsxArm64. manifest - .remove_platforms([pp(Platform::OsxArm64)].iter(), &FeatureName::DEFAULT) + .remove_platforms([pp(Platform::OsxArm64)].iter(), &FeatureName::Default) .unwrap(); assert_eq!( @@ -3290,7 +3304,7 @@ platforms = ["linux-64", "win-64"] let conda_forge = PrioritizedChannel::from(NamedChannelOrUrl::Name(String::from("conda-forge"))); manifest - .add_channels([conda_forge.clone()], &FeatureName::DEFAULT, false) + .add_channels([conda_forge.clone()], &FeatureName::Default, false) .unwrap(); let cuda_feature = FeatureName::from("cuda"); @@ -3324,7 +3338,7 @@ platforms = ["linux-64", "win-64"] // Try to add again, should not add more channels manifest - .add_channels([conda_forge.clone()], &FeatureName::DEFAULT, false) + .add_channels([conda_forge.clone()], &FeatureName::Default, false) .unwrap(); assert_eq!( @@ -3411,7 +3425,7 @@ platforms = ["linux-64", "win-64"] exclude_newer: None, }; manifest - .add_channels([custom_channel.clone()], &FeatureName::DEFAULT, false) + .add_channels([custom_channel.clone()], &FeatureName::Default, false) .unwrap(); assert!( @@ -3430,7 +3444,7 @@ platforms = ["linux-64", "win-64"] exclude_newer: None, }; manifest - .add_channels([prioritized_channel1.clone()], &FeatureName::DEFAULT, false) + .add_channels([prioritized_channel1.clone()], &FeatureName::Default, false) .unwrap(); assert!( @@ -3448,7 +3462,7 @@ platforms = ["linux-64", "win-64"] exclude_newer: None, }; manifest - .add_channels([prioritized_channel2.clone()], &FeatureName::DEFAULT, false) + .add_channels([prioritized_channel2.clone()], &FeatureName::Default, false) .unwrap(); assert!( @@ -3498,7 +3512,7 @@ platforms = ["linux-64", "win-64"] priority: None, exclude_newer: None, }], - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -3533,7 +3547,7 @@ platforms = ["linux-64", "win-64"] priority: None, exclude_newer: None, }], - &FeatureName::DEFAULT, + &FeatureName::Default, ) .is_err() ); @@ -3785,7 +3799,7 @@ test = "test initial" "default".into(), Task::Plain("echo default".into()), None, - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); let linux64 = PixiPlatform::from_subdir(Platform::Linux64); @@ -3794,7 +3808,7 @@ test = "test initial" "target_linux".into(), Task::Plain("echo target_linux".into()), Some(&linux64), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); manifest @@ -3848,7 +3862,7 @@ bar = "*" &spec, SpecType::Run, &[], - &FeatureName::DEFAULT, + &FeatureName::Default, DependencyOverwriteBehavior::Overwrite, ) .unwrap(); @@ -4106,7 +4120,12 @@ bar = "*" assert!(modified.is_empty()); // Check the feature was removed from the manifest - assert!(manifest.workspace.feature("test").is_none()); + assert!( + manifest + .workspace + .feature(&FeatureName::from("test")) + .is_none() + ); // Remove non-existent feature should succeed let result = manifest @@ -4124,12 +4143,17 @@ bar = "*" ); // Check the feature was removed from the manifest - assert!(manifest.workspace.feature("used").is_none()); + assert!( + manifest + .workspace + .feature(&FeatureName::from("used")) + .is_none() + ); // Check the environment was updated (feature removed) let env = manifest.workspace.environment("test-env").unwrap(); - assert!(!env.features.contains(&"used".to_string())); - assert!(env.features.contains(&"also-used".to_string())); + assert!(!env.features.contains(&FeatureName::from("used"))); + assert!(env.features.contains(&FeatureName::from("also-used"))); // Cannot remove default feature let result = manifest.remove_feature(&FeatureName::from_str("default").unwrap()); @@ -4172,7 +4196,7 @@ bar = "*" assert!(manifest.default_feature().channel_priority.is_none()); assert_eq!( manifest - .feature("strict") + .feature(&FeatureName::from("strict")) .unwrap() .channel_priority .unwrap(), @@ -4180,7 +4204,7 @@ bar = "*" ); assert_eq!( manifest - .feature("disabled") + .feature(&FeatureName::from("disabled")) .unwrap() .channel_priority .unwrap(), @@ -4217,7 +4241,7 @@ bar = "*" // Add pytorch channel with prepend=true let pytorch = PrioritizedChannel::from(NamedChannelOrUrl::Name(String::from("pytorch"))); manifest - .add_channels([pytorch.clone()], &FeatureName::DEFAULT, true) + .add_channels([pytorch.clone()], &FeatureName::Default, true) .unwrap(); // Verify pytorch is first in the list @@ -4236,7 +4260,7 @@ bar = "*" // Add another channel without prepend let bioconda = PrioritizedChannel::from(NamedChannelOrUrl::Name(String::from("bioconda"))); manifest - .add_channels([bioconda.clone()], &FeatureName::DEFAULT, false) + .add_channels([bioconda.clone()], &FeatureName::Default, false) .unwrap(); // Verify order is still pytorch, conda-forge, bioconda @@ -4281,7 +4305,7 @@ channels = ["nvidia", "pytorch"] PrioritizedChannel::from(NamedChannelOrUrl::Name(String::from("conda-forge"))), ]; manifest - .set_channels(new_channels, &FeatureName::DEFAULT) + .set_channels(new_channels, &FeatureName::Default) .unwrap(); // Verify channels were replaced @@ -4384,7 +4408,7 @@ channels = ["nvidia", "pytorch"] manifest .remove_platforms( [PixiPlatform::from_subdir(Platform::Linux64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -4737,7 +4761,7 @@ exclude-newer = "2015-12-02T02:07:43Z" ) .expect("rich platform with name != subdir"); editable - .add_platforms([&rich], &FeatureName::DEFAULT) + .add_platforms([&rich], &FeatureName::Default) .unwrap(); // Flag clears, legacy tables are gone, feature platforms point at the @@ -4786,7 +4810,7 @@ exclude-newer = "2015-12-02T02:07:43Z" editable .add_platforms( [PixiPlatform::from_subdir(Platform::Osx64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -4829,7 +4853,7 @@ exclude-newer = "2015-12-02T02:07:43Z" editable .add_platforms( [PixiPlatform::from_subdir(Platform::Linux64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -5045,7 +5069,7 @@ platforms = [ editable .add_platforms( [PixiPlatform::from_subdir(Platform::Linux64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); @@ -5077,7 +5101,7 @@ platforms = [ editable .remove_platforms( [PixiPlatform::from_subdir(Platform::Osx64)].iter(), - &FeatureName::DEFAULT, + &FeatureName::Default, ) .unwrap(); diff --git a/crates/pixi_manifest/src/toml/manifest.rs b/crates/pixi_manifest/src/toml/manifest.rs index cfcbc53b55..7a9259533f 100644 --- a/crates/pixi_manifest/src/toml/manifest.rs +++ b/crates/pixi_manifest/src/toml/manifest.rs @@ -305,7 +305,7 @@ impl TomlManifest { warnings.append(&mut feature_warnings); feature_sysreqs.insert(name.value.clone(), sysreqs); feature_name_to_span - .entry(name.value.clone().to_string()) + .entry(name.value.clone()) .or_insert(name.span); Ok((name.value, feature)) }) @@ -360,6 +360,13 @@ impl TomlManifest { (features.value, Some(features.span), None, false) } }; + let included_features: Vec> = included_features + .into_iter() + .map(|Spanned { value, span }| Spanned { + value: FeatureName::from(value), + span, + }) + .collect(); features_used_by_environments .extend(included_features.iter().map(|span| span.value.clone())); @@ -373,7 +380,7 @@ impl TomlManifest { span, } in &included_features { - let Some(feature) = features.get(feature_name.as_str()) else { + let Some(feature) = features.get(feature_name) else { return Err(TomlError::from( GenericError::new(format!( "The feature '{feature_name}' is not defined in the manifest", @@ -400,7 +407,7 @@ impl TomlManifest { if !no_default_feature { used_features.push( features - .get(&FeatureName::DEFAULT) + .get(&FeatureName::Default) .expect("default feature must exist"), ); }; @@ -1036,7 +1043,7 @@ mod test { // it now points at the synthesised platforms rather than `None`. let default = workspace_manifest .features - .get(&FeatureName::DEFAULT) + .get(&FeatureName::Default) .unwrap(); let mut default_platforms: Vec<&str> = default .platforms @@ -2199,7 +2206,7 @@ mod test { // Extra feature should have develop dependencies let extra_feature = manifest - .feature("extra") + .feature(&FeatureName::from("extra")) .expect("extra feature should exist"); let dev_deps = extra_feature .dev_dependencies(None) From eaf047e3ce9f640742619e43e4c87218ea7924da Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Wed, 1 Jul 2026 09:12:46 +0000 Subject: [PATCH 2/7] feat(manifest): add env: feature namespace to FeatureName --- crates/pixi_consts/src/consts.rs | 5 + crates/pixi_manifest/src/feature.rs | 127 +++++++++++++++++++--- crates/pixi_manifest/src/toml/manifest.rs | 23 ++++ 3 files changed, 141 insertions(+), 14 deletions(-) diff --git a/crates/pixi_consts/src/consts.rs b/crates/pixi_consts/src/consts.rs index 25a4385d3d..ecae7e63e8 100644 --- a/crates/pixi_consts/src/consts.rs +++ b/crates/pixi_consts/src/consts.rs @@ -9,6 +9,11 @@ use url::Url; pub const DEFAULT_ENVIRONMENT_NAME: &str = "default"; pub const DEFAULT_FEATURE_NAME: &str = DEFAULT_ENVIRONMENT_NAME; + +/// Prefix of the implicit feature that is synthesized for an environment which +/// defines dependencies inline. This prefix is reserved: user-defined features +/// may not start with it. +pub const ENVIRONMENT_FEATURE_PREFIX: &str = "env:"; pub const PYPROJECT_PIXI_PREFIX: &str = "tool.pixi"; pub const WORKSPACE_MANIFEST: &str = "pixi.toml"; diff --git a/crates/pixi_manifest/src/feature.rs b/crates/pixi_manifest/src/feature.rs index 222cce2457..09f3cf90e4 100644 --- a/crates/pixi_manifest/src/feature.rs +++ b/crates/pixi_manifest/src/feature.rs @@ -1,6 +1,6 @@ use crate::{ - CondaConstraints, SpecType, WorkspaceTarget, channel::PrioritizedChannel, consts, - pypi::pypi_options::PypiOptions, target::Targets, workspace::ChannelPriority, + CondaConstraints, EnvironmentName, SpecType, WorkspaceTarget, channel::PrioritizedChannel, + consts, pypi::pypi_options::PypiOptions, target::Targets, workspace::ChannelPriority, workspace::SolveStrategy, }; use crate::{InlinePackageManifest, PixiPlatform, PixiPlatformName}; @@ -11,9 +11,7 @@ use pixi_spec_containers::DependencyMap; use rattler_conda_types::PackageName; use serde::{Deserialize, Serialize}; use std::ops::Not; -use std::{ - borrow::Cow, collections::HashSet, convert::Infallible, fmt, hash::Hash, str::FromStr, -}; +use std::{borrow::Cow, collections::HashSet, fmt, hash::Hash, str::FromStr}; /// The name of a feature. This is either a name or default for the default /// feature. @@ -22,11 +20,16 @@ pub enum FeatureName { #[default] Default, Named(String), + /// The implicit feature that is synthesized for an environment which + /// defines feature content (like dependencies) inline. It is displayed + /// with the reserved `env:` prefix, e.g. `env:dev` for the environment + /// `dev`. + Environment(EnvironmentName), } impl Serialize for FeatureName { fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.as_str()) + serializer.collect_str(self) } } @@ -41,11 +44,7 @@ impl<'de> Deserialize<'de> for FeatureName { impl<'s> From<&'s str> for FeatureName { fn from(value: &'s str) -> Self { - if value == consts::DEFAULT_FEATURE_NAME { - FeatureName::Default - } else { - FeatureName::Named(value.to_owned()) - } + FeatureName::from(value.to_owned()) } } @@ -53,6 +52,11 @@ impl From for FeatureName { fn from(value: String) -> Self { if value == consts::DEFAULT_FEATURE_NAME { FeatureName::Default + } else if let Some(environment_name) = value + .strip_prefix(consts::ENVIRONMENT_FEATURE_PREFIX) + .and_then(|name| EnvironmentName::from_str(name).ok()) + { + FeatureName::Environment(environment_name) } else { FeatureName::Named(value) } @@ -60,11 +64,22 @@ impl From for FeatureName { } impl FeatureName { + /// Constructs the implicit feature name for the inline content of an + /// environment, e.g. `env:dev` for the environment `dev`. + pub fn environment(name: &EnvironmentName) -> Self { + FeatureName::Environment(name.clone()) + } + /// Returns the string representation of the feature. + /// + /// For an environment feature this is the bare environment name; use the + /// [`fmt::Display`] implementation to get the canonical representation + /// including the `env:` prefix. pub fn as_str(&self) -> &str { match self { FeatureName::Default => consts::DEFAULT_FEATURE_NAME, FeatureName::Named(name) => name, + FeatureName::Environment(name) => name.as_str(), } } @@ -73,10 +88,45 @@ impl FeatureName { matches!(self, FeatureName::Default) } + /// Returns true if this is an implicit feature synthesized for an + /// environment that defines feature content inline. + pub fn is_environment(&self) -> bool { + matches!(self, FeatureName::Environment(_)) + } + + /// Returns the name of the environment this feature was synthesized for, if + /// it is an environment feature. + pub fn environment_name(&self) -> Option<&EnvironmentName> { + match self { + FeatureName::Environment(name) => Some(name), + _ => None, + } + } + /// Returns the name of the feature if it is not default. pub fn non_default(&self) -> Option<&str> { self.is_default().not().then(|| self.as_str()) } + + /// Renders the feature for user-facing diagnostics, describing an + /// environment feature as `environment ''` and any other feature as + /// `feature ''`. + pub fn user_facing(&self) -> UserFacingFeatureName<'_> { + UserFacingFeatureName(self) + } +} + +/// Helper returned by [`FeatureName::user_facing`] that renders a feature name +/// for diagnostics without exposing the internal `env:` prefix. +pub struct UserFacingFeatureName<'a>(&'a FeatureName); + +impl fmt::Display for UserFacingFeatureName<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + FeatureName::Environment(name) => write!(f, "environment '{name}'"), + _ => write!(f, "feature '{}'", self.0.as_str()), + } + } } impl PartialEq for FeatureName { @@ -92,29 +142,47 @@ impl PartialEq<&str> for FeatureName { } impl FromStr for FeatureName { - type Err = Infallible; + type Err = ParseFeatureNameError; fn from_str(s: &str) -> Result { + if s.starts_with(consts::ENVIRONMENT_FEATURE_PREFIX) { + return Err(ParseFeatureNameError); + } Ok(FeatureName::from(s)) } } +/// Error returned when parsing a [`FeatureName`] from user input that uses the +/// reserved `env:` prefix. +#[derive(Debug, Clone, thiserror::Error)] +#[error( + "feature names starting with '{}' are reserved for environments that define their content inline", + consts::ENVIRONMENT_FEATURE_PREFIX +)] +pub struct ParseFeatureNameError; + impl From for String { fn from(name: FeatureName) -> Self { match name { FeatureName::Default => consts::DEFAULT_FEATURE_NAME.to_owned(), FeatureName::Named(name) => name, + FeatureName::Environment(_) => name.to_string(), } } } impl<'a> From<&'a FeatureName> for String { fn from(name: &'a FeatureName) -> Self { - name.as_str().to_owned() + name.to_string() } } impl fmt::Display for FeatureName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) + match self { + FeatureName::Environment(name) => { + write!(f, "{}{}", consts::ENVIRONMENT_FEATURE_PREFIX, name.as_str()) + } + _ => write!(f, "{}", self.as_str()), + } } } @@ -511,6 +579,37 @@ mod tests { use super::*; use crate::WorkspaceManifest; + #[test] + fn test_environment_feature_name() { + let environment = EnvironmentName::Named("dev".to_string()); + let name = FeatureName::environment(&environment); + assert_eq!(name.to_string(), "env:dev"); + assert_eq!(name.as_str(), "dev"); + assert!(name.is_environment()); + assert!(!name.is_default()); + assert_eq!(name.environment_name(), Some(&environment)); + assert_eq!(name.user_facing().to_string(), "environment 'dev'"); + + // The canonical representation round-trips through `From`. + assert_eq!(FeatureName::from(name.to_string()), name); + // User input cannot use the reserved prefix. + assert!("env:dev".parse::().is_err()); + } + + #[test] + fn test_regular_feature_name_is_not_environment() { + let name = FeatureName::from("dev"); + assert!(!name.is_environment()); + assert_eq!(name.environment_name(), None); + assert_eq!(name.user_facing().to_string(), "feature 'dev'"); + + // A feature whose name merely starts with `env` (but not `env:`) is a + // normal feature. + let name = FeatureName::from("environment"); + assert!(!name.is_environment()); + assert_eq!(name.environment_name(), None); + } + #[test] fn test_dependencies_borrowed() { let manifest = WorkspaceManifest::from_toml_str_with_base_dir( diff --git a/crates/pixi_manifest/src/toml/manifest.rs b/crates/pixi_manifest/src/toml/manifest.rs index 7a9259533f..ce35b0d722 100644 --- a/crates/pixi_manifest/src/toml/manifest.rs +++ b/crates/pixi_manifest/src/toml/manifest.rs @@ -2102,6 +2102,29 @@ mod test { )); } + #[test] + fn test_reserved_env_feature_name() { + assert_snapshot!(expect_parse_failure( + r#" + [workspace] + name = "foo" + channels = [] + platforms = [] + + [feature."env:dev".dependencies] + git = "*" + "#, + ), @r###" + × feature names starting with 'env:' are reserved for environments that define their content inline + ╭─[pixi.toml:7:19] + 6 │ + 7 │ [feature."env:dev".dependencies] + · ─────── + 8 │ git = "*" + ╰──── + "###); + } + #[test] fn test_parse_dev_path() { let manifest = WorkspaceManifest::from_toml_str_with_base_dir( From 25f96ee4265b41f57948356a988089e8ae542703 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Wed, 1 Jul 2026 10:29:38 +0000 Subject: [PATCH 3/7] feat(manifest): synthesize env feature from inline environment dependencies --- crates/pixi_manifest/src/toml/environment.rs | 69 ++++- crates/pixi_manifest/src/toml/feature.rs | 2 +- crates/pixi_manifest/src/toml/manifest.rs | 267 ++++++++++++++++-- ...ment__test__parse_invalid_environment.snap | 3 +- ...ment__test__parse_invalid_solve_group.snap | 3 +- 5 files changed, 319 insertions(+), 25 deletions(-) diff --git a/crates/pixi_manifest/src/toml/environment.rs b/crates/pixi_manifest/src/toml/environment.rs index 865241338e..87edd53182 100644 --- a/crates/pixi_manifest/src/toml/environment.rs +++ b/crates/pixi_manifest/src/toml/environment.rs @@ -1,5 +1,13 @@ +use std::path::Path; + use toml_span::{DeserError, Spanned, Value, de_helpers::expected}; +use crate::{ + Feature, FeatureName, TomlError, WithWarnings, + toml::{TomlFeature, TomlWorkspace, WorkspacePackageProperties, preview::TomlPreview}, + utils::{PixiSpanned, package_map::DependencyTable}, +}; + /// Helper struct to deserialize the environment from TOML. /// The environment description can only hold these values. #[derive(Debug)] @@ -7,11 +15,60 @@ pub struct TomlEnvironment { pub features: Option>>>, pub solve_group: Option, pub no_default_feature: bool, + /// Feature content defined directly on the environment. This is turned into + /// an implicit feature that is prepended to the environment's features. + pub inline: TomlEnvironmentInline, +} + +/// The feature content that can be defined directly on an environment. This is +/// the same content as a regular feature, minus the fields that only make sense +/// on a feature (`host-dependencies`, `build-dependencies` and +/// `system-requirements`). +#[derive(Debug, Default)] +pub struct TomlEnvironmentInline { + pub dependencies: Option>, +} + +impl TomlEnvironmentInline { + /// Returns true if the environment does not define any inline feature + /// content, in which case no implicit feature needs to be synthesized. + pub fn is_empty(&self) -> bool { + let Self { dependencies } = self; + dependencies.is_none() + } + + /// Builds the implicit feature that carries the environment's inline + /// content. + pub fn into_feature( + self, + name: FeatureName, + preview: &TomlPreview, + workspace: &TomlWorkspace, + workspace_package_properties: &WorkspacePackageProperties, + root_directory: &Path, + ) -> Result, TomlError> { + let Self { dependencies } = self; + let WithWarnings { + value: (feature, _system_requirements), + warnings, + } = TomlFeature { + dependencies, + ..TomlFeature::default() + } + .into_feature( + name, + preview, + workspace, + workspace_package_properties, + root_directory, + )?; + Ok(WithWarnings::from(feature).with_warnings(warnings)) + } } #[derive(Debug)] pub enum TomlEnvironmentList { - Map(TomlEnvironment), + Map(Box), Seq(Spanned>>), } @@ -22,10 +79,13 @@ impl<'de> toml_span::Deserialize<'de> for TomlEnvironment { let features = th.optional_s("features"); let solve_group = th.optional("solve-group"); let no_default_feature = th.optional("no-default-feature"); + let dependencies = th.optional("dependencies"); th.finalize(None)?; - if features.is_none() && solve_group.is_none() { + let inline = TomlEnvironmentInline { dependencies }; + + if features.is_none() && solve_group.is_none() && inline.is_empty() { return Err(DeserError::from(toml_span::Error { kind: toml_span::ErrorKind::MissingField("features"), span: value.span, @@ -37,6 +97,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlEnvironment { features, solve_group, no_default_feature: no_default_feature.unwrap_or_default(), + inline, }) } } @@ -48,9 +109,9 @@ impl<'de> toml_span::Deserialize<'de> for TomlEnvironmentList { toml_span::Deserialize::deserialize(value)?, )) } else if value.as_table().is_some() { - Ok(TomlEnvironmentList::Map( + Ok(TomlEnvironmentList::Map(Box::new( toml_span::Deserialize::deserialize(value)?, - )) + ))) } else { Err(expected("either a map or a sequence", value.take(), value.span).into()) } diff --git a/crates/pixi_manifest/src/toml/feature.rs b/crates/pixi_manifest/src/toml/feature.rs index 7e8af1b4fa..af1771e854 100644 --- a/crates/pixi_manifest/src/toml/feature.rs +++ b/crates/pixi_manifest/src/toml/feature.rs @@ -22,7 +22,7 @@ use crate::{ }; use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName}; -#[derive(Debug)] +#[derive(Debug, Default)] pub struct TomlFeature { pub platforms: Option>>, pub channels: Option>, diff --git a/crates/pixi_manifest/src/toml/manifest.rs b/crates/pixi_manifest/src/toml/manifest.rs index ce35b0d722..e0aa102830 100644 --- a/crates/pixi_manifest/src/toml/manifest.rs +++ b/crates/pixi_manifest/src/toml/manifest.rs @@ -22,6 +22,7 @@ use crate::{ Activation, Environment, EnvironmentName, Environments, Feature, FeatureName, KnownPreviewFeature, PixiPlatform, PixiPlatformName, SolveGroups, SystemRequirements, TargetSelector, Targets, Task, TaskName, TomlError, Warning, WithWarnings, WorkspaceManifest, + consts, environment::EnvironmentIdx, error::{FeatureNotEnabled, GenericError}, manifests::PackageManifest, @@ -29,7 +30,8 @@ use crate::{ toml::{ PackageDefaults, PlatformSpan, TomlFeature, TomlPackage, TomlTarget, TomlWorkspace, WorkspacePackageProperties, create_unsupported_selector_warning, - environment::TomlEnvironmentList, task::TomlTask, + environment::{TomlEnvironment, TomlEnvironmentList}, + task::TomlTask, }, utils::{ PixiSpanned, @@ -343,22 +345,62 @@ impl TomlManifest { let mut features_used_by_environments = HashSet::new(); for (name, env) in toml_environments { // Decompose the TOML - let (included_features, features_span, solve_group, no_default_feature) = match env { - TomlEnvironmentList::Map(env) => { - let (features, features_span) = env.features.map_or_else( - || (Vec::new(), None), - |Spanned { value, span }| (value, Some(span)), - ); - ( - features, - features_span, - env.solve_group, - env.no_default_feature, - ) - } - TomlEnvironmentList::Seq(features) => { - (features.value, Some(features.span), None, false) + let (inline, included_features, features_span, solve_group, no_default_feature) = + match env { + TomlEnvironmentList::Map(env) => { + let TomlEnvironment { + features, + solve_group, + no_default_feature, + inline, + } = *env; + let (features, features_span) = features.map_or_else( + || (Vec::new(), None), + |Spanned { value, span }| (value, Some(span)), + ); + ( + Some(inline), + features, + features_span, + solve_group, + no_default_feature, + ) + } + TomlEnvironmentList::Seq(features) => { + (None, features.value, Some(features.span), None, false) + } + }; + + // Synthesize the implicit feature that carries the environment's + // inline content and prepend it to the environment's features. + let inline_feature_name = match inline { + Some(inline) if !inline.is_empty() => { + if name.is_default() { + return Err(TomlError::from( + GenericError::new( + "The 'default' environment cannot define dependencies inline", + ) + .with_help( + "Add these dependencies to the top-level tables (for example '[dependencies]'); they are part of the default environment.", + ), + )); + } + let feature_name = FeatureName::environment(&name); + let WithWarnings { + value: feature, + warnings: mut feature_warnings, + } = inline.into_feature( + feature_name.clone(), + preview, + &workspace.value, + &inline_workspace_properties, + root_directory, + )?; + warnings.append(&mut feature_warnings); + features.insert(feature_name.clone(), feature); + Some(feature_name) } + _ => None, }; let included_features: Vec> = included_features .into_iter() @@ -374,12 +416,33 @@ impl TomlManifest { // Verify that the features of the environment actually exist and that they are // not defined twice. let mut features_seen_where = HashMap::new(); - let mut used_features = Vec::with_capacity(included_features.len()); + let mut used_features = Vec::with_capacity(included_features.len() + 1); + if let Some(feature_name) = &inline_feature_name { + used_features.push( + features + .get(feature_name) + .expect("the inline feature was just inserted"), + ); + } for Spanned { value: feature_name, span, } in &included_features { + if feature_name.is_environment() + || feature_name + .as_str() + .starts_with(consts::ENVIRONMENT_FEATURE_PREFIX) + { + return Err(TomlError::from( + GenericError::new(format!( + "The feature '{feature_name}' cannot be referenced: names starting with '{}' refer to content defined inline on an environment", + consts::ENVIRONMENT_FEATURE_PREFIX, + )) + .with_span((*span).into()) + .with_help("Content defined inline on an environment is private to that environment. Define a named feature to share content between environments."), + )); + } let Some(feature) = features.get(feature_name) else { return Err(TomlError::from( GenericError::new(format!( @@ -445,11 +508,17 @@ impl TomlManifest { )); } + let mut feature_names: Vec = + included_features.into_iter().map(Spanned::take).collect(); + if let Some(feature_name) = inline_feature_name { + feature_names.insert(0, feature_name); + } + let environment_idx = EnvironmentIdx(environments.environments.len()); environments.by_name.insert(name.clone(), environment_idx); environments.environments.push(Some(Environment { name, - features: included_features.into_iter().map(Spanned::take).collect(), + features: feature_names, solve_group: solve_group.map(|sg| solve_groups.add(sg, environment_idx)), no_default_feature, })); @@ -2125,6 +2194,168 @@ mod test { "###); } + #[test] + fn test_environment_inline_dependencies() { + let manifest = WorkspaceManifest::from_toml_str_with_base_dir( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [environments.dev.dependencies] + git = "*" + "#, + Path::new(""), + ) + .unwrap(); + + // Defining dependencies inline auto-declares the environment and + // prepends the implicit `env:dev` feature to its feature list. + let env_feature = FeatureName::environment(&EnvironmentName::Named("dev".to_string())); + let dev = manifest.environment("dev").expect("dev environment exists"); + assert_eq!(dev.features, vec![env_feature.clone()]); + + // The implicit feature carries the inline dependency. + let feature = manifest + .feature(&env_feature) + .expect("the inline feature exists"); + let deps = feature.dependencies(crate::SpecType::Run, None).unwrap(); + assert!(deps.contains_key("git")); + } + + #[test] + fn test_environment_inline_dependencies_with_features() { + let manifest = WorkspaceManifest::from_toml_str_with_base_dir( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [feature.python.dependencies] + python = "*" + + [environments.dev] + features = ["python"] + dependencies = { git = "*" } + "#, + Path::new(""), + ) + .unwrap(); + + // The implicit feature is prepended before the referenced features. + let dev = manifest.environment("dev").expect("dev environment exists"); + assert_eq!( + dev.features, + vec![ + FeatureName::environment(&EnvironmentName::Named("dev".to_string())), + FeatureName::from("python"), + ] + ); + } + + #[test] + fn test_environment_without_inline_content_has_no_feature() { + let manifest = WorkspaceManifest::from_toml_str_with_base_dir( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [feature.python.dependencies] + python = "*" + + [environments] + dev = { features = ["python"] } + "#, + Path::new(""), + ) + .unwrap(); + + // A purely compositional environment does not synthesize a feature. + assert!( + manifest + .feature(&FeatureName::environment(&EnvironmentName::Named( + "dev".to_string() + ))) + .is_none() + ); + let dev = manifest.environment("dev").expect("dev environment exists"); + assert_eq!(dev.features, vec![FeatureName::from("python")]); + } + + #[test] + fn test_environment_default_inline_dependencies_rejected() { + assert_snapshot!(expect_parse_failure( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [environments.default.dependencies] + git = "*" + "#, + ), @r###" + × The 'default' environment cannot define dependencies inline + help: Add these dependencies to the top-level tables (for example '[dependencies]'); they are part of the default environment. + "###); + } + + #[test] + fn test_environment_feature_reference_rejected() { + assert_snapshot!(expect_parse_failure( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [environments.dev.dependencies] + git = "*" + + [environments.test] + features = ["env:dev"] + "#, + ), @r###" + × The feature 'env:dev' cannot be referenced: names starting with 'env:' refer to content defined inline on an environment + ╭─[pixi.toml:11:22] + 10 │ [environments.test] + 11 │ features = ["env:dev"] + · ─────── + 12 │ + ╰──── + help: Content defined inline on an environment is private to that environment. Define a named feature to share content between environments. + "###); + } + + #[test] + fn test_environment_feature_self_reference_rejected() { + assert_snapshot!(expect_parse_failure( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [environments.dev] + features = ["env:dev"] + dependencies = { git = "*" } + "#, + ), @r###" + × The feature 'env:dev' cannot be referenced: names starting with 'env:' refer to content defined inline on an environment + ╭─[pixi.toml:8:22] + 7 │ [environments.dev] + 8 │ features = ["env:dev"] + · ─────── + 9 │ dependencies = { git = "*" } + ╰──── + help: Content defined inline on an environment is private to that environment. Define a named feature to share content between environments. + "###); + } + #[test] fn test_parse_dev_path() { let manifest = WorkspaceManifest::from_toml_str_with_base_dir( diff --git a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap index 09381bace8..d74ff893c3 100644 --- a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap +++ b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap @@ -1,8 +1,9 @@ --- source: crates/pixi_manifest/src/toml/environment.rs +assertion_line: 167 expression: "format_parse_error(input, TopLevel::from_toml_str(input).unwrap_err())" --- - × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature' + × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'dependencies' ╭─[pixi.toml:2:21] 1 │ 2 │ env = { feat = ["foo", "bar"] } diff --git a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap index 50573ab045..48d29e8141 100644 --- a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap +++ b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap @@ -1,8 +1,9 @@ --- source: crates/pixi_manifest/src/toml/environment.rs +assertion_line: 203 expression: "format_parse_error(input, TopLevel::from_toml_str(input).unwrap_err())" --- - × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature' + × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'dependencies' ╭─[pixi.toml:2:36] 1 │ 2 │ env = { features = [], solve_groups = "group" } From 12b82a5e494f59a672c1442b6c08ada0fa09b4b1 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Wed, 1 Jul 2026 10:47:16 +0000 Subject: [PATCH 4/7] feat(manifest): support full feature content on inline environments --- crates/pixi_manifest/src/toml/environment.rs | 149 ++++++++++++++++-- crates/pixi_manifest/src/toml/manifest.rs | 66 ++++++++ ...ment__test__parse_invalid_environment.snap | 4 +- ...ment__test__parse_invalid_solve_group.snap | 4 +- 4 files changed, 210 insertions(+), 13 deletions(-) diff --git a/crates/pixi_manifest/src/toml/environment.rs b/crates/pixi_manifest/src/toml/environment.rs index 87edd53182..21894470ac 100644 --- a/crates/pixi_manifest/src/toml/environment.rs +++ b/crates/pixi_manifest/src/toml/environment.rs @@ -1,11 +1,23 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; +use indexmap::{IndexMap, IndexSet}; +use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName}; +use pixi_toml::{Same, TomlHashMap, TomlIndexMap, TomlIndexSet, TomlWith}; use toml_span::{DeserError, Spanned, Value, de_helpers::expected}; use crate::{ - Feature, FeatureName, TomlError, WithWarnings, - toml::{TomlFeature, TomlWorkspace, WorkspacePackageProperties, preview::TomlPreview}, - utils::{PixiSpanned, package_map::DependencyTable}, + Activation, Feature, FeatureName, SystemRequirements, TargetSelector, Task, TaskName, + TomlError, Warning, WithWarnings, + pypi::pypi_options::PypiOptions, + toml::{ + TomlFeature, TomlPrioritizedChannel, TomlTarget, TomlWorkspace, WorkspacePackageProperties, + preview::TomlPreview, task::TomlTask, + }, + utils::{ + PixiSpanned, + package_map::{DependencyTable, UniquePackageMap}, + }, + workspace::{ChannelPriority, SolveStrategy}, }; /// Helper struct to deserialize the environment from TOML. @@ -26,15 +38,53 @@ pub struct TomlEnvironment { /// `system-requirements`). #[derive(Debug, Default)] pub struct TomlEnvironmentInline { + pub platforms: Option>>, + pub channels: Option>, + pub channel_priority: Option, + pub solve_strategy: Option, + pub target: IndexMap, TomlTarget>, pub dependencies: Option>, + pub pypi_dependencies: Option>, + pub dev_dependencies: + Option>, + pub constraints: Option>, + pub activation: Option, + pub tasks: HashMap, + pub pypi_options: Option, + pub warnings: Vec, } impl TomlEnvironmentInline { /// Returns true if the environment does not define any inline feature /// content, in which case no implicit feature needs to be synthesized. pub fn is_empty(&self) -> bool { - let Self { dependencies } = self; - dependencies.is_none() + let Self { + platforms, + channels, + channel_priority, + solve_strategy, + target, + dependencies, + pypi_dependencies, + dev_dependencies, + constraints, + activation, + tasks, + pypi_options, + warnings: _, + } = self; + platforms.is_none() + && channels.is_none() + && channel_priority.is_none() + && solve_strategy.is_none() + && target.is_empty() + && dependencies.is_none() + && pypi_dependencies.is_none() + && dev_dependencies.is_none() + && constraints.is_none() + && activation.is_none() + && tasks.is_empty() + && pypi_options.is_none() } /// Builds the implicit feature that carries the environment's inline @@ -47,13 +97,41 @@ impl TomlEnvironmentInline { workspace_package_properties: &WorkspacePackageProperties, root_directory: &Path, ) -> Result, TomlError> { - let Self { dependencies } = self; + let Self { + platforms, + channels, + channel_priority, + solve_strategy, + target, + dependencies, + pypi_dependencies, + dev_dependencies, + constraints, + activation, + tasks, + pypi_options, + warnings, + } = self; let WithWarnings { value: (feature, _system_requirements), warnings, } = TomlFeature { + platforms, + channels, + channel_priority, + solve_strategy, + target, dependencies, - ..TomlFeature::default() + pypi_dependencies, + dev: dev_dependencies, + constraints, + activation, + tasks, + pypi_options, + host_dependencies: None, + build_dependencies: None, + system_requirements: SystemRequirements::default(), + warnings, } .into_feature( name, @@ -76,14 +154,67 @@ impl<'de> toml_span::Deserialize<'de> for TomlEnvironment { fn deserialize(value: &mut Value<'de>) -> Result { let mut th = toml_span::de_helpers::TableHelper::new(value)?; + let mut warnings = Vec::new(); + let features = th.optional_s("features"); let solve_group = th.optional("solve-group"); let no_default_feature = th.optional("no-default-feature"); + + // Inline feature content. `host-dependencies`, `build-dependencies` and + // `system-requirements` are intentionally not accepted here and are + // rejected by `finalize` below. + let platforms = th + .optional::>>>("platforms") + .map(TomlWith::into_inner); + let channels = th.optional("channels"); + let channel_priority = th.optional("channel-priority"); + let solve_strategy = th.optional("solve-strategy"); + let target = th + .optional::>("target") + .map(TomlIndexMap::into_inner) + .unwrap_or_default(); let dependencies = th.optional("dependencies"); + let pypi_dependencies = th + .optional::>("pypi-dependencies") + .map(TomlIndexMap::into_inner); + let dev_dependencies = th + .optional::>("dev") + .map(TomlIndexMap::into_inner); + let constraints = th.optional("constraints"); + let activation = th.optional("activation"); + let tasks = th + .optional::>("tasks") + .map(TomlHashMap::into_inner) + .unwrap_or_default() + .into_iter() + .map(|(key, value)| { + let WithWarnings { + value: task, + warnings: mut task_warnings, + } = value; + warnings.append(&mut task_warnings); + (key, task) + }) + .collect(); + let pypi_options = th.optional("pypi-options"); th.finalize(None)?; - let inline = TomlEnvironmentInline { dependencies }; + let inline = TomlEnvironmentInline { + platforms, + channels, + channel_priority, + solve_strategy, + target, + dependencies, + pypi_dependencies, + dev_dependencies, + constraints, + activation, + tasks, + pypi_options, + warnings, + }; if features.is_none() && solve_group.is_none() && inline.is_empty() { return Err(DeserError::from(toml_span::Error { diff --git a/crates/pixi_manifest/src/toml/manifest.rs b/crates/pixi_manifest/src/toml/manifest.rs index e0aa102830..d046c4328f 100644 --- a/crates/pixi_manifest/src/toml/manifest.rs +++ b/crates/pixi_manifest/src/toml/manifest.rs @@ -2304,6 +2304,72 @@ mod test { "###); } + #[test] + fn test_environment_inline_full_content() { + let manifest = WorkspaceManifest::from_toml_str_with_base_dir( + r#" + [workspace] + name = "foo" + channels = ["conda-forge"] + platforms = ["linux-64", "osx-64"] + + [environments.dev] + channels = ["bioconda"] + platforms = ["linux-64"] + dependencies = { git = "*" } + pypi-dependencies = { requests = "*" } + + [environments.dev.tasks] + greet = "echo hi" + "#, + Path::new(""), + ) + .unwrap(); + + let feature = manifest + .feature(&FeatureName::environment(&EnvironmentName::Named( + "dev".to_string(), + ))) + .expect("the inline feature exists"); + + assert!( + feature + .dependencies(crate::SpecType::Run, None) + .unwrap() + .contains_key("git") + ); + assert!(!feature.pypi_dependencies(None).unwrap().is_empty()); + assert!(feature.channels.is_some()); + assert!(feature.platforms.is_some()); + assert_eq!(feature.targets.default().tasks.len(), 1); + } + + #[test] + fn test_environment_inline_host_dependencies_rejected() { + assert_snapshot!(expect_parse_failure( + r#" + [workspace] + name = "foo" + channels = [] + platforms = ["linux-64"] + + [environments.dev.host-dependencies] + git = "*" + "#, + ), @r###" + × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'platforms', 'channels', 'channel-priority', 'solve-strategy', 'target', 'dependencies', 'pypi-dependencies', + │ 'dev', 'constraints', 'activation', 'tasks', 'pypi-options' + ╭─[pixi.toml:7:27] + 6 │ + 7 │ [environments.dev.host-dependencies] + · ────────┬──────── + · ╰── 'host-dependencies' was not expected here + 8 │ git = "*" + ╰──── + help: Did you mean 'dependencies'? + "###); + } + #[test] fn test_environment_feature_reference_rejected() { assert_snapshot!(expect_parse_failure( diff --git a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap index d74ff893c3..e8b286dc5b 100644 --- a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap +++ b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_environment.snap @@ -1,9 +1,9 @@ --- source: crates/pixi_manifest/src/toml/environment.rs -assertion_line: 167 expression: "format_parse_error(input, TopLevel::from_toml_str(input).unwrap_err())" --- - × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'dependencies' + × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'platforms', 'channels', 'channel-priority', 'solve-strategy', 'target', 'dependencies', 'pypi-dependencies', + │ 'dev', 'constraints', 'activation', 'tasks', 'pypi-options' ╭─[pixi.toml:2:21] 1 │ 2 │ env = { feat = ["foo", "bar"] } diff --git a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap index 48d29e8141..8d8451b6e4 100644 --- a/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap +++ b/crates/pixi_manifest/src/toml/snapshots/pixi_manifest__toml__environment__test__parse_invalid_solve_group.snap @@ -1,9 +1,9 @@ --- source: crates/pixi_manifest/src/toml/environment.rs -assertion_line: 203 expression: "format_parse_error(input, TopLevel::from_toml_str(input).unwrap_err())" --- - × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'dependencies' + × Unexpected keys, expected only 'features', 'solve-group', 'no-default-feature', 'platforms', 'channels', 'channel-priority', 'solve-strategy', 'target', 'dependencies', 'pypi-dependencies', + │ 'dev', 'constraints', 'activation', 'tasks', 'pypi-options' ╭─[pixi.toml:2:36] 1 │ 2 │ env = { features = [], solve_groups = "group" } From 36b1b116aa9c2b1f9443ac781b6a5190b7326dca Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Thu, 2 Jul 2026 08:19:08 +0000 Subject: [PATCH 5/7] feat(cli): guard reserved feature names on the CLI and render environment features user-facingly --- .../pixi/tests/integration_rust/common/mod.rs | 4 +- crates/pixi_cli/src/cli_config.rs | 4 +- crates/pixi_cli/src/import.rs | 10 +- crates/pixi_cli/src/info.rs | 2 +- crates/pixi_cli/src/task.rs | 20 ++-- crates/pixi_cli/src/upgrade.rs | 4 +- crates/pixi_cli/src/workspace/channel.rs | 9 +- crates/pixi_cli/src/workspace/feature.rs | 3 + crates/pixi_cli/src/workspace/platform.rs | 24 +++-- crates/pixi_manifest/src/toml/feature.rs | 2 +- crates/pixi_manifest/src/toml/manifest.rs | 23 +++-- crates/pixi_manifest/src/toml/mod.rs | 6 +- .../test_inline_environments.py | 94 +++++++++++++++++++ 13 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 tests/integration_python/test_inline_environments.py diff --git a/crates/pixi/tests/integration_rust/common/mod.rs b/crates/pixi/tests/integration_rust/common/mod.rs index 614889abf6..637bb48361 100644 --- a/crates/pixi/tests/integration_rust/common/mod.rs +++ b/crates/pixi/tests/integration_rust/common/mod.rs @@ -840,7 +840,7 @@ impl TasksControl<'_> { commands: vec![], depends_on: None, platform: platform.map(Into::into), - feature: feature_name.non_default().map(str::to_owned), + feature: feature_name.non_default().map(FeatureName::from), cwd: None, default_environment: None, env: Default::default(), @@ -856,7 +856,7 @@ impl TasksControl<'_> { &self, name: TaskName, platform: Option, - feature_name: Option, + feature_name: Option, ) -> miette::Result<()> { task::execute(task::Args { config_source: isolated_config_source(), diff --git a/crates/pixi_cli/src/cli_config.rs b/crates/pixi_cli/src/cli_config.rs index 39e6a1d54c..b79542dac2 100644 --- a/crates/pixi_cli/src/cli_config.rs +++ b/crates/pixi_cli/src/cli_config.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use crate::has_specs::HasSpecs; use clap::Parser; use indexmap::IndexMap; @@ -301,7 +303,7 @@ pub struct DependencyConfig { pub platforms: Vec, /// The feature for which the dependency should be modified. - #[clap(long, short, default_value_t)] + #[clap(long, short, default_value_t, value_parser = FeatureName::from_str)] pub feature: FeatureName, /// The git url to use when adding a git dependency diff --git a/crates/pixi_cli/src/import.rs b/crates/pixi_cli/src/import.rs index 4f951fdb18..44bab49ac9 100644 --- a/crates/pixi_cli/src/import.rs +++ b/crates/pixi_cli/src/import.rs @@ -56,8 +56,8 @@ pub struct Args { pub environment: Option, /// A name for the created feature - #[clap(long, short)] - pub feature: Option, + #[clap(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, #[clap(flatten)] pub config: ConfigCli, @@ -87,19 +87,19 @@ pub async fn execute(args: Args) -> miette::Result<()> { struct MissingEnvironmentName; fn get_feature_and_environment( - feature_arg: &Option, + feature_arg: &Option, environment_arg: &Option, fallback: impl Fn() -> Result, ) -> Result<(FeatureName, EnvironmentName), miette::Report> { let feature_string = match (feature_arg, environment_arg) { - (Some(f), _) => f.clone(), + (Some(f), _) => f.as_str().to_string(), (_, Some(e)) => e.clone(), _ => fallback()?, }; let environment_string = match (environment_arg, feature_arg) { (Some(e), _) => e.clone(), - (_, Some(f)) => f.clone(), + (_, Some(f)) => f.as_str().to_string(), _ => fallback()?, }; diff --git a/crates/pixi_cli/src/info.rs b/crates/pixi_cli/src/info.rs index 10ae63f3d4..1f1ac44828 100644 --- a/crates/pixi_cli/src/info.rs +++ b/crates/pixi_cli/src/info.rs @@ -144,7 +144,7 @@ impl Display for EnvironmentInfo { bold.apply_to("Features"), self.features .iter() - .map(|feature| feature.fancy_display()) + .map(|feature| consts::FEATURE_STYLE.apply_to(feature.to_string())) .format(", ") )?; if let Some(solve_group) = &self.solve_group { diff --git a/crates/pixi_cli/src/task.rs b/crates/pixi_cli/src/task.rs index a5bfc354d6..9883f1d930 100644 --- a/crates/pixi_cli/src/task.rs +++ b/crates/pixi_cli/src/task.rs @@ -57,8 +57,8 @@ pub struct RemoveArgs { pub platform: Option, /// The feature for which the task should be removed. - #[arg(long, short)] - pub feature: Option, + #[arg(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, } #[derive(Parser, Debug, Clone)] @@ -81,8 +81,8 @@ pub struct AddArgs { pub platform: Option, /// The feature for which the task should be added. - #[arg(long, short)] - pub feature: Option, + #[arg(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, /// The working directory relative to the root of the workspace. #[arg(long)] @@ -417,10 +417,7 @@ async fn add_task( workspace_ctx: WorkspaceContext, args: AddArgs, ) -> miette::Result<()> { - let feature = args - .clone() - .feature - .map_or_else(FeatureName::default, FeatureName::from); + let feature = args.feature.clone().unwrap_or_default(); workspace_ctx .add_task( @@ -450,12 +447,7 @@ async fn remove_tasks( args: RemoveArgs, ) -> miette::Result<()> { workspace_ctx - .remove_task( - args.names, - args.platform, - args.feature - .map_or_else(FeatureName::default, FeatureName::from), - ) + .remove_task(args.names, args.platform, args.feature.unwrap_or_default()) .await } diff --git a/crates/pixi_cli/src/upgrade.rs b/crates/pixi_cli/src/upgrade.rs index 2a5a5ce152..a46ee2c2fc 100644 --- a/crates/pixi_cli/src/upgrade.rs +++ b/crates/pixi_cli/src/upgrade.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use std::cmp::Ordering; use clap::Parser; @@ -62,7 +64,7 @@ pub struct UpgradeSpecsArgs { pub packages: Option>, /// The feature to update - #[clap(long = "feature", short = 'f')] + #[clap(long = "feature", short = 'f', value_parser = FeatureName::from_str)] pub feature: Option, /// The packages which should be excluded diff --git a/crates/pixi_cli/src/workspace/channel.rs b/crates/pixi_cli/src/workspace/channel.rs index 4329baa56f..59e95c08a9 100644 --- a/crates/pixi_cli/src/workspace/channel.rs +++ b/crates/pixi_cli/src/workspace/channel.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use std::io::Write; use clap::Parser; @@ -6,6 +8,7 @@ use miette::IntoDiagnostic; use pixi_api::{WorkspaceContext, workspace::ChannelOptions}; use pixi_config::ConfigCli; use pixi_core::WorkspaceLocator; +use pixi_manifest::FeatureName; use rattler_conda_types::NamedChannelOrUrl; use crate::{ @@ -50,8 +53,8 @@ pub struct AddRemoveArgs { pub config: ConfigCli, /// The name of the feature to modify. - #[clap(long, short)] - pub feature: Option, + #[clap(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, } #[derive(Parser, Debug, Default, Clone)] @@ -67,7 +70,7 @@ impl TryFrom<&AddRemoveArgs> for ChannelOptions { fn try_from(args: &AddRemoveArgs) -> Result { Ok(Self { channels: args.channel.clone(), - feature: args.feature.clone(), + feature: args.feature.clone().map(|f| f.to_string()), no_install: args.no_install_config.no_install, lock_file_usage: args.lock_file_update_config.lock_file_usage()?, }) diff --git a/crates/pixi_cli/src/workspace/feature.rs b/crates/pixi_cli/src/workspace/feature.rs index 30feaced48..1cf8677e48 100644 --- a/crates/pixi_cli/src/workspace/feature.rs +++ b/crates/pixi_cli/src/workspace/feature.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use std::io::Write; use clap::Parser; @@ -27,6 +29,7 @@ pub struct Args { #[derive(Parser, Debug)] pub struct RemoveArgs { /// The name of the feature to remove + #[clap(value_parser = FeatureName::from_str)] pub feature: FeatureName, } diff --git a/crates/pixi_cli/src/workspace/platform.rs b/crates/pixi_cli/src/workspace/platform.rs index f4d95a2e13..984d097bb0 100644 --- a/crates/pixi_cli/src/workspace/platform.rs +++ b/crates/pixi_cli/src/workspace/platform.rs @@ -8,8 +8,8 @@ use pixi_api::WorkspaceContext; use pixi_core::WorkspaceLocator; use pixi_core::workspace::{PlatformOverrides, PlatformSource}; use pixi_manifest::{ - FeaturesExt, HasWorkspaceManifest, PixiPlatform, PixiPlatformName, PlatformEdit, PlatformMove, - platform::subdir_default_virtual_packages, + FeatureName, FeaturesExt, HasWorkspaceManifest, PixiPlatform, PixiPlatformName, PlatformEdit, + PlatformMove, platform::subdir_default_virtual_packages, }; use rattler_conda_types::{GenericVirtualPackage, PackageName, Platform, Version}; use rattler_virtual_packages::{VirtualPackageOverrides, VirtualPackages}; @@ -316,8 +316,8 @@ pub struct AddArgs { pub no_install: bool, /// The name of the feature to add the platform to. - #[clap(long, short)] - pub feature: Option, + #[clap(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, } #[derive(Parser, Debug)] @@ -395,8 +395,8 @@ pub struct RemoveArgs { pub no_install: bool, /// The name of the feature to remove the platform from. - #[clap(long, short)] - pub feature: Option, + #[clap(long, short, value_parser = FeatureName::from_str)] + pub feature: Option, } #[derive(Parser, Debug, Default)] @@ -498,7 +498,11 @@ async fn execute_add( } workspace_ctx - .add_platforms(platforms, args.no_install, args.feature) + .add_platforms( + platforms, + args.no_install, + args.feature.map(|f| f.to_string()), + ) .await } @@ -654,7 +658,11 @@ async fn execute_remove( }) .collect::>>()?; workspace_ctx - .remove_platforms(platforms, args.no_install, args.feature) + .remove_platforms( + platforms, + args.no_install, + args.feature.map(|f| f.to_string()), + ) .await } diff --git a/crates/pixi_manifest/src/toml/feature.rs b/crates/pixi_manifest/src/toml/feature.rs index af1771e854..e76114c23e 100644 --- a/crates/pixi_manifest/src/toml/feature.rs +++ b/crates/pixi_manifest/src/toml/feature.rs @@ -130,7 +130,7 @@ impl TomlFeature { .any(|p| feature_platforms.value.iter().any(|fp| fp == p.name())) { let warning = create_unsupported_selector_warning( - PlatformSpan::Feature(name.to_string(), feature_platforms.span), + PlatformSpan::Feature(name.clone(), feature_platforms.span), &selector, &matching_platforms, ); diff --git a/crates/pixi_manifest/src/toml/manifest.rs b/crates/pixi_manifest/src/toml/manifest.rs index d046c4328f..893de27199 100644 --- a/crates/pixi_manifest/src/toml/manifest.rs +++ b/crates/pixi_manifest/src/toml/manifest.rs @@ -669,8 +669,8 @@ fn migrate_system_requirements_to_platforms( } if !all_simple_subdir { return Err(TomlError::from(GenericError::new(format!( - "feature '{}' uses `[system-requirements]` but the workspace declares per-platform virtual packages; remove the system-requirements table and declare the constraints on the platforms instead", - feature.name, + "{} uses `[system-requirements]` but the workspace declares per-platform virtual packages; remove the system-requirements table and declare the constraints on the platforms instead", + feature.name.user_facing(), )))); } let sysreqs = sysreqs.expect("checked just above"); @@ -699,8 +699,8 @@ fn extend_originals_with_referenced_subdirs( } let subdir = Platform::from_str(name.as_str()).map_err(|e| { TomlError::from(GenericError::new(format!( - "feature '{}' references platform '{}' which is neither declared in the workspace nor a valid conda subdir: {e}", - feature.name, name, + "{} references platform '{}' which is neither declared in the workspace nor a valid conda subdir: {e}", + feature.name.user_facing(), name, ))) })?; originals.insert(PixiPlatform::from_subdir(subdir)); @@ -721,8 +721,9 @@ fn validate_referenced_platforms( for name in names { if !platforms.iter().any(|p| p.name() == name) { return Err(TomlError::from(GenericError::new(format!( - "feature '{}' references platform '{}' which is not declared in the workspace", - feature.name, name, + "{} references platform '{}' which is not declared in the workspace", + feature.name.user_facing(), + name, )))); } } @@ -742,8 +743,9 @@ fn register_referenced_originals( for name in names { let original = originals.iter().find(|p| p.name() == name).ok_or_else(|| { TomlError::from(GenericError::new(format!( - "feature '{}' references platform '{}' which is not declared in the workspace", - feature.name, name, + "{} references platform '{}' which is not declared in the workspace", + feature.name.user_facing(), + name, ))) })?; target.insert(original.clone()); @@ -767,8 +769,9 @@ fn synthesise_for_feature( .map(|name| { Platform::from_str(name.as_str()).map_err(|e| { TomlError::from(GenericError::new(format!( - "feature '{}' references platform '{}' which is not a conda subdir: {e}", - feature.name, name, + "{} references platform '{}' which is not a conda subdir: {e}", + feature.name.user_facing(), + name, ))) }) }) diff --git a/crates/pixi_manifest/src/toml/mod.rs b/crates/pixi_manifest/src/toml/mod.rs index 815c27e87a..b37bd224e5 100644 --- a/crates/pixi_manifest/src/toml/mod.rs +++ b/crates/pixi_manifest/src/toml/mod.rs @@ -40,7 +40,7 @@ pub use workspace::TomlWorkspace; use rattler_conda_types::Platform; use crate::PixiPlatform; -use crate::{TargetSelector, TomlError, error::GenericError, utils::PixiSpanned}; +use crate::{FeatureName, TargetSelector, TomlError, error::GenericError, utils::PixiSpanned}; pub trait FromTomlStr { fn from_toml_str(source: &str) -> Result @@ -83,7 +83,7 @@ pub(crate) fn reject_glob_in_package_target( /// An enum that contains a span to a `platforms =` section. Either from a /// feature or a workspace. enum PlatformSpan { - Feature(String, Span), + Feature(FeatureName, Span), Workspace(Span), } @@ -93,7 +93,7 @@ fn create_unsupported_selector_warning( matching_platforms: &[&PixiPlatform], ) -> GenericError { let (feature_or_workspace, span) = match platform_span { - PlatformSpan::Feature(name, span) => (Cow::Owned(format!("feature '{name}'")), span), + PlatformSpan::Feature(name, span) => (Cow::Owned(name.user_facing().to_string()), span), PlatformSpan::Workspace(span) => (Cow::Borrowed("workspace"), span), }; diff --git a/tests/integration_python/test_inline_environments.py b/tests/integration_python/test_inline_environments.py new file mode 100644 index 0000000000..ee60a88a2e --- /dev/null +++ b/tests/integration_python/test_inline_environments.py @@ -0,0 +1,94 @@ +from pathlib import Path + +from .common import CURRENT_PLATFORM, ExitCode, verify_cli_command + + +def test_inline_environment_installs_and_runs( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + manifest = tmp_pixi_workspace.joinpath("pixi.toml") + toml = f""" + [workspace] + name = "test" + channels = ["{dummy_channel_1}"] + platforms = ["{CURRENT_PLATFORM}"] + + [environments.dev] + dependencies = {{ dummy-a = "*" }} + + [environments.dev.tasks] + greet = "echo hello-from-dev" + """ + manifest.write_text(toml) + + # The inline task runs in the inline environment. + verify_cli_command( + [pixi, "run", "--manifest-path", manifest, "--environment", "dev", "greet"], + stdout_contains="hello-from-dev", + ) + + # The inline dependency is installed in the environment. + verify_cli_command( + [pixi, "list", "--manifest-path", manifest, "--environment", "dev"], + stdout_contains="dummy-a", + ) + + +def test_inline_environment_combines_with_features( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + manifest = tmp_pixi_workspace.joinpath("pixi.toml") + toml = f""" + [workspace] + name = "test" + channels = ["{dummy_channel_1}"] + platforms = ["{CURRENT_PLATFORM}"] + + [feature.shared.dependencies] + dummy-b = "*" + + [environments.dev] + features = ["shared"] + dependencies = {{ dummy-a = "*" }} + """ + manifest.write_text(toml) + + verify_cli_command( + [pixi, "list", "--manifest-path", manifest, "--environment", "dev"], + stdout_contains=["dummy-a", "dummy-b"], + ) + + +def test_reserved_env_feature_name_rejected( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + manifest = tmp_pixi_workspace.joinpath("pixi.toml") + toml = f""" + [workspace] + name = "test" + channels = ["{dummy_channel_1}"] + platforms = ["{CURRENT_PLATFORM}"] + + [environments.dev] + dependencies = {{ dummy-a = "*" }} + """ + manifest.write_text(toml) + + # The reserved feature namespace is rejected on the CLI. + verify_cli_command( + [pixi, "add", "--manifest-path", manifest, "--feature", "env:dev", "dummy-b"], + ExitCode.INCORRECT_USAGE, + stderr_contains="reserved", + ) + + # Referencing the synthesized feature of another environment is rejected. + toml += """ + [environments.other] + features = ["env:dev"] + """ + manifest.write_text(toml) + verify_cli_command( + [pixi, "install", "--manifest-path", manifest, "--environment", "other"], + ExitCode.FAILURE, + stderr_contains="cannot be referenced", + ) From ae8bbc30e6b252b7cd8f23beeda3964cd1e14f70 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Wed, 1 Jul 2026 10:55:11 +0000 Subject: [PATCH 6/7] docs(manifest): document inline environment dependencies and update schema --- crates/pixi_manifest/src/pyproject.rs | 36 +++++ docs/reference/pixi_manifest.md | 36 +++++ schema/model.py | 50 +++++++ schema/pyproject/partial-pixi.json | 194 ++++++++++++++++++++++++++ schema/pyproject/schema.json | 194 ++++++++++++++++++++++++++ schema/schema.json | 194 ++++++++++++++++++++++++++ 6 files changed, 704 insertions(+) diff --git a/crates/pixi_manifest/src/pyproject.rs b/crates/pixi_manifest/src/pyproject.rs index 3f5c7b2076..501fb375a4 100644 --- a/crates/pixi_manifest/src/pyproject.rs +++ b/crates/pixi_manifest/src/pyproject.rs @@ -609,4 +609,40 @@ mod tests { "expected to find the security extra defined on the including extra" ); } + + #[test] + fn test_inline_environment_dependencies() { + const PYPROJECT: &str = r#" + [project] + name = "example" + + [tool.pixi.workspace] + channels = ["conda-forge"] + platforms = ["linux-64"] + + [tool.pixi.environments.dev.dependencies] + git = "*" + "#; + + let manifest = super::PyProjectManifest::from_toml_str(PYPROJECT).unwrap(); + let (workspace_manifest, _, _) = manifest.into_workspace_manifest(Path::new("")).unwrap(); + + // The implicit `env:dev` feature is prepended to the environment. + let env_feature = + FeatureName::environment(&crate::EnvironmentName::Named("dev".to_string())); + let dev = workspace_manifest + .environment("dev") + .expect("dev environment exists"); + assert_eq!(dev.features, vec![env_feature.clone()]); + + let feature = workspace_manifest + .feature(&env_feature) + .expect("the inline feature exists"); + assert!( + feature + .dependencies(crate::SpecType::Run, None) + .unwrap() + .contains_key("git") + ); + } } diff --git a/docs/reference/pixi_manifest.md b/docs/reference/pixi_manifest.md index aa8352ba5e..1239c6b2c0 100644 --- a/docs/reference/pixi_manifest.md +++ b/docs/reference/pixi_manifest.md @@ -1284,6 +1284,8 @@ The environments table is defined using the following fields: But the different environments contain different subsets of the solve-groups dependencies set. - `no-default-feature`: Whether to include the default feature in that environment. The default is `false`, to include the default feature. +Additionally, most fields that a [feature](#the-feature-table) accepts - `dependencies`, `pypi-dependencies`, `tasks`, `activation`, `channels`, `platforms`, `constraints`, `target` and more - can be set directly on an environment. See [Defining dependencies directly on an environment](#defining-dependencies-directly-on-an-environment). + ```toml title="Full environments table specification" [environments] test = {features = ["test"], solve-group = "test"} @@ -1325,6 +1327,40 @@ When an environment comprises several features (including the default feature): it is usually a good idea to set the workspace `platforms` to all platforms it can support across its environments. +#### Defining dependencies directly on an environment + +Features are the right tool when you want to *share* dependencies between environments. +When something belongs to a single environment, you can skip the feature and define it directly on the environment: + +```toml title="Inline environment dependencies" +[environments.dev.dependencies] +git = "*" + +[environments.test.dependencies] +pytest = "*" +pytest-xdist = "*" +``` + +This defines two environments, `dev` and `test`, without any intermediate feature. +All feature content is available this way: `dependencies`, `pypi-dependencies`, `dev`, `tasks`, `activation`, `channels`, `channel-priority`, `solve-strategy`, `platforms`, `constraints`, `target` and `pypi-options`. +`host-dependencies`, `build-dependencies` and `system-requirements` are not accepted here; define a feature if you need them. + +Inline content can be combined with shared features. +The inline content takes precedence over the referenced features, which is relevant for the fields where order matters (such as `tasks` and `activation`): + +```toml title="Inline content combined with shared features" +[feature.python.dependencies] +python = "3.14.*" + +[environments.dev] +features = ["python"] +dependencies = { git = "*" } +``` + +Under the hood pixi synthesizes an implicit feature named `env:` (here `env:dev`) and prepends it to the environment's features. +Because of this, feature names cannot start with `env:`. +The `default` environment cannot define dependencies inline; add them to the top-level tables (for example `[dependencies]`) instead, as those already belong to the default environment. + ## Global configuration The global configuration options are documented in the [global configuration](../reference/pixi_configuration.md) section. diff --git a/schema/model.py b/schema/model.py index 5d8f9f0129..18c9595467 100644 --- a/schema/model.py +++ b/schema/model.py @@ -822,6 +822,56 @@ class Environment(StrictBaseModel): False, description="Whether to add the default feature to this environment", ) + # Inline feature content. Defining any of these synthesizes an implicit + # feature that is prepended to the environment's features. `host-dependencies`, + # `build-dependencies` and `system-requirements` are intentionally not allowed + # here; they belong on a feature. + channels: list[Channel] | None = Field( + None, + description="The `conda` channels that can be considered when solving this environment", + ) + channel_priority: ChannelPriority | None = Field( + None, + examples=["strict", "disabled"], + description="""The type of channel priority that is used in the solve. +- 'strict': only take the package from the channel it exist in first. +- 'disabled': group all dependencies together as if there is no channel difference.""", + ) + solve_strategy: SolveStrategy | None = Field( + None, + examples=["lowest", "lowest-direct", "highest"], + description="""The strategy that is used in the solve. +- 'highest': solve all packages to the highest compatible version. +- 'lowest': solve all packages to the lowest compatible version. +- 'lowest-direct': solve direct dependencies to the lowest compatible version and transitive ones to the highest compatible version.""", + ) + platforms: list[Platform | PlatformName] | None = Field( + None, + description="The platforms that this environment supports. Each entry is either a conda subdir or the name of a workspace platform.", + ) + dependencies: Dependencies = DependenciesField + constraints: Dependencies = ConstraintsField + pypi_dependencies: dict[PyPIPackageName, PyPIRequirement] | None = Field( + None, description="The PyPI dependencies of this environment" + ) + dev: dict[CondaPackageName, SourceSpecTable] | None = Field( + None, + description="Source packages whose dependencies should be installed without building the package itself. Useful for development environments.", + ) + tasks: dict[TaskName, TaskInlineTable | list[DependsOn] | NonEmptyStr] | None = Field( + None, description="The tasks provided by this environment" + ) + activation: Activation | None = Field( + None, description="The scripts used on the activation of this environment" + ) + target: dict[TargetName, Target] | None = Field( + None, + description="Machine-specific aspects of this environment", + examples=[{"linux": {"dependencies": {"python": "3.8"}}}], + ) + pypi_options: PyPIOptions | None = Field( + None, description="Options related to PyPI indexes for this environment" + ) ###################### diff --git a/schema/pyproject/partial-pixi.json b/schema/pyproject/partial-pixi.json index e42ea029be..8a6714e84d 100644 --- a/schema/pyproject/partial-pixi.json +++ b/schema/pyproject/partial-pixi.json @@ -805,6 +805,88 @@ "type": "object", "additionalProperties": false, "properties": { + "activation": { + "$ref": "#/$defs/Activation", + "description": "The scripts used on the activation of this environment" + }, + "channel-priority": { + "$ref": "#/$defs/ChannelPriority", + "description": "The type of channel priority that is used in the solve.\n- 'strict': only take the package from the channel it exist in first.\n- 'disabled': group all dependencies together as if there is no channel difference.", + "examples": [ + "strict", + "disabled" + ] + }, + "channels": { + "title": "Channels", + "description": "The `conda` channels that can be considered when solving this environment", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "format": "uri", + "minLength": 1 + }, + { + "$ref": "#/$defs/ChannelInlineTable" + } + ] + } + }, + "constraints": { + "title": "Constraints", + "description": "The `conda` version constraints. These constrain the versions of packages that may be installed without explicitly requiring them. If the package is installed as a dependency of another package, it must satisfy these constraints.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dependencies": { + "title": "Dependencies", + "description": "The `conda` dependencies, consisting of a package name and a requirement in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dev": { + "title": "Dev", + "description": "Source packages whose dependencies should be installed without building the package itself. Useful for development environments.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SourceSpecTable" + }, + "propertyNames": { + "minLength": 1 + } + }, "features": { "title": "Features", "description": "The features that define the environment", @@ -820,11 +902,123 @@ "type": "boolean", "default": false }, + "platforms": { + "title": "Platforms", + "description": "The platforms that this environment supports. Each entry is either a conda subdir or the name of a workspace platform.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Platform" + }, + { + "type": "string", + "maxLength": 64, + "pattern": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$|^[a-zA-Z]$" + } + ] + } + }, + "pypi-dependencies": { + "title": "Pypi-Dependencies", + "description": "The PyPI dependencies of this environment", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/PyPIVersion" + }, + { + "$ref": "#/$defs/PyPIGitBranchRequirement" + }, + { + "$ref": "#/$defs/PyPIGitTagRequirement" + }, + { + "$ref": "#/$defs/PyPIGitRevRequirement" + }, + { + "$ref": "#/$defs/PyPIPathRequirement" + }, + { + "$ref": "#/$defs/PyPIUrlRequirement" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "pypi-options": { + "$ref": "#/$defs/PyPIOptions", + "description": "Options related to PyPI indexes for this environment" + }, "solve-group": { "title": "Solve-Group", "description": "The group name for environments that should be solved together", "type": "string", "minLength": 1 + }, + "solve-strategy": { + "$ref": "#/$defs/SolveStrategy", + "description": "The strategy that is used in the solve.\n- 'highest': solve all packages to the highest compatible version.\n- 'lowest': solve all packages to the lowest compatible version.\n- 'lowest-direct': solve direct dependencies to the lowest compatible version and transitive ones to the highest compatible version.", + "examples": [ + "lowest", + "lowest-direct", + "highest" + ] + }, + "target": { + "title": "Target", + "description": "Machine-specific aspects of this environment", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Target" + }, + "propertyNames": { + "minLength": 1 + }, + "examples": [ + { + "linux": { + "dependencies": { + "python": "3.8" + } + } + } + ] + }, + "tasks": { + "title": "Tasks", + "description": "The tasks provided by this environment", + "type": "object", + "patternProperties": { + "^[^\\s\\$]+$": { + "anyOf": [ + { + "$ref": "#/$defs/TaskInlineTable" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DependsOn" + } + }, + { + "type": "string", + "minLength": 1 + } + ] + } + }, + "propertyNames": { + "description": "A valid task name.", + "minLength": 1 + } } } }, diff --git a/schema/pyproject/schema.json b/schema/pyproject/schema.json index dd457821dd..e510eecece 100644 --- a/schema/pyproject/schema.json +++ b/schema/pyproject/schema.json @@ -548,6 +548,88 @@ "type": "object", "additionalProperties": false, "properties": { + "activation": { + "$ref": "#/$defs/Activation", + "description": "The scripts used on the activation of this environment" + }, + "channel-priority": { + "$ref": "#/$defs/ChannelPriority", + "description": "The type of channel priority that is used in the solve.\n- 'strict': only take the package from the channel it exist in first.\n- 'disabled': group all dependencies together as if there is no channel difference.", + "examples": [ + "strict", + "disabled" + ] + }, + "channels": { + "title": "Channels", + "description": "The `conda` channels that can be considered when solving this environment", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "format": "uri", + "minLength": 1 + }, + { + "$ref": "#/$defs/ChannelInlineTable" + } + ] + } + }, + "constraints": { + "title": "Constraints", + "description": "The `conda` version constraints. These constrain the versions of packages that may be installed without explicitly requiring them. If the package is installed as a dependency of another package, it must satisfy these constraints.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dependencies": { + "title": "Dependencies", + "description": "The `conda` dependencies, consisting of a package name and a requirement in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dev": { + "title": "Dev", + "description": "Source packages whose dependencies should be installed without building the package itself. Useful for development environments.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SourceSpecTable" + }, + "propertyNames": { + "minLength": 1 + } + }, "features": { "title": "Features", "description": "The features that define the environment", @@ -563,11 +645,123 @@ "type": "boolean", "default": false }, + "platforms": { + "title": "Platforms", + "description": "The platforms that this environment supports. Each entry is either a conda subdir or the name of a workspace platform.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Platform" + }, + { + "type": "string", + "maxLength": 64, + "pattern": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$|^[a-zA-Z]$" + } + ] + } + }, + "pypi-dependencies": { + "title": "Pypi-Dependencies", + "description": "The PyPI dependencies of this environment", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/PyPIVersion" + }, + { + "$ref": "#/$defs/PyPIGitBranchRequirement" + }, + { + "$ref": "#/$defs/PyPIGitTagRequirement" + }, + { + "$ref": "#/$defs/PyPIGitRevRequirement" + }, + { + "$ref": "#/$defs/PyPIPathRequirement" + }, + { + "$ref": "#/$defs/PyPIUrlRequirement" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "pypi-options": { + "$ref": "#/$defs/PyPIOptions", + "description": "Options related to PyPI indexes for this environment" + }, "solve-group": { "title": "Solve-Group", "description": "The group name for environments that should be solved together", "type": "string", "minLength": 1 + }, + "solve-strategy": { + "$ref": "#/$defs/SolveStrategy", + "description": "The strategy that is used in the solve.\n- 'highest': solve all packages to the highest compatible version.\n- 'lowest': solve all packages to the lowest compatible version.\n- 'lowest-direct': solve direct dependencies to the lowest compatible version and transitive ones to the highest compatible version.", + "examples": [ + "lowest", + "lowest-direct", + "highest" + ] + }, + "target": { + "title": "Target", + "description": "Machine-specific aspects of this environment", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Target" + }, + "propertyNames": { + "minLength": 1 + }, + "examples": [ + { + "linux": { + "dependencies": { + "python": "3.8" + } + } + } + ] + }, + "tasks": { + "title": "Tasks", + "description": "The tasks provided by this environment", + "type": "object", + "patternProperties": { + "^[^\\s\\$]+$": { + "anyOf": [ + { + "$ref": "#/$defs/TaskInlineTable" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DependsOn" + } + }, + { + "type": "string", + "minLength": 1 + } + ] + } + }, + "propertyNames": { + "description": "A valid task name.", + "minLength": 1 + } } } }, diff --git a/schema/schema.json b/schema/schema.json index 62fa682940..d33c4e1b77 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -829,6 +829,88 @@ "type": "object", "additionalProperties": false, "properties": { + "activation": { + "$ref": "#/$defs/Activation", + "description": "The scripts used on the activation of this environment" + }, + "channel-priority": { + "$ref": "#/$defs/ChannelPriority", + "description": "The type of channel priority that is used in the solve.\n- 'strict': only take the package from the channel it exist in first.\n- 'disabled': group all dependencies together as if there is no channel difference.", + "examples": [ + "strict", + "disabled" + ] + }, + "channels": { + "title": "Channels", + "description": "The `conda` channels that can be considered when solving this environment", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "format": "uri", + "minLength": 1 + }, + { + "$ref": "#/$defs/ChannelInlineTable" + } + ] + } + }, + "constraints": { + "title": "Constraints", + "description": "The `conda` version constraints. These constrain the versions of packages that may be installed without explicitly requiring them. If the package is installed as a dependency of another package, it must satisfy these constraints.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dependencies": { + "title": "Dependencies", + "description": "The `conda` dependencies, consisting of a package name and a requirement in [MatchSpec](https://github.com/conda/conda/blob/078e7ee79381060217e1ec7f9b0e9cf80ecc8f3f/conda/models/match_spec.py) format", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/MatchspecTable" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "dev": { + "title": "Dev", + "description": "Source packages whose dependencies should be installed without building the package itself. Useful for development environments.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SourceSpecTable" + }, + "propertyNames": { + "minLength": 1 + } + }, "features": { "title": "Features", "description": "The features that define the environment", @@ -844,11 +926,123 @@ "type": "boolean", "default": false }, + "platforms": { + "title": "Platforms", + "description": "The platforms that this environment supports. Each entry is either a conda subdir or the name of a workspace platform.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Platform" + }, + { + "type": "string", + "maxLength": 64, + "pattern": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$|^[a-zA-Z]$" + } + ] + } + }, + "pypi-dependencies": { + "title": "Pypi-Dependencies", + "description": "The PyPI dependencies of this environment", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/PyPIVersion" + }, + { + "$ref": "#/$defs/PyPIGitBranchRequirement" + }, + { + "$ref": "#/$defs/PyPIGitTagRequirement" + }, + { + "$ref": "#/$defs/PyPIGitRevRequirement" + }, + { + "$ref": "#/$defs/PyPIPathRequirement" + }, + { + "$ref": "#/$defs/PyPIUrlRequirement" + } + ] + }, + "propertyNames": { + "minLength": 1 + } + }, + "pypi-options": { + "$ref": "#/$defs/PyPIOptions", + "description": "Options related to PyPI indexes for this environment" + }, "solve-group": { "title": "Solve-Group", "description": "The group name for environments that should be solved together", "type": "string", "minLength": 1 + }, + "solve-strategy": { + "$ref": "#/$defs/SolveStrategy", + "description": "The strategy that is used in the solve.\n- 'highest': solve all packages to the highest compatible version.\n- 'lowest': solve all packages to the lowest compatible version.\n- 'lowest-direct': solve direct dependencies to the lowest compatible version and transitive ones to the highest compatible version.", + "examples": [ + "lowest", + "lowest-direct", + "highest" + ] + }, + "target": { + "title": "Target", + "description": "Machine-specific aspects of this environment", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Target" + }, + "propertyNames": { + "minLength": 1 + }, + "examples": [ + { + "linux": { + "dependencies": { + "python": "3.8" + } + } + } + ] + }, + "tasks": { + "title": "Tasks", + "description": "The tasks provided by this environment", + "type": "object", + "patternProperties": { + "^[^\\s\\$]+$": { + "anyOf": [ + { + "$ref": "#/$defs/TaskInlineTable" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DependsOn" + } + }, + { + "type": "string", + "minLength": 1 + } + ] + } + }, + "propertyNames": { + "description": "A valid task name.", + "minLength": 1 + } } } }, From 6efd259f0baa57ac82e42aafcdc7a2ad2006331c Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Thu, 2 Jul 2026 08:37:31 +0000 Subject: [PATCH 7/7] docs: prefer inline environments over single-feature environments --- docs/advanced/channel_logic.md | 13 +-- docs/integration/ci/github_actions.md | 8 +- docs/integration/extensions/pixi_skills.md | 2 +- .../pixi_tomls/multi-environment-docs.toml | 10 ++ .../pixi_tomls/multi-environment-py-envs.toml | 19 ++-- .../pixi_tomls/multi-environment-simple.toml | 17 ++-- .../pixi_tomls/task_default_environment.toml | 5 +- .../pixi_tomls/tasks_depends_on.toml | 8 +- .../introduction/multi_env/pixi.toml | 8 +- .../pixi_build/workspace_variants/pixi.toml | 8 +- docs/switching_from/uv.md | 11 +-- docs/tutorials/multi_environment.md | 93 ++++++------------- docs/workspace/multi_environment.md | 63 ++++++++----- 13 files changed, 116 insertions(+), 149 deletions(-) create mode 100644 docs/source_files/pixi_tomls/multi-environment-docs.toml diff --git a/docs/advanced/channel_logic.md b/docs/advanced/channel_logic.md index 81e316e822..b793cbf5e1 100644 --- a/docs/advanced/channel_logic.md +++ b/docs/advanced/channel_logic.md @@ -94,7 +94,7 @@ If you want to force a specific priority for a channel, you can use the `priorit The higher the number, the higher the priority. Non specified priorities are set to 0 but the index in the array still counts as a priority, where the first in the list has the highest priority. -This priority definition is mostly important for [multiple environments](../workspace/multi_environment.md) with different channel priorities, as by default feature channels are prepended to the workspace channels. +This priority definition is mostly important for [multiple environments](../workspace/multi_environment.md) with different channel priorities, as by default environment and feature channels are prepended to the workspace channels. ```toml [workspace] @@ -102,19 +102,14 @@ name = "test_channel_priority" platforms = ["linux-64", "osx-64", "win-64", "osx-arm64"] channels = ["conda-forge"] -[feature.a] +[environments.a] channels = ["nvidia"] -[feature.b] +[environments.b] channels = [ "pytorch", {channel = "nvidia", priority = 1}] -[feature.c] +[environments.c] channels = [ "pytorch", {channel = "nvidia", priority = -1}] - -[environments] -a = ["a"] -b = ["b"] -c = ["c"] ``` This example creates 4 environments, `a`, `b`, `c`, and the default environment. Which will have the following channel order: diff --git a/docs/integration/ci/github_actions.md b/docs/integration/ci/github_actions.md index 9fddbb78c2..db96aa1b41 100644 --- a/docs/integration/ci/github_actions.md +++ b/docs/integration/ci/github_actions.md @@ -89,14 +89,10 @@ python = ">=3.11" pip = "*" polars = ">=0.14.24,<0.21" -[feature.py311.dependencies] +[environments.py311.dependencies] python = "3.11.*" -[feature.py312.dependencies] +[environments.py312.dependencies] python = "3.12.*" - -[environments] -py311 = ["py311"] -py312 = ["py312"] ``` #### Multiple environments using a matrix diff --git a/docs/integration/extensions/pixi_skills.md b/docs/integration/extensions/pixi_skills.md index f217fd3c82..d08d1a0ba5 100644 --- a/docs/integration/extensions/pixi_skills.md +++ b/docs/integration/extensions/pixi_skills.md @@ -42,7 +42,7 @@ channels = ["conda-forge", "https://prefix.dev/skill-forge"] [dependencies] polars = ">=1,<2" -[feature.dev.dependencies] +[environments.dev.dependencies] agent-skill-polars = "*" ``` diff --git a/docs/source_files/pixi_tomls/multi-environment-docs.toml b/docs/source_files/pixi_tomls/multi-environment-docs.toml new file mode 100644 index 0000000000..fd215894af --- /dev/null +++ b/docs/source_files/pixi_tomls/multi-environment-docs.toml @@ -0,0 +1,10 @@ +[workspace] +channels = ["https://prefix.dev/conda-forge"] +name = "pixi" +platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"] + +# --8<-- [start:docs-env] +[environments.docs] +dependencies = { mkdocs = "*" } +no-default-feature = true +# --8<-- [end:docs-env] diff --git a/docs/source_files/pixi_tomls/multi-environment-py-envs.toml b/docs/source_files/pixi_tomls/multi-environment-py-envs.toml index 8da5c88e6f..0784da1d20 100644 --- a/docs/source_files/pixi_tomls/multi-environment-py-envs.toml +++ b/docs/source_files/pixi_tomls/multi-environment-py-envs.toml @@ -3,17 +3,20 @@ channels = ["https://prefix.dev/conda-forge"] name = "pixi" platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"] +# --8<-- [start:py-envs] +# The testing tools are shared, so they live in a feature. [feature.test.dependencies] pytest = "*" -# --8<-- [start:py-envs] -[feature.py311.dependencies] -python = "3.11.*" +[feature.test.tasks] +test = "pytest" -[feature.py312.dependencies] -python = "3.12.*" +# The python version is unique to each environment, so it is defined inline. +[environments.test-py311] +dependencies = { python = "3.11.*" } +features = ["test"] -[environments] -test-py311 = ["py311", "test"] -test-py312 = ["py312", "test"] +[environments.test-py312] +dependencies = { python = "3.12.*" } +features = ["test"] # --8<-- [end:py-envs] diff --git a/docs/source_files/pixi_tomls/multi-environment-simple.toml b/docs/source_files/pixi_tomls/multi-environment-simple.toml index 386fd437bb..60ad0a0895 100644 --- a/docs/source_files/pixi_tomls/multi-environment-simple.toml +++ b/docs/source_files/pixi_tomls/multi-environment-simple.toml @@ -3,17 +3,12 @@ channels = ["https://prefix.dev/conda-forge"] name = "pixi" platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"] -# --8<-- [start:test-feat-dep] -[feature.test.dependencies] +# --8<-- [start:test-env-dep] +[environments.test.dependencies] pytest = "*" -# --8<-- [end:test-feat-dep] +# --8<-- [end:test-env-dep] -# --8<-- [start:test-env] -[environments] -test = ["test"] -# --8<-- [end:test-env] - -# --8<-- [start:test-tasks] -[feature.test.tasks] +# --8<-- [start:test-env-tasks] +[environments.test.tasks] test = "pytest" -# --8<-- [end:test-tasks] +# --8<-- [end:test-env-tasks] diff --git a/docs/source_files/pixi_tomls/task_default_environment.toml b/docs/source_files/pixi_tomls/task_default_environment.toml index 746380c642..24256b50a9 100644 --- a/docs/source_files/pixi_tomls/task_default_environment.toml +++ b/docs/source_files/pixi_tomls/task_default_environment.toml @@ -3,12 +3,9 @@ channels = ["conda-forge"] name = "task-default-environment" # --8<-- [start:default-environment] -[feature.test.dependencies] +[environments.test.dependencies] pytest = "*" -[environments] -test = ["test"] # An environment covering the "test" feature - [tasks] test = { cmd = "pytest", default-environment = "test" } # --8<-- [end:default-environment] diff --git a/docs/source_files/pixi_tomls/tasks_depends_on.toml b/docs/source_files/pixi_tomls/tasks_depends_on.toml index 0b1bac38bc..708041b85e 100644 --- a/docs/source_files/pixi_tomls/tasks_depends_on.toml +++ b/docs/source_files/pixi_tomls/tasks_depends_on.toml @@ -6,16 +6,12 @@ name = "tasks-depends-on" [tasks] test = "python --version" -[feature.py311.dependencies] +[environments.py311.dependencies] python = "3.11.*" -[feature.py312.dependencies] +[environments.py312.dependencies] python = "3.12.*" -[environments] -py311 = ["py311"] -py312 = ["py312"] - # Task that depends on other tasks in different environments [tasks.test-all] depends-on = [ diff --git a/docs/source_files/pixi_workspaces/introduction/multi_env/pixi.toml b/docs/source_files/pixi_workspaces/introduction/multi_env/pixi.toml index b2d3d9716e..73a1a1fe5f 100644 --- a/docs/source_files/pixi_workspaces/introduction/multi_env/pixi.toml +++ b/docs/source_files/pixi_workspaces/introduction/multi_env/pixi.toml @@ -9,12 +9,8 @@ start = "python hello.py" [dependencies] cowpy = "1.1.*" -[feature.py312.dependencies] +[environments.py312.dependencies] python = "3.12.*" -[feature.py313.dependencies] +[environments.py313.dependencies] python = "3.13.*" - -[environments] -py312 = ["py312"] -py313 = ["py313"] diff --git a/docs/source_files/pixi_workspaces/pixi_build/workspace_variants/pixi.toml b/docs/source_files/pixi_workspaces/pixi_build/workspace_variants/pixi.toml index acc68d3c97..ff4ce953a6 100644 --- a/docs/source_files/pixi_workspaces/pixi_build/workspace_variants/pixi.toml +++ b/docs/source_files/pixi_workspaces/pixi_build/workspace_variants/pixi.toml @@ -9,15 +9,11 @@ python_rich = { path = "." } # --8<-- [end:dependencies] # --8<-- [start:environments] -[feature.py311.dependencies] +[environments.py311.dependencies] python = "3.11.*" -[feature.py312.dependencies] +[environments.py312.dependencies] python = "3.12.*" - -[environments] -py311 = ["py311"] -py312 = ["py312"] # --8<-- [end:environments] diff --git a/docs/switching_from/uv.md b/docs/switching_from/uv.md index b5f19228c6..eaaa041a10 100644 --- a/docs/switching_from/uv.md +++ b/docs/switching_from/uv.md @@ -73,11 +73,8 @@ uv uses `pyproject.toml` for project configuration and `uv.toml` for tool-level numpy = ">=1.26" pandas = ">=2.0" - [feature.test.dependencies] + [environments.test.dependencies] pytest = ">=8.0" - - [environments] - test = ["test"] ``` === "Pixi (pyproject.toml)" @@ -137,16 +134,16 @@ See [Multi Environment](../workspace/multi_environment.md). ### Dependency groups and extras -uv uses [PEP 735 dependency groups](https://peps.python.org/pep-0735/) and optional dependencies (extras) to organize dependencies. Pixi uses **features**, composable sets of dependencies that map to environments: +uv uses [PEP 735 dependency groups](https://peps.python.org/pep-0735/) and optional dependencies (extras) to organize dependencies. Pixi uses **environments**, which define their dependencies directly, and **features**, composable sets of dependencies that can be shared between environments: | uv | Pixi | |------------------------------|---------------------------------------------------------------------| -| `[dependency-groups]` | `[feature..dependencies]` | +| `[dependency-groups]` | `[environments..dependencies]` | | `[project.optional-dependencies]` | `[feature..dependencies]` mapped to environments | | `uv sync --group dev` | `pixi install -e dev` | | `uv sync --all-groups` | `pixi install --all` | -Features are more flexible than dependency groups: they can include conda dependencies, platform-specific packages, system requirements, and activation scripts. +Environments and features are more flexible than dependency groups: they can include conda dependencies, platform-specific packages, tasks, and activation scripts. ### Workspaces diff --git a/docs/tutorials/multi_environment.md b/docs/tutorials/multi_environment.md index 8012f4421e..3b573f1ba3 100644 --- a/docs/tutorials/multi_environment.md +++ b/docs/tutorials/multi_environment.md @@ -10,20 +10,20 @@ Setting up different environments for these different use cases can be a hassle, ## Glossary This tutorial possibly uses some new terms, here is a quick overview: +#### **Environment** +An environment is a collection of dependencies, tasks and more, that can be installed and activated to run tasks in. +You can define multiple environments in one workspace. +Defining environments is done by adding them to the `[environments]` table in the manifest file. +An environment can define its content directly, e.g. `[environments..dependencies]`, or pull in shared content through features. #### **Feature** -A feature defines a part of an environment, but are not useful without being part of an environment. -You can define multiple features in one workspace. +A feature defines a part of an environment, but is not useful without being part of an environment. +Features exist to *share* content between environments. A feature can contain `tasks`, `dependencies`, `platforms`, `channels` and [more](../reference/pixi_manifest.md#the-feature-table). You can mix multiple features to create an environment. Features are defined by adding `[feature..*]` to a table in the manifest file. -#### **Environment** -An environment is a collection of features. -Environments can actually be installed and activated to run tasks in. -You can define multiple environments in one workspace. -Defining environments is done by adding them to the `[environments]` table in the manifest file. #### **Default** Instead of specifying `[feature..dependencies]`, one can populate `[dependencies]` directly. -These top level table, are added to the "default" feature, which is added to every environment, unless you specifically opt-out. +These top level tables are added to the "default" feature, which is added to every environment, unless you specifically opt-out. ## Let's Get Started @@ -38,8 +38,8 @@ pixi add python Now we have a new Pixi workspace with the following structure: ``` ├── .pixi -│   └── envs -│   └── default +│ └── envs +│ └── default ├── pixi.lock └── pixi.toml ``` @@ -48,30 +48,13 @@ Note the `.pixi/envs/default` directory, this is where the default environment i If no environment is specified, Pixi will create or use the `default` environment. -### Adding a feature -Let's start adding a simple `test` feature to our workspace. -We can do this through the command line, or by editing the `pixi.toml` file. -Here we will use the command line, and add a `pytest` dependency to the `test` feature in our workspace. -```shell -pixi add --feature test pytest -``` -This will add the following to our `pixi.toml` file: -```toml ---8<-- "docs/source_files/pixi_tomls/multi-environment-simple.toml:test-feat-dep" -``` -This table acts exactly the same as a normal `dependencies` table, but it is only used when the `test` feature is part of an environment. - ### Adding an environment -We will add the `test` environment to our workspace to add some testing tools. -We can do this through the command line, or by editing the `pixi.toml` file. -Here we will use the command line: -```shell -pixi workspace environment add test --feature test -``` -This will add the following to our `pixi.toml` file: +Let's add a simple `test` environment to our workspace. +An environment that isn't shared with other environments doesn't need a feature; we can define its dependencies directly on the environment by editing the `pixi.toml` file: ```toml ---8<-- "docs/source_files/pixi_tomls/multi-environment-simple.toml:test-env" +--8<-- "docs/source_files/pixi_tomls/multi-environment-simple.toml:test-env-dep" ``` +This table acts exactly the same as a normal `dependencies` table, but the dependency is only part of the `test` environment. ### Running a task We can now run a task in our new environment. @@ -82,23 +65,18 @@ This has created the test environment, and run the `pytest --version` command in You can see the environment will be added to the `.pixi/envs` directory. ```shell ├── .pixi -│   └── envs -│   ├── default -│   └── test +│ └── envs +│ ├── default +│ └── test ``` If you want to see the environment, you can use the `pixi list` command. ```shell pixi list --environment test ``` -If you have special test commands that always fit with the test environment you can add them to the `test` feature. -```shell -# Adding the 'test' task to the 'test' feature and setting it to run `pytest` -pixi task add test --feature test pytest -``` -This will add the following to our `pixi.toml` file: +If you have special test commands that always fit with the test environment you can add them to the environment as well. ```toml ---8<-- "docs/source_files/pixi_tomls/multi-environment-simple.toml:test-tasks" +--8<-- "docs/source_files/pixi_tomls/multi-environment-simple.toml:test-env-tasks" ``` Now you don't have to specify the environment when running the test command. ```shell @@ -121,19 +99,9 @@ To allow python being flexible in the new environments we need to set it to a mo pixi add "python=*" ``` -We will start by setting up two features, `py311` and `py312`. -```shell -pixi add --feature py311 python=3.11 -pixi add --feature py312 python=3.12 -``` - -We'll add the `test` and Python features to the corresponding environments. -```shell -pixi workspace environment add test-py311 --feature py311 --feature test -pixi workspace environment add test-py312 --feature py312 --feature test -``` - -This should result in adding the following to the `pixi.toml`: +We now want two environments that share the testing tools, but differ in their Python version. +This is exactly what features are for: sharing content between environments. +We move the `pytest` dependency into a `test` feature, and give each environment its own Python version directly: ```toml --8<-- "docs/source_files/pixi_tomls/multi-environment-py-envs.toml:py-envs" ``` @@ -185,6 +153,8 @@ To accommodate the different use-cases we'll add a `production`, `test` and `def We make this the default environment as it will be the easiest to run locally, as it avoids the need to specify the environment when running tasks. +We use features here because the `test` feature is shared between the `test` and `default` environments, and because the `default` environment cannot define dependencies directly (those live in the top-level tables). + We'll also add the `solve-group` `prod` to the environments, this will make sure that the dependencies are solved as if they were in the same environment. This will result in the `production` environment having the exact same versions of the dependencies as the `default` and `test` environment. This way we can be sure that the project will run in the same way in all environments. @@ -218,19 +188,14 @@ python 3.13.1 h4f43103_105_cp313 12.3 MiB conda python ``` ### Non default environments -When you want to have an environment that doesn't have the `default` feature, you can use the `--no-default-feature` flag. -This will result in the environment not having the `default` feature, and only the features you specify. +When you want to have an environment that doesn't have the `default` feature, you can use `no-default-feature`. +This will result in the environment only having the content you specify. A common use-case of this would be having an environment that can generate your documentation. -Let's add the `mkdocs` dependency to the `docs` feature. -```shell -pixi add --feature docs mkdocs -``` - -Now we can add the `docs` environment without the `default` feature. -```shell -pixi workspace environment add docs --feature docs --no-default-feature +Since the documentation tools are only needed in one environment, we define the dependency directly on the environment: +```toml +--8<-- "docs/source_files/pixi_tomls/multi-environment-docs.toml:docs-env" ``` If we run `pixi list -x -e docs` we can see that it only has the `mkdocs` dependency. diff --git a/docs/workspace/multi_environment.md b/docs/workspace/multi_environment.md index 345bbba82f..9b1e7f5808 100644 --- a/docs/workspace/multi_environment.md +++ b/docs/workspace/multi_environment.md @@ -21,10 +21,36 @@ There are a few things we wanted to keep in mind in the design: 4. **Single environment Activation**: The design should allow only one environment to be active at any given time, simplifying the resolution process and preventing conflicts. 5. **Fixed lock files**: It's crucial to preserve fixed lock files for consistency and predictability. Solutions must ensure reliability not just for authors but also for end-users, particularly at the time of lock file creation. -### Feature & Environment Set Definitions +### Environment & Feature Definitions -Introduce environment sets into the `pixi.toml` this describes environments based on features. Introduce features into the `pixi.toml` that can describe parts of environments. -As an environment goes beyond just `dependencies` the `feature` fields can be described by including the following fields: +Environments are defined in the `pixi.toml` under the `[environments]` table. +The content of an environment - dependencies, tasks and more - can be defined in two ways: + +- **directly on the environment**, for content that belongs to a single environment +- **through features**, reusable parts that can be shared between environments + +When something is only needed in one environment, define it directly on the environment: + +```toml title="Environments with their own dependencies" +[environments.lint.dependencies] +pre-commit = "*" + +[environments.test.dependencies] +pytest = "*" +``` + +When multiple environments share content, put the shared part in a feature and compose the environments from features: + +```toml title="Sharing dependencies through a feature" +[feature.python.dependencies] +python = "3.13.*" + +[environments] +dev = { features = ["python"] } +test = { features = ["python"] } +``` + +As an environment goes beyond just `dependencies`, both environments and features can be described by including the following fields: - `dependencies`: The conda package dependencies - `pypi-dependencies`: The pypi package dependencies @@ -81,12 +107,9 @@ target.osx-arm64 = {dependencies = {mlx = "x.y.z"}} ``` ```toml title="Define tasks as defaults of an environment" -[feature.test.tasks] +[environments.test.tasks] test = "pytest" -[environments] -test = ["test"] - # `pixi run test` == `pixi run --environment test test` ``` @@ -121,12 +144,10 @@ test_prod = {features = ["py39", "test"], solve-group = "prod"} python = "*" numpy = "*" -[feature.lint.dependencies] -pre-commit = "*" - -[environments] -# Create a custom environment which only has the `lint` feature (numpy isn't part of that env). -lint = {features = ["lint"], no-default-feature = true} +# Create a custom environment which only has the `pre-commit` dependency (numpy isn't part of that env). +[environments.lint] +no-default-feature = true +dependencies = { pre-commit = "*" } ``` ### lock file Structure @@ -442,33 +463,33 @@ Initial write-up of the proposal: [GitHub Gist by 0xbe7a](https://gist.github.co matplotlib-base = ">=3.8.2,<3.9" ipykernel = ">=6.28.0,<6.29" - [feature.cuda] + [environments.cuda] platforms = ["win-64-cuda", "linux-64-cuda"] channels = ["nvidia", {channel = "pytorch", priority = -1}] - [feature.cuda.tasks] + [environments.cuda.tasks] train-model = "python train.py --cuda" evaluate-model = "python test.py --cuda" - [feature.cuda.dependencies] + [environments.cuda.dependencies] pytorch-cuda = {version = "12.1.*", channel = "pytorch"} - [feature.mlx] + [environments.mlx] platforms = ["osx-arm64-mlx"] - [feature.mlx.tasks] + [environments.mlx.tasks] train-model = "python train.py --mlx" evaluate-model = "python test.py --mlx" - [feature.mlx.dependencies] + [environments.mlx.dependencies] mlx = ">=0.16.0,<0.17.0" + # The default environment takes its content from features, + # so the cpu variant is defined as a feature. [feature.cpu] platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"] [environments] - cuda = ["cuda"] - mlx = ["mlx"] default = ["cpu"] ```