libpybuild 0.0.2

Python src+dependency bundler library
Documentation
use std::{
  fs,
  path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use clap::Parser;
use miniserde::{Deserialize, Serialize};
use regex::{RegexSet, RegexSetBuilder};

use super::{transfer, zip};

// Default working directory
static WORKING_DIR: &str = ".pybuild";

/// Creates Python source code and dependency bundle
// #[napi_derive::napi(object)]
#[derive(Parser, Debug, Serialize, Deserialize)]
pub struct BundleOptions {
  /// RegEx pattern used to exclude files from the bundle
  #[clap(short = 'x', long)]
  pub exclude: Option<String>,

  /// `<filepath>.<function_name>` style path to function entrypoint
  // #[clap(short, long)]
  // pub entrypoint: Option<String>,

  /// RegEx pattern used to include files in the bundle
  #[clap(short, long)]
  pub include: Option<String>,

  /// Output as zip archive suitable for AWS Lambda layer
  #[clap(long)]
  pub lambda_layer: bool,

  /// Minify the output (sets all --strip-* flags)
  #[clap(short, long)]
  pub minify: bool,

  /// Output directory
  #[clap(long, default_value = WORKING_DIR)]
  pub out_dir: String,

  /// Output filename (for zip or lambda-layer archive)
  #[clap(long)]
  pub out_file: Option<String>,

  /// Path to directory that contains the python package dependencies to be bundled in the artifact
  #[clap(short, long)]
  pub packages: Option<String>,

  /// Path to source file(s) to bundle
  #[clap(short, long, default_value = ".")]
  pub src: String,

  /// Remove comments and docstrings
  #[clap(long)]
  pub strip_comments: bool,

  /// Remove whitespace (sans indentions)
  #[clap(long)]
  pub strip_whitespace: bool,

  /// Output as zip archive
  #[clap(short, long)]
  pub zip: bool,
}

impl BundleOptions {
  pub fn new() -> Self {
    BundleOptions {
      exclude: None,
      // entrypoint: None,
      include: None,
      lambda_layer: false,
      minify: false,
      out_dir: WORKING_DIR.to_string(),
      out_file: None,
      packages: None,
      src: ".".to_string(),
      strip_comments: false,
      strip_whitespace: false,
      zip: false,
    }
  }
}

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

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

impl Bundle for BundleOptions {
  // TODO: this should return a result and let the CLI main() handle it
  fn execute(&self) -> Result<()> {
    let artifact_dir = create_artifact_dir(&self.out_dir, self.lambda_layer)?;

    // Build and compile regex once with inputs provided
    let include = &get_regex_set(&self.include, PatternType::Include)?;
    let exclude = &get_regex_set(&self.exclude, PatternType::Exclude)?;

    let strip = transfer::Strip {
      comments: self.minify || self.strip_comments,
      whitespace: self.minify || self.strip_whitespace,
    };

    // Copy `--src` to `--out-dir`
    let src = Path::new(&self.src);
    copy_source(src, &artifact_dir, &strip, include, exclude)?;

    // Copy `--packages` to `--out
    copy_packages(&self.packages, &artifact_dir, &strip, include, exclude)?;

    // Both the `--zip` and `--lambda-layer` options require zipping
    if self.zip || self.lambda_layer {
      let prefix = &src.to_string_lossy();
      let ofile = match &self.out_file {
        Some(out_file) => PathBuf::from(out_file),
        None => {
          let filename = format!("{}.zip", &self.out_dir);
          PathBuf::from(filename)
        }
      };

      zip::zip_dir(&artifact_dir, &ofile, prefix).unwrap();
    }

    Ok(())
  }
}

// Create a working directory to copy files to and generate artifacts
// It is recommended to .gitignore this directory, which is `.pybuild` by default
fn create_artifact_dir(artifact_dir: &str, is_lambda_layer: bool) -> Result<PathBuf> {
  let dir = Path::new(artifact_dir);
  let dist_dir = dir.join("dist");

  if dist_dir.exists() {
    // Remove `dist` directory where files are copied
    fs::remove_dir_all(&dist_dir).unwrap();
  }

  if !dist_dir.exists() {
    fs::create_dir_all(&dist_dir).with_context(|| format!("Failed to create directory {:?}", dist_dir))?;
  }

  let mut canonical_dir =
    fs::canonicalize(&dist_dir).with_context(|| format!("Failed to canonicalize path: {:?}", dist_dir))?;

  // When creating AWS Lambda Layer, push contents under `python/` path
  if is_lambda_layer {
    canonical_dir.push("python");
  }

  fs::create_dir_all(&canonical_dir).with_context(|| format!("Failed to create directory {:?}", canonical_dir))?;

  Ok(canonical_dir)
}

// Pattern type for including or excluding file patterns
enum PatternType {
  Include,
  Exclude,
}

// Returns regex pattern set for including or excluding files within the bundled output
fn get_regex_set(pattern: &Option<String>, pattern_type: PatternType) -> Result<RegexSet> {
  let mut patterns: Vec<String> = vec![];

  match pattern_type {
    PatternType::Include => patterns.push(r#"(.py)$"#.into()),
    PatternType::Exclude => patterns.push(r#".git|test|__pycache__|dist-info|example|node_modules"#.into()),
  }

  if let Some(pattern) = pattern {
    patterns.push(pattern.into());
  };

  RegexSetBuilder::new(patterns)
    .build()
    .with_context(|| format!("Failed to parse regex pattern {:?}", pattern))
}

// Include source file(s) in the bundled artifact
//
// This is primarily concerned with python source files that are unique to the
// project that is being bundled. Library/package dependencies are handled in `copy_packages()`
// Files and directories are kept at the artifact "root" directory when copied over ("root" because
// the root can be influenced with the use of the `--lambda-layer` flag)
fn copy_source(src: &Path, dest: &Path, strip: &transfer::Strip, include: &RegexSet, exclude: &RegexSet) -> Result<()> {
  for entry in fs::read_dir(src)? {
    let entry = entry?;
    let path = entry.path();
    if !path.ends_with(WORKING_DIR) {
      let src_copy = transfer::DirCopy {
        src: &path,
        dest,
        strip,
      };

      // TODO - handle both file and dir for `--src`
      transfer::copy(&src_copy, include, exclude);
    }
  }
  Ok(())
}

// Include library/package dependencies in the bundled artifact
//
// Like `copy_source()`, this is concerned with dependencies that support the bundled project.
// Users can specify the location of where the dependency packages have been installed with `--packages`,
// otherwise we check for the existence of a file that is created by the `install` sub-command that is
// written to the working directory at the path `.pybuild/.target`. If the target file is present,
// we use the path stored in the file to include the package dependencies in the bundle
fn copy_packages(
  packages: &Option<String>,
  dest: &Path,
  strip: &transfer::Strip,
  include: &RegexSet,
  exclude: &RegexSet,
) -> Result<()> {
  let target_file = Path::new(WORKING_DIR).join(".target");
  let packages_path = match packages {
    Some(p) => Some(Path::new(p).to_path_buf()),
    None => {
      if target_file.exists() {
        let contents = fs::read_to_string(target_file)?;
        Some(Path::new(&contents).to_path_buf())
      } else {
        None
      }
    }
  };

  if let Some(pkgs_path) = packages_path {
    let pkgs_copy = transfer::DirCopy {
      src: pkgs_path.as_path(),
      dest,
      strip,
    };
    transfer::copy(&pkgs_copy, include, exclude);
  }

  Ok(())
}

#[cfg(test)]
mod tests {

  use super::*;

  #[test]
  fn out_dir_exists() {
    let dir = "something".to_string();
    let artifact_dir = create_artifact_dir(&dir, false).unwrap();
    assert!(artifact_dir.exists());
    fs::remove_dir_all(dir).unwrap();
  }
}