Skip to content
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

feat: Improve tool.pixi.project detection logic #1127

Merged
merged 6 commits into from
Apr 8, 2024
Merged
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
16 changes: 8 additions & 8 deletions src/cli/init.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::config::Config;
use crate::environment::{get_up_to_date_prefix, LockFileUsage};
use crate::project::manifest::pyproject;
use crate::project::manifest::pyproject::PyProjectToml;
use crate::utils::conda_environment_file::CondaEnvFile;
use crate::{config::get_default_author, consts};
use crate::{FeatureName, Project};
Expand Down Expand Up @@ -64,7 +64,7 @@ platforms = {{ platforms }}
{%- if loop.first %}

[tool.pixi.environments]
default = { features = [], solve-group = "default" }
default = { solve-group = "default" }
{%- endif %}
{{env}} = { features = {{ features }}, solve-group = "default" }
{%- endfor %}
Expand Down Expand Up @@ -157,20 +157,20 @@ pub async fn execute(args: Args) -> miette::Result<()> {

// Inject a tool.pixi.project section into an existing pyproject.toml file if there is one without '[tool.pixi.project]'
if pyproject_manifest_path.is_file() {
let file = fs::read_to_string(pyproject_manifest_path.clone()).unwrap();
let file = fs::read_to_string(&pyproject_manifest_path).unwrap();

// Early exit if 'pyproject.toml' already contains a '[tool.pixi.project]' section
if file.contains("[tool.pixi.project]") {
// Early exit if 'pyproject.toml' already contains a '[tool.pixi.project]' table
if PyProjectToml::is_pixi_str(&file)? {
eprintln!(
"{}Nothing to do here: 'pyproject.toml' already contains a '[tool.pixi.project]' section.",
console::style(console::Emoji("🤔 ", "")).blue(),
);
return Ok(());
}

let pyproject = pyproject::pyproject(&file)?;
let name = pyproject.project.as_ref().unwrap().name.clone();
let environments = pyproject::environments_from_extras(&pyproject);
let pyproject = PyProjectToml::from(&file)?;
let name = pyproject.name();
let environments = pyproject.environments_from_extras();
let rv = env
.render_named_str(
consts::PYPROJECT_MANIFEST,
Expand Down
113 changes: 76 additions & 37 deletions src/project/manifest/pyproject.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use miette::Report;
use pep508_rs::VersionOrUrl;
use pyproject_toml::PyProjectToml;
use pyproject_toml::{self, Project};
use rattler_conda_types::{NamelessMatchSpec, PackageName, ParseStrictness::Lenient, VersionSpec};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::{
collections::{HashMap, HashSet},
str::FromStr,
};
use toml_edit;
use toml_edit::DocumentMut;

use crate::FeatureName;

Expand All @@ -20,7 +22,7 @@ use super::{
#[derive(Deserialize, Debug, Clone)]
pub struct PyProjectManifest {
#[serde(flatten)]
inner: PyProjectToml,
inner: pyproject_toml::PyProjectToml,
tool: Tool,
}

Expand All @@ -30,7 +32,7 @@ struct Tool {
}

impl std::ops::Deref for PyProjectManifest {
type Target = PyProjectToml;
type Target = pyproject_toml::PyProjectToml;

fn deref(&self) -> &Self::Target {
&self.inner
Expand Down Expand Up @@ -147,46 +149,83 @@ fn version_or_url_to_nameless_matchspec(
}
}

/// Builds a list of pixi environments from pyproject groups of extra dependencies:
/// - one environment is created per group of extra, with the same name as the group of extra
/// - each environment includes the feature of the same name as the group of extra
/// - it will also include other features inferred from any self references to other groups of extras
pub fn environments_from_extras(pyproject: &PyProjectToml) -> HashMap<String, Vec<String>> {
let mut environments = HashMap::new();
if let Some(Some(extras)) = &pyproject.project.as_ref().map(|p| &p.optional_dependencies) {
let pname = &pyproject
.project
.as_ref()
.map(|p| pep508_rs::PackageName::new(p.name.clone()).unwrap());
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().is_some_and(|n| n == &req.name) {
for extra in &req.extras {
features.push(extra.to_string())
}
/// A struct wrapping pyproject_toml::PyProjectToml
/// ensuring it has a project table
///
/// This is used during 'pixi init' to parse a potentially non-pixi 'pyproject.toml'
pub struct PyProjectToml {
inner: pyproject_toml::PyProjectToml,
}

impl PyProjectToml {
/// Parses a non-pixi pyproject.toml string into a PyProjectToml struct
/// making sure it contains a 'project' table
pub fn from(source: &str) -> Result<PyProjectToml, Report> {
match toml_edit::de::from_str::<pyproject_toml::PyProjectToml>(source)
.map_err(TomlError::from)
{
Err(e) => e.to_fancy("pyproject.toml", source),
Ok(pyproject) => {
// Make sure [project] exists in pyproject.toml,
// This will ensure project.name is defined
if pyproject.project.is_none() {
TomlError::NoProjectTable.to_fancy("pyproject.toml", source)
} else {
Ok(PyProjectToml { inner: pyproject })
}
}
environments.insert(extra.clone(), features);
}
}
environments
}

/// Parses a non-pixi pyproject.toml string.
pub fn pyproject(source: &str) -> Result<PyProjectToml, Report> {
match toml_edit::de::from_str::<PyProjectToml>(source).map_err(TomlError::from) {
Err(e) => e.to_fancy("pyproject.toml", source),
Ok(pyproject) => {
// Make sure [project] exists in pyproject.toml,
// This will ensure project.name is defined
if pyproject.project.is_none() {
TomlError::NoProjectTable.to_fancy("pyproject.toml", source)
} else {
Ok(pyproject)
pub fn name(&self) -> String {
self.project().name.clone()
}

pub fn project(&self) -> &Project {
self.inner.project.as_ref().unwrap()
}

/// Builds a list of pixi environments from pyproject groups of extra dependencies:
/// - one environment is created per group of extra, with the same name as the group of extra
/// - each environment includes the feature of the same name as the group of extra
/// - it will also include other features inferred from any self references to other groups of extras
pub fn environments_from_extras(&self) -> HashMap<String, Vec<String>> {
let mut environments = HashMap::new();
if let Some(extras) = &self.project().optional_dependencies {
let pname = pep508_rs::PackageName::new(self.name()).unwrap();
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 == req.name {
for extra in &req.extras {
features.push(extra.to_string())
}
}
}
environments.insert(extra.clone(), features);
}
}
environments
}

/// Checks whether a path is a valid `pyproject.toml` for use with pixi by checking if it
/// contains a `[tool.pixi.project]` item.
pub fn is_pixi(path: &PathBuf) -> bool {
let source = fs::read_to_string(path).unwrap();
Self::is_pixi_str(&source).unwrap_or(false)
}
/// Checks whether a string is a valid `pyproject.toml` for use with pixi by checking if it
/// contains a `[tool.pixi.project]` item.
pub fn is_pixi_str(source: &str) -> Result<bool, Report> {
match source.parse::<DocumentMut>().map_err(TomlError::from) {
Err(e) => e.to_fancy("pyproject.toml", source),
Ok(doc) => Ok(doc
.get("tool")
.and_then(|t| t.get("pixi"))
.and_then(|p| p.get("project"))
.is_some()),
}
}
}

Expand Down
15 changes: 3 additions & 12 deletions src/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use miette::{IntoDiagnostic, NamedSource};

use rattler_conda_types::{Channel, Platform, Version};
use reqwest_middleware::ClientWithMiddleware;
use std::fs;
use std::hash::Hash;

use rattler_virtual_packages::VirtualPackage;
Expand Down Expand Up @@ -41,7 +40,7 @@ pub use dependencies::Dependencies;
pub use environment::Environment;
pub use solve_group::SolveGroup;

use self::manifest::Environments;
use self::manifest::{pyproject::PyProjectToml, Environments};

/// The dependency types we support
#[derive(Debug, Copy, Clone)]
Expand Down Expand Up @@ -164,7 +163,7 @@ impl Project {
if let Some(project_toml) = project_toml {
if env_manifest_path != project_toml.to_string_lossy() {
tracing::warn!(
"Using mainfest {} from `PIXI_PROJECT_MANIFEST` rather than local {}",
"Using manifest {} from `PIXI_PROJECT_MANIFEST` rather than local {}",
env_manifest_path,
project_toml.to_string_lossy()
);
Expand Down Expand Up @@ -515,7 +514,7 @@ pub fn find_project_manifest() -> Option<PathBuf> {
if path.is_file() {
match *manifest {
PROJECT_MANIFEST => Some(path.to_path_buf()),
PYPROJECT_MANIFEST if is_valid_pixi_pyproject_toml(&path) => {
PYPROJECT_MANIFEST if PyProjectToml::is_pixi(&path) => {
Some(path.to_path_buf())
}
_ => None,
Expand All @@ -527,14 +526,6 @@ pub fn find_project_manifest() -> Option<PathBuf> {
})
}

/// Checks whether a path is a valid `pyproject.toml` for use with pixi file by checking if it
/// contains the `[tool.pixi.project]` section.
fn is_valid_pixi_pyproject_toml(path: &Path) -> bool {
fs::read_to_string(path)
.map(|content| content.contains("[tool.pixi.project]"))
.unwrap_or(false)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading