Skip to content

Commit 32d6c86

Browse files
Resolve optional dependencies recursively
1 parent d0146fb commit 32d6c86

File tree

5 files changed

+105
-107
lines changed

5 files changed

+105
-107
lines changed

Cargo.lock

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ pathdiff = "0.2.3"
6464
pep440_rs = "0.7.3"
6565
pep508_rs = "0.9.2"
6666
percent-encoding = "2.3.1"
67-
pyproject-toml = "0.13.4"
67+
pyproject-toml = { git = "https://github.com/olivier-lacroix/pyproject-toml-rs", rev = "c9506f308db221180679c924bd4f201c3a0a58e0" }
6868
regex = "1.11.1"
6969
reqwest = { version = "0.12.12", default-features = false }
7070
reqwest-middleware = "0.4"

crates/pixi_manifest/src/pyproject.rs

Lines changed: 65 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use miette::{Diagnostic, IntoDiagnostic, Report, WrapErr};
99
use pep440_rs::{Version, VersionSpecifiers};
1010
use pep508_rs::Requirement;
1111
use pixi_spec::PixiSpec;
12-
use pyproject_toml::{self, pep735_resolve::Pep735Error, Contact};
12+
use pyproject_toml::{self, has_recursion::RecursionResolutionError, Contact};
1313
use rattler_conda_types::{PackageName, ParseStrictness::Lenient, VersionSpec};
1414
use thiserror::Error;
1515
use toml_span::Spanned;
@@ -22,7 +22,7 @@ use crate::{
2222
error::{DependencyError, GenericError},
2323
manifests::PackageManifest,
2424
toml::{
25-
pyproject::{TomlContact, TomlDependencyGroups, TomlProject},
25+
pyproject::{TomlContact, TomlDependencyGroups, TomlOptionalDependencies, TomlProject},
2626
ExternalPackageProperties, ExternalWorkspaceProperties, FromTomlStr, PyProjectToml,
2727
TomlManifest,
2828
},
@@ -97,11 +97,6 @@ impl PyProjectManifest {
9797
None
9898
}
9999

100-
/// Returns the project name as PEP508 name
101-
fn package_name(&self) -> Option<pep508_rs::PackageName> {
102-
pep508_rs::PackageName::new(self.name()?.to_string()).ok()
103-
}
104-
105100
fn tool(&self) -> Option<&Tool> {
106101
self.tool.as_ref()
107102
}
@@ -124,19 +119,19 @@ impl PyProjectManifest {
124119

125120
/// Returns optional dependencies from the `[project.optional-dependencies]`
126121
/// table
127-
fn optional_dependencies(&self) -> Option<IndexMap<String, Vec<Requirement>>> {
122+
fn optional_dependencies(
123+
&self,
124+
project_name: Option<&str>,
125+
) -> Option<Result<IndexMap<String, Vec<Requirement>>, RecursionResolutionError>> {
128126
let project = self.project.project.as_ref()?;
129127
let optional_dependencies = project.optional_dependencies.as_ref()?;
130-
Some(
131-
optional_dependencies
132-
.iter()
133-
.map(|(k, v)| (k.clone(), v.iter().cloned().map(Spanned::take).collect()))
134-
.collect(),
135-
)
128+
Some(optional_dependencies.value.0.resolve(project_name))
136129
}
137130

138131
/// Returns dependency groups from the `[dependency-groups]` table
139-
fn dependency_groups(&self) -> Option<Result<IndexMap<String, Vec<Requirement>>, Pep735Error>> {
132+
fn dependency_groups(
133+
&self,
134+
) -> Option<Result<IndexMap<String, Vec<Requirement>>, RecursionResolutionError>> {
140135
let dg = self.project.dependency_groups.as_ref()?;
141136
Some(dg.value.0.resolve())
142137
}
@@ -145,37 +140,25 @@ impl PyProjectManifest {
145140
/// dependencies and/or dependency groups:
146141
/// - one environment is created per group with the same name
147142
/// - each environment includes the feature of the same name
148-
/// - it will also include other features inferred from any self references
149-
/// to other groups of optional dependencies (but won't for dependency
150-
/// groups, as recursion between groups is resolved upstream)
151-
pub fn environments_from_extras(&self) -> Result<HashMap<String, Vec<String>>, Pep735Error> {
143+
pub fn environments_from_dependency_groups(
144+
&self,
145+
) -> Result<HashMap<String, Vec<String>>, RecursionResolutionError> {
152146
let mut environments = HashMap::new();
153-
if let Some(extras) = self.optional_dependencies() {
154-
let pname = self.package_name();
155-
for (extra, reqs) in extras {
156-
let mut features = vec![extra.to_string()];
157-
// Add any references to other groups of extra dependencies
158-
for req in reqs.iter() {
159-
if pname.as_ref() == Some(&req.name) {
160-
for extra in &req.extras {
161-
features.push(extra.to_string())
162-
}
163-
}
164-
}
165-
// Environments can only contain number, strings and dashes
166-
environments.insert(extra.replace('_', "-").clone(), features);
167-
}
168-
}
169147

170-
if let Some(groups) = self.dependency_groups().transpose()? {
171-
for group in groups.into_keys() {
172-
let normalised = group.replace('_', "-");
173-
// Nothing to do if a group of optional dependencies has the same name as the
174-
// dependency group
175-
if !environments.contains_key(&normalised) {
176-
environments.insert(normalised.clone(), vec![normalised]);
177-
}
178-
}
148+
let groups = self
149+
// no need to pass project name to resolve recursions properly here,
150+
// as only group names are used downstream
151+
.optional_dependencies(None)
152+
.transpose()?
153+
.unwrap_or_default()
154+
.into_iter()
155+
.chain(self.dependency_groups().transpose()?.unwrap_or_default());
156+
157+
for (group, _) in groups {
158+
let normalised = group.replace('_', "-");
159+
environments
160+
.entry(normalised.clone())
161+
.or_insert_with(|| vec![group]);
179162
}
180163

181164
Ok(environments)
@@ -187,7 +170,7 @@ pub enum PyProjectToManifestError {
187170
#[error("Unsupported pep508 requirement: '{0}'")]
188171
DependencyError(Requirement, #[source] DependencyError),
189172
#[error(transparent)]
190-
DependencyGroupError(#[from] Pep735Error),
173+
DependencyGroupError(#[from] RecursionResolutionError),
191174
#[error(transparent)]
192175
TomlError(#[from] TomlError),
193176
}
@@ -200,7 +183,7 @@ pub struct PyProjectFields {
200183
pub authors: Option<Vec<Spanned<TomlContact>>>,
201184
pub requires_python: Option<Spanned<VersionSpecifiers>>,
202185
pub dependencies: Option<Vec<Spanned<Requirement>>>,
203-
pub optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
186+
pub optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
204187
}
205188

206189
impl From<TomlProject> for PyProjectFields {
@@ -309,8 +292,12 @@ impl PyProjectManifest {
309292
let poetry = poetry.unwrap_or_default();
310293

311294
// Define an iterator over both optional dependencies and dependency groups
312-
let pypi_dependency_groups =
313-
Self::extract_dependency_groups(dependency_groups, project.optional_dependencies)?;
295+
let project_name = project.name.map(Spanned::take);
296+
let pypi_dependency_groups = Self::extract_dependency_groups(
297+
dependency_groups,
298+
project.optional_dependencies,
299+
project_name.as_deref(),
300+
)?;
314301

315302
// Convert the TOML document into a pixi manifest.
316303
// TODO: would be nice to add license, license-file, readme, homepage,
@@ -329,7 +316,7 @@ impl PyProjectManifest {
329316
.collect();
330317
let (mut workspace_manifest, package_manifest, warnings) = pixi.into_workspace_manifest(
331318
ExternalWorkspaceProperties {
332-
name: project.name.map(Spanned::take),
319+
name: project_name,
333320
version: project
334321
.version
335322
.and_then(|v| v.take().to_string().parse().ok())
@@ -391,12 +378,7 @@ impl PyProjectManifest {
391378
}
392379

393380
// For each group of optional dependency or dependency group, add pypi
394-
// dependencies, filtering out self-references in optional dependencies
395-
let project_name = workspace_manifest
396-
.workspace
397-
.name
398-
.clone()
399-
.and_then(|name| pep508_rs::PackageName::new(name).ok());
381+
// dependencies
400382
for (group, reqs) in pypi_dependency_groups {
401383
let feature_name = FeatureName::from(group.to_string());
402384
let target = workspace_manifest
@@ -406,16 +388,13 @@ impl PyProjectManifest {
406388
.targets
407389
.default_mut();
408390
for requirement in reqs.iter() {
409-
// filter out any self references in groups of extra dependencies
410-
if project_name.as_ref() != Some(&requirement.name) {
411-
target
412-
.try_add_pep508_dependency(
413-
requirement,
414-
None,
415-
DependencyOverwriteBehavior::Error,
416-
)
417-
.map_err(|err| GenericError::new(format!("{}", err)))?;
418-
}
391+
target
392+
.try_add_pep508_dependency(
393+
requirement,
394+
None,
395+
DependencyOverwriteBehavior::Error,
396+
)
397+
.map_err(|err| GenericError::new(format!("{}", err)))?;
419398
}
420399
}
421400

@@ -424,31 +403,28 @@ impl PyProjectManifest {
424403

425404
fn extract_dependency_groups(
426405
dependency_groups: Option<Spanned<TomlDependencyGroups>>,
427-
optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
406+
optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
407+
project_name: Option<&str>,
428408
) -> Result<Vec<(String, Vec<Requirement>)>, TomlError> {
429-
Ok(optional_dependencies
430-
.map(|deps| {
431-
deps.into_iter()
432-
.map(|(group, reqs)| {
433-
(
434-
group,
435-
reqs.into_iter().map(Spanned::take).collect::<Vec<_>>(),
436-
)
437-
})
438-
.collect()
439-
})
440-
.into_iter()
441-
.chain(
442-
dependency_groups
443-
.map(|Spanned { span, value }| {
444-
value.0.resolve().map_err(|err| {
445-
GenericError::new(format!("{}", err)).with_span(span.into())
446-
})
447-
})
448-
.transpose()?,
449-
)
450-
.flat_map(|map| map.into_iter())
451-
.collect::<Vec<_>>())
409+
let mut result = Vec::new();
410+
411+
if let Some(Spanned { span, value }) = optional_dependencies {
412+
let resolved = value
413+
.0
414+
.resolve(project_name)
415+
.map_err(|err| GenericError::new(err.to_string()).with_span(span.into()))?;
416+
result.extend(resolved);
417+
}
418+
419+
if let Some(Spanned { span, value }) = dependency_groups {
420+
let resolved = value
421+
.0
422+
.resolve()
423+
.map_err(|err| GenericError::new(err.to_string()).with_span(span.into()))?;
424+
result.extend(resolved);
425+
}
426+
427+
Ok(result)
452428
}
453429
}
454430

crates/pixi_manifest/src/toml/pyproject.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use pep440_rs::{Version, VersionSpecifiers};
88
use pep508_rs::Requirement;
99
use pixi_toml::{DeserializeAs, Same, TomlFromStr, TomlIndexMap, TomlWith};
1010
use pyproject_toml::{
11-
BuildSystem, Contact, DependencyGroupSpecifier, DependencyGroups, License, Project, ReadMe,
11+
BuildSystem, Contact, DependencyGroupSpecifier, DependencyGroups, License,
12+
OptionalDependencies, Project, ReadMe,
1213
};
1314
use toml_span::{
1415
de_helpers::{expected, TableHelper},
@@ -154,7 +155,7 @@ pub struct TomlProject {
154155
/// Project dependencies
155156
pub dependencies: Option<Vec<Spanned<Requirement>>>,
156157
/// Optional dependencies
157-
pub optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
158+
pub optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
158159
/// Specifies which fields listed by PEP 621 were intentionally unspecified
159160
/// so another tool can/will provide such metadata dynamically.
160161
pub dynamic: Option<Vec<Spanned<String>>>,
@@ -213,12 +214,10 @@ impl TomlProject {
213214
dependencies: self
214215
.dependencies
215216
.map(|dependencies| dependencies.into_iter().map(Spanned::take).collect()),
216-
optional_dependencies: self.optional_dependencies.map(|optional_dependencies| {
217-
optional_dependencies
218-
.into_iter()
219-
.map(|(k, v)| (k, v.into_iter().map(Spanned::take).collect()))
220-
.collect()
221-
}),
217+
optional_dependencies: self
218+
.optional_dependencies
219+
.map(Spanned::take)
220+
.map(TomlOptionalDependencies::into_inner),
222221
dynamic: self
223222
.dynamic
224223
.map(|dynamic| dynamic.into_iter().map(Spanned::take).collect()),
@@ -262,11 +261,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlProject {
262261
let dependencies = th
263262
.optional::<TomlWith<_, Vec<Spanned<TomlFromStr<_>>>>>("dependencies")
264263
.map(TomlWith::into_inner);
265-
let optional_dependencies = th
266-
.optional::<TomlWith<_, TomlIndexMap<_, Vec<Spanned<TomlFromStr<_>>>>>>(
267-
"optional-dependencies",
268-
)
269-
.map(TomlWith::into_inner);
264+
let optional_dependencies = th.optional("optional-dependencies");
270265
let dynamic = th.optional("dynamic");
271266

272267
th.finalize(None)?;
@@ -428,6 +423,32 @@ impl<'de> DeserializeAs<'de, Contact> for TomlContact {
428423
}
429424
}
430425

426+
/// A wrapper around [`OptionalDependencies`] that implements
427+
/// [`toml_span::Deserialize`] and [`pixi_toml::DeserializeAs`].
428+
#[derive(Debug)]
429+
pub struct TomlOptionalDependencies(pub OptionalDependencies);
430+
431+
impl TomlOptionalDependencies {
432+
pub fn into_inner(self) -> OptionalDependencies {
433+
self.0
434+
}
435+
}
436+
437+
impl<'de> toml_span::Deserialize<'de> for TomlOptionalDependencies {
438+
fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
439+
Ok(Self(OptionalDependencies(
440+
TomlWith::<_, TomlIndexMap<String, Vec<TomlFromStr<Requirement>>>>::deserialize(value)?
441+
.into_inner(),
442+
)))
443+
}
444+
}
445+
446+
impl<'de> DeserializeAs<'de, OptionalDependencies> for TomlOptionalDependencies {
447+
fn deserialize_as(value: &mut Value<'de>) -> Result<OptionalDependencies, DeserError> {
448+
Self::deserialize(value).map(Self::into_inner)
449+
}
450+
}
451+
431452
/// A wrapper around [`DependencyGroups`] that implements
432453
/// [`toml_span::Deserialize`] and [`pixi_toml::DeserializeAs`].
433454
#[derive(Debug)]

src/cli/init.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,9 @@ pub async fn execute(args: Args) -> miette::Result<()> {
402402
Some(name) => (name, false),
403403
None => (default_name.as_str(), true),
404404
};
405-
let environments = pyproject.environments_from_extras().into_diagnostic()?;
405+
let environments = pyproject
406+
.environments_from_dependency_groups()
407+
.into_diagnostic()?;
406408
let rv = env
407409
.render_named_str(
408410
consts::PYPROJECT_MANIFEST,

0 commit comments

Comments
 (0)