libpybuild 0.0.2

Python src+dependency bundler library
Documentation
use std::{
  fmt, fs,
  io::prelude::*,
  path,
  process::{Command, ExitStatus},
};

use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use log::{debug, info};
use miniserde::{Deserialize, Serialize};

// Settings for stripping from file transferred
// #[napi_derive::napi]
#[derive(ValueEnum, Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum PackageManager {
  Pip,
  Pipenv,
  Poetry,
}

impl fmt::Display for PackageManager {
  fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    write!(formatter, "{}", format!("{:?}", self).to_lowercase())
  }
}

/// Installs Python dependencies
// #[napi_derive::napi(object)]
#[derive(Parser, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct InstallOptions {
  /// Install dependencies from the given requirements file.
  #[clap(short, long)]
  pub extra_args: Option<String>,

  /// Install development dependencies.
  #[clap(short, long, value_enum)]
  pub package_manager: PackageManager,

  /// Install packages into <TARGET>.
  /// By default this will not replace existing files/folders in <TARGET>.
  /// Use `--extra-args='--upgrade'` to replace existing packages in <TARGET> with new versions.
  #[clap(short, long)]
  pub target: Option<String>,
}

impl InstallOptions {
  pub fn new() -> Self {
    InstallOptions {
      extra_args: None,
      package_manager: PackageManager::Pip,
      target: None,
    }
  }
}

impl Default for InstallOptions {
  fn default() -> Self {
    Self::new()
  }
}

pub trait Install {
  fn execute(&self) -> Result<()>;
}

fn get_target_dir(target: &Option<String>) -> String {
  let working_dir = path::Path::new(".pybuild");

  let default_target_path = &working_dir.join("site-packages");
  let default_target = default_target_path.to_str().unwrap();

  let _target = match target {
    Some(target) => target.to_owned(),
    None => default_target.to_string(),
  };

  // Save --target to file for the bundler to use
  if !working_dir.exists() {
    fs::create_dir_all(working_dir).unwrap();
  }
  let target_file = &working_dir.join(".target");
  let mut file = fs::File::create(target_file).unwrap();
  file.write_all(_target.as_bytes()).unwrap();

  _target
}

impl Install for InstallOptions {
  // TODO: this should return a result and let the CLI main() handle it
  fn execute(&self) -> Result<()> {
    let pkgr = self.package_manager;

    // --target
    let target = get_target_dir(&self.target);
    let target = target.as_ref(); // TODO - Ugh

    // --extra-args
    let extra_args = self
      .extra_args
      .as_ref()
      .map_or_else(Vec::new, |x| x.split_whitespace().collect());

    let requirements_file = "requirements.txt";
    let requirements_path = path::Path::new(requirements_file);

    match pkgr {
      PackageManager::Pip => {
        let mut args = vec!["install", "-r", requirements_file, "-t", target];
        args.extend_from_slice(&extra_args);
        shell_execute(&pkgr.to_string(), &args)?;
      }
      PackageManager::Pipenv => {
        // 1. pipenv lock --requirements
        let req_file_args = vec!["lock", "--requirements"];
        let _ = shell_stdout_to_file(&pkgr.to_string(), &req_file_args, requirements_path)?;

        // 2. pip install -r requirements.txt ... <extra_args>
        let mut args = vec!["run", "pip", "install", "-r", requirements_file, "-t", target];
        args.extend_from_slice(&extra_args);
        shell_execute(&pkgr.to_string(), &args)?;
      }
      PackageManager::Poetry => {
        // 1. poetry export -f requirements.txt --output requirements.txt
        let req_file_args = vec![
          "export",
          "-f",
          requirements_file,
          "--without-hashes",
          "--output",
          requirements_file,
        ];
        shell_execute(&pkgr.to_string(), &req_file_args)?;

        // 2. pip install -r requirements.txt ... <extra_args>
        let mut args = vec!["run", "pip", "install", "-r", requirements_file, "-t", target];
        args.extend_from_slice(&extra_args);
        shell_execute(&pkgr.to_string(), &args)?;
      }
    }
    Ok(())
  }
}

// Shell out command to execute <packagemanager> <args>
// i.e. - `pip install -r requirements.txt -t <target>
fn shell_execute(command: &str, args: &[&str]) -> Result<ExitStatus> {
  debug!("cmd: {command}\nargs: {:?}", args);
  let output = Command::new(command)
    .args(args)
    // TODO - how to stream without breaking package install
    // .stdout(Stdio::inherit())
    // .stderr(Stdio::inherit())
    .output()?;

  let stdout = String::from_utf8_lossy(&output.stdout).to_string();
  info!("stdout: {stdout}");

  let stderr = String::from_utf8_lossy(&output.stderr).to_string();
  info!("stderr: {stderr}");

  Ok(output.status)
}

// Shell out command and save stdout to <requirements_path>
// Used to generate `requirements.txt` file from pipenv and poetry
fn shell_stdout_to_file(command: &str, args: &[&str], requirements_path: &path::Path) -> Result<ExitStatus> {
  let output = Command::new(command)
    .args(args)
    // TODO - how to stream without breaking package install
    // .stderr(Stdio::piped())
    // .stdout(Stdio::piped())
    .output()?;

  let stdout = String::from_utf8_lossy(&output.stdout).to_string();
  info!("{stdout}");

  fs::write(requirements_path, stdout)
    .with_context(|| format!("Failed to requirements to {:?}", &requirements_path))?;

  Ok(output.status)
}