Skip to content

feat: Resolve optional dependencies recursively #3646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
154 changes: 65 additions & 89 deletions crates/pixi_manifest/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
},
Expand Down Expand Up @@ -97,11 +97,6 @@ impl PyProjectManifest {
None
}

/// Returns the project name as PEP508 name
fn package_name(&self) -> Option<pep508_rs::PackageName> {
pep508_rs::PackageName::new(self.name()?.to_string()).ok()
}

fn tool(&self) -> Option<&Tool> {
self.tool.as_ref()
}
Expand All @@ -124,19 +119,19 @@ impl PyProjectManifest {

/// Returns optional dependencies from the `[project.optional-dependencies]`
/// table
fn optional_dependencies(&self) -> Option<IndexMap<String, Vec<Requirement>>> {
fn optional_dependencies(
&self,
project_name: Option<&str>,
) -> Option<Result<IndexMap<String, Vec<Requirement>>, 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<Result<IndexMap<String, Vec<Requirement>>, Pep735Error>> {
fn dependency_groups(
&self,
) -> Option<Result<IndexMap<String, Vec<Requirement>>, RecursionResolutionError>> {
let dg = self.project.dependency_groups.as_ref()?;
Some(dg.value.0.resolve())
}
Expand All @@ -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<HashMap<String, Vec<String>>, Pep735Error> {
pub fn environments_from_dependency_groups(
&self,
) -> Result<HashMap<String, Vec<String>>, 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)
Expand All @@ -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),
}
Expand All @@ -200,7 +183,7 @@ pub struct PyProjectFields {
pub authors: Option<Vec<Spanned<TomlContact>>>,
pub requires_python: Option<Spanned<VersionSpecifiers>>,
pub dependencies: Option<Vec<Spanned<Requirement>>>,
pub optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
pub optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
}

impl From<TomlProject> for PyProjectFields {
Expand Down Expand Up @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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)))?;
}
}

Expand All @@ -424,31 +403,28 @@ impl PyProjectManifest {

fn extract_dependency_groups(
dependency_groups: Option<Spanned<TomlDependencyGroups>>,
optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
project_name: Option<&str>,
) -> Result<Vec<(String, Vec<Requirement>)>, TomlError> {
Ok(optional_dependencies
.map(|deps| {
deps.into_iter()
.map(|(group, reqs)| {
(
group,
reqs.into_iter().map(Spanned::take).collect::<Vec<_>>(),
)
})
.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::<Vec<_>>())
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)
}
}

Expand Down
47 changes: 34 additions & 13 deletions crates/pixi_manifest/src/toml/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -154,7 +155,7 @@ pub struct TomlProject {
/// Project dependencies
pub dependencies: Option<Vec<Spanned<Requirement>>>,
/// Optional dependencies
pub optional_dependencies: Option<IndexMap<String, Vec<Spanned<Requirement>>>>,
pub optional_dependencies: Option<Spanned<TomlOptionalDependencies>>,
/// Specifies which fields listed by PEP 621 were intentionally unspecified
/// so another tool can/will provide such metadata dynamically.
pub dynamic: Option<Vec<Spanned<String>>>,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -262,11 +261,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlProject {
let dependencies = th
.optional::<TomlWith<_, Vec<Spanned<TomlFromStr<_>>>>>("dependencies")
.map(TomlWith::into_inner);
let optional_dependencies = th
.optional::<TomlWith<_, TomlIndexMap<_, Vec<Spanned<TomlFromStr<_>>>>>>(
"optional-dependencies",
)
.map(TomlWith::into_inner);
let optional_dependencies = th.optional("optional-dependencies");
let dynamic = th.optional("dynamic");

th.finalize(None)?;
Expand Down Expand Up @@ -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<Self, DeserError> {
Ok(Self(OptionalDependencies(
TomlWith::<_, TomlIndexMap<String, Vec<TomlFromStr<Requirement>>>>::deserialize(value)?
.into_inner(),
)))
}
}

impl<'de> DeserializeAs<'de, OptionalDependencies> for TomlOptionalDependencies {
fn deserialize_as(value: &mut Value<'de>) -> Result<OptionalDependencies, DeserError> {
Self::deserialize(value).map(Self::into_inner)
}
}

/// A wrapper around [`DependencyGroups`] that implements
/// [`toml_span::Deserialize`] and [`pixi_toml::DeserializeAs`].
#[derive(Debug)]
Expand Down
2 changes: 1 addition & 1 deletion docs/python/pyproject_toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading