Skip to content

Commit

Permalink
feat: Improve tool.pixi.project detection logic (#1127)
Browse files Browse the repository at this point in the history
@tdejager , as discussed, an improved detection of pixi project table in
`pyproject.toml `, encapsulating all non-pixi `pyproject.toml` logic
used during `init` into a struct.

plus minor cleanup (e.g. typo)

---------

Co-authored-by: Tim de Jager <tdejager89@gmail.com>
  • Loading branch information
olivier-lacroix and tdejager committed Apr 8, 2024
1 parent 4fecc12 commit 11e2cf1
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 57 deletions.
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

0 comments on commit 11e2cf1

Please sign in to comment.