diff --git a/Cargo.lock b/Cargo.lock index 3950c38bef..09f0fc0b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3514,7 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -5071,8 +5071,7 @@ dependencies = [ [[package]] name = "pyproject-toml" version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643af57c3f36ba90a8b53e972727d8092f7408a9ebfbaf4c3d2c17b07c58d835" +source = "git+https://github.com/olivier-lacroix/pyproject-toml-rs?rev=c9506f308db221180679c924bd4f201c3a0a58e0#c9506f308db221180679c924bd4f201c3a0a58e0" dependencies = [ "indexmap 2.9.0", "pep440_rs", diff --git a/Cargo.toml b/Cargo.toml index f98417cb6e..4f63d1d3b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ pathdiff = "0.2.3" pep440_rs = "0.7.3" pep508_rs = "0.9.2" percent-encoding = "2.3.1" -pyproject-toml = "0.13.4" +pyproject-toml = { git = "https://github.com/olivier-lacroix/pyproject-toml-rs", rev = "c9506f308db221180679c924bd4f201c3a0a58e0" } regex = "1.11.1" reqwest = { version = "0.12.12", default-features = false } reqwest-middleware = "0.4" diff --git a/crates/pixi_manifest/src/pyproject.rs b/crates/pixi_manifest/src/pyproject.rs index 2a912ad03f..55cf9c4101 100644 --- a/crates/pixi_manifest/src/pyproject.rs +++ b/crates/pixi_manifest/src/pyproject.rs @@ -9,7 +9,7 @@ use miette::{Diagnostic, IntoDiagnostic, Report, WrapErr}; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; use pixi_spec::PixiSpec; -use pyproject_toml::{self, pep735_resolve::Pep735Error, Contact}; +use pyproject_toml::{self, has_recursion::RecursionResolutionError, Contact}; use rattler_conda_types::{PackageName, ParseStrictness::Lenient, VersionSpec}; use thiserror::Error; use toml_span::Spanned; @@ -22,7 +22,7 @@ use crate::{ error::{DependencyError, GenericError}, manifests::PackageManifest, toml::{ - pyproject::{TomlContact, TomlDependencyGroups, TomlProject}, + pyproject::{TomlContact, TomlDependencyGroups, TomlOptionalDependencies, TomlProject}, ExternalPackageProperties, ExternalWorkspaceProperties, FromTomlStr, PyProjectToml, TomlManifest, }, @@ -97,11 +97,6 @@ impl PyProjectManifest { None } - /// Returns the project name as PEP508 name - fn package_name(&self) -> Option { - pep508_rs::PackageName::new(self.name()?.to_string()).ok() - } - fn tool(&self) -> Option<&Tool> { self.tool.as_ref() } @@ -124,19 +119,19 @@ impl PyProjectManifest { /// Returns optional dependencies from the `[project.optional-dependencies]` /// table - fn optional_dependencies(&self) -> Option>> { + fn optional_dependencies( + &self, + project_name: Option<&str>, + ) -> Option>, RecursionResolutionError>> { let project = self.project.project.as_ref()?; let optional_dependencies = project.optional_dependencies.as_ref()?; - Some( - optional_dependencies - .iter() - .map(|(k, v)| (k.clone(), v.iter().cloned().map(Spanned::take).collect())) - .collect(), - ) + Some(optional_dependencies.value.0.resolve(project_name)) } /// Returns dependency groups from the `[dependency-groups]` table - fn dependency_groups(&self) -> Option>, Pep735Error>> { + fn dependency_groups( + &self, + ) -> Option>, RecursionResolutionError>> { let dg = self.project.dependency_groups.as_ref()?; Some(dg.value.0.resolve()) } @@ -145,37 +140,25 @@ impl PyProjectManifest { /// dependencies and/or dependency groups: /// - one environment is created per group with the same name /// - each environment includes the feature of the same name - /// - it will also include other features inferred from any self references - /// to other groups of optional dependencies (but won't for dependency - /// groups, as recursion between groups is resolved upstream) - pub fn environments_from_extras(&self) -> Result>, Pep735Error> { + pub fn environments_from_dependency_groups( + &self, + ) -> Result>, RecursionResolutionError> { let mut environments = HashMap::new(); - if let Some(extras) = self.optional_dependencies() { - let pname = self.package_name(); - for (extra, reqs) in extras { - let mut features = vec![extra.to_string()]; - // Add any references to other groups of extra dependencies - for req in reqs.iter() { - if pname.as_ref() == Some(&req.name) { - for extra in &req.extras { - features.push(extra.to_string()) - } - } - } - // Environments can only contain number, strings and dashes - environments.insert(extra.replace('_', "-").clone(), features); - } - } - if let Some(groups) = self.dependency_groups().transpose()? { - for group in groups.into_keys() { - let normalised = group.replace('_', "-"); - // Nothing to do if a group of optional dependencies has the same name as the - // dependency group - if !environments.contains_key(&normalised) { - environments.insert(normalised.clone(), vec![normalised]); - } - } + let groups = self + // no need to pass project name to resolve recursions properly here, + // as only group names are used downstream + .optional_dependencies(None) + .transpose()? + .unwrap_or_default() + .into_iter() + .chain(self.dependency_groups().transpose()?.unwrap_or_default()); + + for (group, _) in groups { + let normalised = group.replace('_', "-"); + environments + .entry(normalised.clone()) + .or_insert_with(|| vec![group]); } Ok(environments) @@ -187,7 +170,7 @@ pub enum PyProjectToManifestError { #[error("Unsupported pep508 requirement: '{0}'")] DependencyError(Requirement, #[source] DependencyError), #[error(transparent)] - DependencyGroupError(#[from] Pep735Error), + DependencyGroupError(#[from] RecursionResolutionError), #[error(transparent)] TomlError(#[from] TomlError), } @@ -200,7 +183,7 @@ pub struct PyProjectFields { pub authors: Option>>, pub requires_python: Option>, pub dependencies: Option>>, - pub optional_dependencies: Option>>>, + pub optional_dependencies: Option>, } impl From for PyProjectFields { @@ -309,8 +292,12 @@ impl PyProjectManifest { let poetry = poetry.unwrap_or_default(); // Define an iterator over both optional dependencies and dependency groups - let pypi_dependency_groups = - Self::extract_dependency_groups(dependency_groups, project.optional_dependencies)?; + let project_name = project.name.map(Spanned::take); + let pypi_dependency_groups = Self::extract_dependency_groups( + dependency_groups, + project.optional_dependencies, + project_name.as_deref(), + )?; // Convert the TOML document into a pixi manifest. // TODO: would be nice to add license, license-file, readme, homepage, @@ -329,7 +316,7 @@ impl PyProjectManifest { .collect(); let (mut workspace_manifest, package_manifest, warnings) = pixi.into_workspace_manifest( ExternalWorkspaceProperties { - name: project.name.map(Spanned::take), + name: project_name, version: project .version .and_then(|v| v.take().to_string().parse().ok()) @@ -391,12 +378,7 @@ impl PyProjectManifest { } // For each group of optional dependency or dependency group, add pypi - // dependencies, filtering out self-references in optional dependencies - let project_name = workspace_manifest - .workspace - .name - .clone() - .and_then(|name| pep508_rs::PackageName::new(name).ok()); + // dependencies for (group, reqs) in pypi_dependency_groups { let feature_name = FeatureName::from(group.to_string()); let target = workspace_manifest @@ -406,16 +388,13 @@ impl PyProjectManifest { .targets .default_mut(); for requirement in reqs.iter() { - // filter out any self references in groups of extra dependencies - if project_name.as_ref() != Some(&requirement.name) { - target - .try_add_pep508_dependency( - requirement, - None, - DependencyOverwriteBehavior::Error, - ) - .map_err(|err| GenericError::new(format!("{}", err)))?; - } + target + .try_add_pep508_dependency( + requirement, + None, + DependencyOverwriteBehavior::Error, + ) + .map_err(|err| GenericError::new(format!("{}", err)))?; } } @@ -424,31 +403,28 @@ impl PyProjectManifest { fn extract_dependency_groups( dependency_groups: Option>, - optional_dependencies: Option>>>, + optional_dependencies: Option>, + project_name: Option<&str>, ) -> Result)>, TomlError> { - Ok(optional_dependencies - .map(|deps| { - deps.into_iter() - .map(|(group, reqs)| { - ( - group, - reqs.into_iter().map(Spanned::take).collect::>(), - ) - }) - .collect() - }) - .into_iter() - .chain( - dependency_groups - .map(|Spanned { span, value }| { - value.0.resolve().map_err(|err| { - GenericError::new(format!("{}", err)).with_span(span.into()) - }) - }) - .transpose()?, - ) - .flat_map(|map| map.into_iter()) - .collect::>()) + let mut result = Vec::new(); + + if let Some(Spanned { span, value }) = optional_dependencies { + let resolved = value + .0 + .resolve(project_name) + .map_err(|err| GenericError::new(err.to_string()).with_span(span.into()))?; + result.extend(resolved); + } + + if let Some(Spanned { span, value }) = dependency_groups { + let resolved = value + .0 + .resolve() + .map_err(|err| GenericError::new(err.to_string()).with_span(span.into()))?; + result.extend(resolved); + } + + Ok(result) } } diff --git a/crates/pixi_manifest/src/toml/pyproject.rs b/crates/pixi_manifest/src/toml/pyproject.rs index 74f373c9d5..0d96d58327 100644 --- a/crates/pixi_manifest/src/toml/pyproject.rs +++ b/crates/pixi_manifest/src/toml/pyproject.rs @@ -8,7 +8,8 @@ use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; use pixi_toml::{DeserializeAs, Same, TomlFromStr, TomlIndexMap, TomlWith}; use pyproject_toml::{ - BuildSystem, Contact, DependencyGroupSpecifier, DependencyGroups, License, Project, ReadMe, + BuildSystem, Contact, DependencyGroupSpecifier, DependencyGroups, License, + OptionalDependencies, Project, ReadMe, }; use toml_span::{ de_helpers::{expected, TableHelper}, @@ -154,7 +155,7 @@ pub struct TomlProject { /// Project dependencies pub dependencies: Option>>, /// Optional dependencies - pub optional_dependencies: Option>>>, + pub optional_dependencies: Option>, /// Specifies which fields listed by PEP 621 were intentionally unspecified /// so another tool can/will provide such metadata dynamically. pub dynamic: Option>>, @@ -213,12 +214,10 @@ impl TomlProject { dependencies: self .dependencies .map(|dependencies| dependencies.into_iter().map(Spanned::take).collect()), - optional_dependencies: self.optional_dependencies.map(|optional_dependencies| { - optional_dependencies - .into_iter() - .map(|(k, v)| (k, v.into_iter().map(Spanned::take).collect())) - .collect() - }), + optional_dependencies: self + .optional_dependencies + .map(Spanned::take) + .map(TomlOptionalDependencies::into_inner), dynamic: self .dynamic .map(|dynamic| dynamic.into_iter().map(Spanned::take).collect()), @@ -262,11 +261,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlProject { let dependencies = th .optional::>>>>("dependencies") .map(TomlWith::into_inner); - let optional_dependencies = th - .optional::>>>>>( - "optional-dependencies", - ) - .map(TomlWith::into_inner); + let optional_dependencies = th.optional("optional-dependencies"); let dynamic = th.optional("dynamic"); th.finalize(None)?; @@ -428,6 +423,32 @@ impl<'de> DeserializeAs<'de, Contact> for TomlContact { } } +/// A wrapper around [`OptionalDependencies`] that implements +/// [`toml_span::Deserialize`] and [`pixi_toml::DeserializeAs`]. +#[derive(Debug)] +pub struct TomlOptionalDependencies(pub OptionalDependencies); + +impl TomlOptionalDependencies { + pub fn into_inner(self) -> OptionalDependencies { + self.0 + } +} + +impl<'de> toml_span::Deserialize<'de> for TomlOptionalDependencies { + fn deserialize(value: &mut Value<'de>) -> Result { + Ok(Self(OptionalDependencies( + TomlWith::<_, TomlIndexMap>>>::deserialize(value)? + .into_inner(), + ))) + } +} + +impl<'de> DeserializeAs<'de, OptionalDependencies> for TomlOptionalDependencies { + fn deserialize_as(value: &mut Value<'de>) -> Result { + Self::deserialize(value).map(Self::into_inner) + } +} + /// A wrapper around [`DependencyGroups`] that implements /// [`toml_span::Deserialize`] and [`pixi_toml::DeserializeAs`]. #[derive(Debug)] diff --git a/docs/python/pyproject_toml.md b/docs/python/pyproject_toml.md index 1ce71fbac6..d4efefb533 100644 --- a/docs/python/pyproject_toml.md +++ b/docs/python/pyproject_toml.md @@ -142,7 +142,7 @@ platforms = ["linux-64"] # if executed on linux [tool.pixi.environments] default = {features = [], solve-group = "default"} test = {features = ["test"], solve-group = "default"} -all = {features = ["all", "test"], solve-group = "default"} +all = {features = ["all"], solve-group = "default"} ``` In this example, three environments will be created by pixi: diff --git a/src/cli/init.rs b/src/cli/init.rs index 60258b348a..8e7ff1b8cb 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -402,7 +402,9 @@ pub async fn execute(args: Args) -> miette::Result<()> { Some(name) => (name, false), None => (default_name.as_str(), true), }; - let environments = pyproject.environments_from_extras().into_diagnostic()?; + let environments = pyproject + .environments_from_dependency_groups() + .into_diagnostic()?; let rv = env .render_named_str( consts::PYPROJECT_MANIFEST,