Skip to content

Commit

Permalink
feat: improve R recipe generator (#949)
Browse files Browse the repository at this point in the history
  • Loading branch information
wolfv authored Jun 26, 2024
1 parent fef395d commit 89dbf68
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 33 deletions.
118 changes: 90 additions & 28 deletions src/recipe_generator/cran.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
use itertools::Itertools;
use miette::IntoDiagnostic;
use rattler_digest::{compute_bytes_digest, Sha256Hash};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use url::Url;

use crate::recipe_generator::serialize::{self, SourceElement};
use crate::recipe_generator::{
serialize::{self, ScriptTest, SourceElement, Test},
write_recipe,
};
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
pub struct PackageInfo {
Expand All @@ -13,7 +19,7 @@ pub struct PackageInfo {
pub Author: String,
pub Maintainer: String,
pub License: String,
pub URL: String,
pub URL: Option<String>,
pub NeedsCompilation: String,
pub Packaged: Packaged,
pub Repository: String,
Expand Down Expand Up @@ -65,7 +71,7 @@ pub struct Commit {
pub struct Maintainer {
pub name: String,
pub email: String,
pub login: String,
pub login: Option<String>,
}

#[allow(non_snake_case)]
Expand All @@ -76,23 +82,34 @@ pub struct Dependency {
pub role: String,
}

fn map_license(license: &str) -> String {
fn map_license(license: &str) -> (Option<String>, Option<String>) {
// replace `|` with ` OR `
// map GPL-3 to GPL-3.0-only
// map GPL-2 to GPL-2.0-only

// split at `+`
let (license, file) = license.rsplit_once('+').unwrap_or((license, ""));

let license_replacements = [
("|", " OR "),
("GPL-3", "GPL-3.0-only"),
("GPL-2", "GPL-2.0-only"),
("GPL (>= 3)", "GPL-3.0-or-later"),
("GPL (>= 2)", "GPL-2.0-or-later"),
("BSD_3_Clause", "BSD-3-Clause"),
];

let mut res = license.to_string();
for (from, to) in license_replacements.iter() {
res = res.replace(from, to);
}
res

if file.trim().starts_with("file") {
let file = file.split_whitespace().last().unwrap();
(Some(res), Some(file.to_string()))
} else {
(Some(res), None)
}
}

fn format_r_package(package: &str, version: Option<&String>) -> String {
Expand All @@ -105,7 +122,14 @@ fn format_r_package(package: &str, version: Option<&String>) -> String {
res
}

pub async fn generate_r_recipe(package: &str) -> miette::Result<()> {
pub async fn fetch_package_sha256sum(url: &Url) -> Result<Sha256Hash, miette::Error> {
let client = reqwest::Client::new();
let response = client.get(url.clone()).send().await.into_diagnostic()?;
let bytes = response.bytes().await.into_diagnostic()?;
Ok(compute_bytes_digest::<Sha256>(&bytes))
}

pub async fn generate_r_recipe(package: &str, write: bool) -> miette::Result<()> {
eprintln!("Generating R recipe for {}", package);
let package_info = reqwest::get(&format!(
"https://cran.r-universe.dev/api/packages/{}",
Expand All @@ -121,19 +145,53 @@ pub async fn generate_r_recipe(package: &str) -> miette::Result<()> {

recipe.package.name = format_r_package(&package_info.Package.to_lowercase(), None);
recipe.package.version = package_info.Version.clone();

let url = Url::parse(&format!(
"https://cran.r-project.org/src/contrib/{}",
package_info._file
))
.expect("Failed to parse URL");

let sha256 = fetch_package_sha256sum(&url).await?;

let source = SourceElement {
url: format!(
"https://cran.r-project.org/src/contrib/{}",
package_info._file
),
md5: Some(package_info.MD5sum.clone()),
sha256: None,
url: url.to_string(),
md5: None,
sha256: Some(format!("{:x}", sha256)),
};
recipe.source.push(source);

recipe.build.script = "R CMD INSTALL --build .".to_string();

let build_requirements = vec![
"${{ compiler('c') }}".to_string(),
"${{ compiler('cxx') }}".to_string(),
"make".to_string(),
];

if package_info.NeedsCompilation == "yes" {
recipe.requirements.build.extend(build_requirements.clone());
}

let builtins = [
"utils",
"stats",
"graphics",
"grDevices",
"datasets",
"methods",
"base",
];

recipe.requirements.host = vec!["r-base".to_string()];
recipe.requirements.run = vec!["r-base".to_string()];

for dep in package_info._dependencies.iter() {
// skip builtins
if builtins.contains(&dep.package.as_str()) {
continue;
}

if dep.package == "R" {
// get r-base
let rbase = format_r_package("base", dep.version.as_ref());
Expand All @@ -150,17 +208,13 @@ pub async fn generate_r_recipe(package: &str) -> miette::Result<()> {
.requirements
.run
.push(format_r_package(&dep.package, dep.version.as_ref()));
}
if dep.role == "LinkingTo" {
recipe
.requirements
.build
.push("${{ compiler('c') }}".to_string());
recipe
.requirements
.build
.push("${{ compiler('cxx') }}".to_string());
recipe.requirements.build.push("make".to_string());
.host
.push(format_r_package(&dep.package, dep.version.as_ref()));
}
if dep.role == "LinkingTo" {
recipe.requirements.build.extend(build_requirements.clone());
}
if dep.role == "Suggests" {
recipe.requirements.run.push(format!(
Expand All @@ -175,22 +229,26 @@ pub async fn generate_r_recipe(package: &str) -> miette::Result<()> {
recipe.requirements.build = recipe.requirements.build.into_iter().unique().collect();
recipe.requirements.run = recipe.requirements.run.into_iter().unique().collect();

recipe.about.homepage = Some(package_info.URL.clone());
recipe.about.homepage = package_info.URL.clone();
recipe.about.summary = Some(package_info.Title.clone());
recipe.about.description = Some(package_info.Description.clone());
recipe.about.license = Some(map_license(&package_info.License));
(recipe.about.license, recipe.about.license_file) = map_license(&package_info.License);
recipe.about.repository = Some(package_info._upstream.clone());
if url::Url::parse(&package_info._pkgdocs).is_ok() {
recipe.about.documentation = Some(package_info._pkgdocs.clone());
}

// ??
// recipe.about.license_file = Some("LICENSE".to_string());
recipe.tests.push(Test::Script(ScriptTest {
script: vec![format!(
"Rscript -e 'library(\"{}\")'",
package_info.Package
)],
}));

let recipe = format!("{}", recipe);
let recipe_str = format!("{}", recipe);

let mut final_recipe = String::new();
for line in recipe.lines() {
for line in recipe_str.lines() {
if line.contains("SUGGEST") {
final_recipe.push_str(&format!(
"{} # suggested\n",
Expand All @@ -201,7 +259,11 @@ pub async fn generate_r_recipe(package: &str) -> miette::Result<()> {
}
}

print!("{}", final_recipe);
if write {
write_recipe(&recipe.package.name, &final_recipe).into_diagnostic()?;
} else {
print!("{}", final_recipe);
}

Ok(())
}
9 changes: 7 additions & 2 deletions src/recipe_generator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod pypi;
mod serialize;

use cran::generate_r_recipe;
pub use serialize::write_recipe;

use self::pypi::generate_pypi_recipe;

Expand All @@ -27,13 +28,17 @@ pub struct GenerateRecipeOpts {
pub source: Source,
/// Name of the package to generate
pub package: String,

/// Whether to write the recipe to a folder
#[arg(short, long)]
pub write: bool,
}

/// Generate a recipe for a package
pub async fn generate_recipe(args: GenerateRecipeOpts) -> miette::Result<()> {
match args.source {
Source::Pypi => generate_pypi_recipe(&args.package).await?,
Source::Cran => generate_r_recipe(&args.package).await?,
Source::Pypi => generate_pypi_recipe(&args.package, args.write).await?,
Source::Cran => generate_r_recipe(&args.package, args.write).await?,
}

Ok(())
Expand Down
10 changes: 8 additions & 2 deletions src/recipe_generator/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use tokio::io::AsyncWriteExt;

use crate::recipe_generator::serialize;

use super::write_recipe;

#[derive(Deserialize)]
struct CondaPyPiNameMapping {
conda_name: String,
Expand Down Expand Up @@ -75,7 +77,7 @@ async fn pypi_requirement(req: &Requirement) -> miette::Result<String> {
Ok(res)
}

pub async fn generate_pypi_recipe(package: &str) -> miette::Result<()> {
pub async fn generate_pypi_recipe(package: &str, write: bool) -> miette::Result<()> {
let client = reqwest::Client::new();
let client_with_middlewares = reqwest_middleware::ClientBuilder::new(client).build();
let package_sources =
Expand Down Expand Up @@ -224,7 +226,11 @@ pub async fn generate_pypi_recipe(package: &str) -> miette::Result<()> {
res.push('\n');
}

print!("{}", res);
if write {
write_recipe(package, &res).into_diagnostic()?;
} else {
print!("{}", res);
}

Ok(())
}
37 changes: 36 additions & 1 deletion src/recipe_generator/serialize.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, path::PathBuf};

use indexmap::IndexMap;
use serde::Serialize;
Expand All @@ -15,6 +15,7 @@ pub struct SourceElement {
#[derive(Default, Debug, Serialize)]
pub struct Build {
pub script: String,
#[serde(skip_serializing_if = "Python::is_default")]
pub python: Python,
}

Expand All @@ -24,6 +25,12 @@ pub struct Python {
pub entry_points: Vec<String>,
}

impl Python {
fn is_default(&self) -> bool {
self.entry_points.is_empty()
}
}

#[derive(Default, Debug, Serialize)]
pub struct About {
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -48,13 +55,25 @@ pub struct Package {
pub version: String,
}

#[derive(Default, Debug, Serialize)]
pub struct ScriptTest {
pub script: Vec<String>,
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Test {
Script(ScriptTest),
}

#[derive(Default, Debug, Serialize)]
pub struct Recipe {
pub context: IndexMap<String, String>,
pub package: Package,
pub source: Vec<SourceElement>,
pub build: Build,
pub requirements: Requirements,
pub tests: Vec<Test>,
pub about: About,
}

Expand Down Expand Up @@ -84,3 +103,19 @@ impl fmt::Display for Recipe {
Ok(())
}
}

/// Write a recipe to "{package_name}/recipe.yaml"
pub fn write_recipe(package_name: &str, recipe: &str) -> std::io::Result<()> {
let path = PathBuf::from(&format!("{}/recipe.yaml", &package_name));
fs_err::create_dir_all(path.parent().unwrap())?;

if path.exists() {
// move to backup
let backup_path = path.with_extension("yaml.bak");
fs_err::rename(&path, backup_path)?;
}

println!("Writing recipe to {}", path.display());

fs_err::write(path, recipe)
}

0 comments on commit 89dbf68

Please sign in to comment.