diff --git a/DESCRIPTION b/DESCRIPTION index b9e623d..411cf68 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: tinypkgr Title: Minimal R Package Development Utilities -Version: 0.1.0 +Version: 0.2.0 Authors@R: person("Troy", "Hernandez", email = "troy@cornball.ai", role = c("aut", "cre"), comment = c(ORCID = "0009-0005-4248-604X")) diff --git a/NAMESPACE b/NAMESPACE index 3b29f4f..2d38652 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,8 @@ export(load_all) export(maintainer) export(reload) export(submit_cran) +export(use_github_action) +export(use_version) importFrom(curl,curl_fetch_memory) importFrom(curl,curl_upload) diff --git a/NEWS.md b/NEWS.md index 9320bbb..eb250dc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# tinypkgr 0.2.0 + +* New `use_version()` bumps the DESCRIPTION Version field and prepends a matching NEWS.md section header. Supports `patch`, `minor`, `major`, and `dev` bumps. (For package skeleton creation, use `pkgKitten::kitten()`.) +* New `use_github_action()` writes a `.github/workflows/ci.yaml` from the r-ci template (Ubuntu + macOS) and adds `^\.github$` to `.Rbuildignore`. + # tinypkgr 0.1.0 * Initial CRAN release. diff --git a/R/dev.R b/R/dev.R index c3bfbed..4cac0d6 100644 --- a/R/dev.R +++ b/R/dev.R @@ -19,89 +19,88 @@ #' check(error_on = "error") # Only fail on errors, not warnings #' check(args = c("--as-cran", "--no-manual")) #' } -check <- function( - path = ".", - args = c("--as-cran", "--no-manual"), - error_on = c("warning", "error", "note") -) { - error_on <- match.arg(error_on) - - # Get absolute path - path <- normalizePath(path, mustWork = TRUE) - - # Get package name from DESCRIPTION - desc_file <- file.path(path, "DESCRIPTION") - if (!file.exists(desc_file)) { - stop("No DESCRIPTION file found in ", path, call. = FALSE) - } - - desc <- read.dcf(desc_file) - pkg_name <- desc[1, "Package"] - pkg_version <- desc[1, "Version"] - - # Create temp directory for build/check - tmp_dir <- tempfile("tinypkgr_check_") - dir.create(tmp_dir) - on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) - - # Build the package - message("Building ", pkg_name, "...") - build_cmd <- paste("R CMD build", shQuote(path)) - old_wd <- setwd(tmp_dir) - on.exit(setwd(old_wd), add = TRUE) - - build_result <- system(build_cmd, ignore.stdout = TRUE, ignore.stderr = TRUE) - if (build_result != 0) { - # Re-run to show errors - system(build_cmd) - stop("R CMD build failed", call. = FALSE) - } - - # Find the tarball - tarball <- paste0(pkg_name, "_", pkg_version, ".tar.gz") - if (!file.exists(tarball)) { - stop("Expected tarball not found: ", tarball, call. = FALSE) - } - - # Run R CMD check - message("Checking ", pkg_name, "...") - check_cmd <- paste("R CMD check", paste(args, collapse = " "), shQuote(tarball)) - check_result <- system(check_cmd) - - # Parse check results - check_dir <- paste0(pkg_name, ".Rcheck") - log_file <- file.path(check_dir, "00check.log") - - if (file.exists(log_file)) { - log <- readLines(log_file, warn = FALSE) - - # Count issues (format: "* checking ... NOTE" or "ERROR: ...") - errors <- sum(grepl("\\.\\.\\. ERROR$|^ERROR:", log)) - warnings <- sum(grepl("\\.\\.\\. WARNING$|^WARNING:", log)) - notes <- sum(grepl("\\.\\.\\. NOTE$|^NOTE:", log)) - - # Print summary - message("\n", pkg_name, " ", pkg_version, ": ", - errors, " error(s), ", warnings, " warning(s), ", notes, " note(s)") - - # Determine if we should error - should_error <- switch(error_on, - "note" = errors > 0 || warnings > 0 || notes > 0, - "warning" = errors > 0 || warnings > 0, - "error" = errors > 0 - ) - - if (should_error) { - stop("R CMD check found issues", call. = FALSE) +check <- function(path = ".", args = c("--as-cran", "--no-manual"), + error_on = c("warning", "error", "note")) { + error_on <- match.arg(error_on) + + # Get absolute path + path <- normalizePath(path, mustWork = TRUE) + + # Get package name from DESCRIPTION + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, call. = FALSE) + } + + desc <- read.dcf(desc_file) + pkg_name <- desc[1, "Package"] + pkg_version <- desc[1, "Version"] + + # Create temp directory for build/check + tmp_dir <- tempfile("tinypkgr_check_") + dir.create(tmp_dir) + on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) + + # Build the package + message("Building ", pkg_name, "...") + build_cmd <- paste("R CMD build", shQuote(path)) + old_wd <- setwd(tmp_dir) + on.exit(setwd(old_wd), add = TRUE) + + build_result <- system(build_cmd, ignore.stdout = TRUE, + ignore.stderr = TRUE) + if (build_result != 0) { + # Re-run to show errors + system(build_cmd) + stop("R CMD build failed", call. = FALSE) } - invisible(TRUE) - } else { - if (check_result != 0) { - stop("R CMD check failed", call. = FALSE) + # Find the tarball + tarball <- paste0(pkg_name, "_", pkg_version, ".tar.gz") + if (!file.exists(tarball)) { + stop("Expected tarball not found: ", tarball, call. = FALSE) + } + + # Run R CMD check + message("Checking ", pkg_name, "...") + check_cmd <- paste("R CMD check", paste(args, collapse = " "), + shQuote(tarball)) + check_result <- system(check_cmd) + + # Parse check results + check_dir <- paste0(pkg_name, ".Rcheck") + log_file <- file.path(check_dir, "00check.log") + + if (file.exists(log_file)) { + log <- readLines(log_file, warn = FALSE) + + # Count issues (format: "* checking ... NOTE" or "ERROR: ...") + errors <- sum(grepl("\\.\\.\\. ERROR$|^ERROR:", log)) + warnings <- sum(grepl("\\.\\.\\. WARNING$|^WARNING:", log)) + notes <- sum(grepl("\\.\\.\\. NOTE$|^NOTE:", log)) + + # Print summary + message("\n", pkg_name, " ", pkg_version, ": ", + errors, " error(s), ", warnings, " warning(s), ", notes, " note(s)") + + # Determine if we should error + should_error <- switch(error_on, + "note" = errors > 0 || warnings > 0 || notes > 0, + "warning" = errors > 0 || warnings > 0, + "error" = errors > 0 + ) + + if (should_error) { + stop("R CMD check found issues", call. = FALSE) + } + + invisible(TRUE) + } else { + if (check_result != 0) { + stop("R CMD check failed", call. = FALSE) + } + invisible(TRUE) } - invisible(TRUE) - } } #' Install Package @@ -120,45 +119,42 @@ check <- function( #' install() #' install(quiet = FALSE) # Show full output #' } -install <- function( - path = ".", - quiet = TRUE -) { - # Get absolute path - path <- normalizePath(path, mustWork = TRUE) - - # Get package name from DESCRIPTION - desc_file <- file.path(path, "DESCRIPTION") - if (!file.exists(desc_file)) { - stop("No DESCRIPTION file found in ", path, call. = FALSE) - } - - desc <- read.dcf(desc_file) - pkg_name <- desc[1, "Package"] - - # Build command - cmd <- paste("R CMD INSTALL", shQuote(path)) - - # Run install (redirect output if quiet) - if (quiet) { - # Redirect both stdout and stderr - if (.Platform$OS.type == "windows") { - cmd_quiet <- paste(cmd, "> NUL 2>&1") +install <- function(path = ".", quiet = TRUE) { + # Get absolute path + path <- normalizePath(path, mustWork = TRUE) + + # Get package name from DESCRIPTION + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, call. = FALSE) + } + + desc <- read.dcf(desc_file) + pkg_name <- desc[1, "Package"] + + # Build command + cmd <- paste("R CMD INSTALL", shQuote(path)) + + # Run install (redirect output if quiet) + if (quiet) { + # Redirect both stdout and stderr + if (.Platform$OS.type == "windows") { + cmd_quiet <- paste(cmd, "> NUL 2>&1") + } else { + cmd_quiet <- paste(cmd, "> /dev/null 2>&1") + } + result <- system(cmd_quiet) } else { - cmd_quiet <- paste(cmd, "> /dev/null 2>&1") + result <- system(cmd) + } + + if (result == 0) { + message("Installed ", pkg_name) + invisible(TRUE) + } else { + message("Install failed for ", pkg_name) + invisible(FALSE) } - result <- system(cmd_quiet) - } else { - result <- system(cmd) - } - - if (result == 0) { - message("Installed ", pkg_name) - invisible(TRUE) - } else { - message("Install failed for ", pkg_name) - invisible(FALSE) - } } #' Load All Package Code @@ -167,55 +163,51 @@ install <- function( #' without requiring a full install. #' #' @param path Path to package root directory. +#' @param env Environment to source files into. Defaults to a fresh +#' environment whose parent is the global environment. #' @param quiet Logical. Suppress file sourcing messages? Default TRUE. #' -#' @return Character vector of sourced files (invisibly). +#' @return The environment into which files were sourced (invisibly). +#' Does not modify the search path. If you want search-path behavior, +#' call `attach()` yourself: +#' \preformatted{attach(tinypkgr::load_all(), name = "tinypkgr:mypkg")} +#' +#' @seealso \code{\link[pkgKitten]{kitten}} for scaffolding a new package. #' #' @export #' #' @examples #' \dontrun{ -#' load_all() -#' load_all(quiet = FALSE) # Show each file being sourced +#' e <- load_all() +#' e$my_function(123) +#' +#' # Or attach to the search path explicitly: +#' attach(load_all(), name = "tinypkgr:mypkg") #' } -load_all <- function( - path = ".", - quiet = TRUE -) { - r_dir <- file.path(path, "R") +load_all <- function(path = ".", env = new.env(parent = globalenv()), + quiet = TRUE) { + r_dir <- file.path(path, "R") - if (!dir.exists(r_dir)) { - stop("No R/ directory found in ", path, call. = FALSE) - } - - r_files <- list.files(r_dir, pattern = "\\.[Rr]$", full.names = TRUE) - - if (length(r_files) == 0) { - message("No R files found.") - return(invisible(character())) - } - - # Create a new environment attached to the search path - pkg_env <- new.env(parent = globalenv()) - - for (f in r_files) { - if (!quiet) message("Sourcing ", basename(f)) - source(f, local = pkg_env) - } + if (!dir.exists(r_dir)) { + stop("No R/ directory found in ", path, call. = FALSE) + } - # Attach the environment - # Use a name that won't conflict - env_name <- paste0("tinypkgr:", basename(normalizePath(path))) + r_files <- list.files(r_dir, pattern = "\\.[Rr]$", full.names = TRUE) - # Detach if already attached - if (env_name %in% search()) { - detach(env_name, character.only = TRUE) - } + if (length(r_files) == 0) { + message("No R files found.") + return(invisible(env)) + } - attach(pkg_env, name = env_name) + for (f in r_files) { + if (!quiet) { + message("Sourcing ", basename(f)) + } + source(f, local = env) + } - message("Loaded ", length(r_files), " file(s) as '", env_name, "'") - invisible(r_files) + message("Loaded ", length(r_files), " file(s)") + invisible(env) } #' Reload an Installed Package @@ -237,63 +229,62 @@ load_all <- function( #' reload() # Reinstall and reload current package #' reload(document = TRUE) # Document first (requires tinyrox) #' } -reload <- function( - path = ".", - document = FALSE, - quiet = TRUE -) { - # Get package name from DESCRIPTION - desc_file <- file.path(path, "DESCRIPTION") - if (!file.exists(desc_file)) { - stop("No DESCRIPTION file found in ", path, ". Is this an R package?", call. = FALSE) - } - - desc <- read.dcf(desc_file) - pkg_name <- desc[1, "Package"] - - # Document first if requested - if (document) { - if (requireNamespace("tinyrox", quietly = TRUE)) { - tinyrox::document(path) - } else { - warning("tinyrox not available, skipping documentation", call. = FALSE) +reload <- function(path = ".", document = FALSE, quiet = TRUE) { + # Get package name from DESCRIPTION + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, + ". Is this an R package?", call. = FALSE) + } + + desc <- read.dcf(desc_file) + pkg_name <- desc[1, "Package"] + + # Document first if requested + if (document) { + if (requireNamespace("tinyrox", quietly = TRUE)) { + tinyrox::document(path) + } else { + warning("tinyrox not available, skipping documentation", + call. = FALSE) + } } - } - - # Unload package if loaded - pkg_loaded <- paste0("package:", pkg_name) - if (pkg_loaded %in% search()) { - tryCatch({ - detach(pkg_loaded, unload = TRUE, character.only = TRUE) - message("Unloaded ", pkg_name) - }, error = function(e) { - # Sometimes unload fails due to dependencies, just detach + + # Unload package if loaded + pkg_loaded <- paste0("package:", pkg_name) + if (pkg_loaded %in% search()) { tryCatch({ - detach(pkg_loaded, character.only = TRUE) - message("Detached ", pkg_name, " (could not fully unload)") - }, error = function(e2) { - message("Note: Could not detach ", pkg_name, ": ", e2$message) - }) - }) - } - - # Also unload namespace if still loaded - if (pkg_name %in% loadedNamespaces()) { - tryCatch({ - unloadNamespace(pkg_name) - }, error = function(e) { - # Ignore - will be handled by library() reload - }) - } - - # Reinstall - success <- install(path, quiet = quiet) - - if (success) { - # Reload - library(pkg_name, character.only = TRUE) - message("Reloaded ", pkg_name) - } - - invisible(success) + detach(pkg_loaded, unload = TRUE, character.only = TRUE) + message("Unloaded ", pkg_name) + }, error = function(e) { + # Sometimes unload fails due to dependencies, just detach + tryCatch({ + detach(pkg_loaded, character.only = TRUE) + message("Detached ", pkg_name, " (could not fully unload)") + }, error = function(e2) { + message("Note: Could not detach ", pkg_name, ": ", e2$message) + }) + }) + } + + # Also unload namespace if still loaded + if (pkg_name %in% loadedNamespaces()) { + tryCatch({ + unloadNamespace(pkg_name) + }, error = function(e) { + # Ignore - will be handled by library() reload + }) + } + + # Reinstall + success <- install(path, quiet = quiet) + + if (success) { + # Reload + library(pkg_name, character.only = TRUE) + message("Reloaded ", pkg_name) + } + + invisible(success) } + diff --git a/R/release.R b/R/release.R index 95c8482..1711a9b 100644 --- a/R/release.R +++ b/R/release.R @@ -21,43 +21,40 @@ NULL #' build() #' build(dest_dir = tempdir()) #' } -build <- function( - path = ".", - dest_dir = "." -) { +build <- function(path = ".", dest_dir = ".") { + path <- normalizePath(path, mustWork = TRUE) + dest_dir <- normalizePath(dest_dir, mustWork = TRUE) - path <- normalizePath(path, mustWork = TRUE) - dest_dir <- normalizePath(dest_dir, mustWork = TRUE) - - desc_file <- file.path(path, "DESCRIPTION") - if (!file.exists(desc_file)) { - stop("No DESCRIPTION file found in ", path, call. = FALSE) - } + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, call. = FALSE) + } - desc <- read.dcf(desc_file) - pkg_name <- desc[1, "Package"] - pkg_version <- desc[1, "Version"] + desc <- read.dcf(desc_file) + pkg_name <- desc[1, "Package"] + pkg_version <- desc[1, "Version"] - message("Building ", pkg_name, " ", pkg_version, "...") + message("Building ", pkg_name, " ", pkg_version, "...") - old_wd <- getwd() - on.exit(setwd(old_wd), add = TRUE) - setwd(dest_dir) + old_wd <- getwd() + on.exit(setwd(old_wd), add = TRUE) + setwd(dest_dir) - cmd <- paste("R CMD build", shQuote(path)) - result <- system(cmd) + cmd <- paste("R CMD build", shQuote(path)) + result <- system(cmd) - if (result != 0) { - stop("R CMD build failed", call. = FALSE) - } + if (result != 0) { + stop("R CMD build failed", call. = FALSE) + } - tarball <- file.path(dest_dir, paste0(pkg_name, "_", pkg_version, ".tar.gz")) - if (!file.exists(tarball)) { - stop("Expected tarball not found: ", tarball, call. = FALSE) - } + tarball <- file.path(dest_dir, + paste0(pkg_name, "_", pkg_version, ".tar.gz")) + if (!file.exists(tarball)) { + stop("Expected tarball not found: ", tarball, call. = FALSE) + } - message("Built: ", tarball) - invisible(tarball) + message("Built: ", tarball) + invisible(tarball) } #' Get Package Maintainer @@ -75,52 +72,49 @@ build <- function( #' maintainer() #' } maintainer <- function(path = ".") { - desc_file <- file.path(path, "DESCRIPTION") - if (!file.exists(desc_file)) { - stop("No DESCRIPTION file found in ", path, call. = FALSE) - } - - desc <- read.dcf(desc_file) - - # Try Authors@R first - if ("Authors@R" %in% colnames(desc)) { - authors_r <- desc[1, "Authors@R"] - # Parse the R expression - authors <- tryCatch( - eval(parse(text = authors_r)), - error = function(e) NULL - ) + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, call. = FALSE) + } - if (!is.null(authors)) { - # Find the maintainer (cre role) - if (inherits(authors, "person")) { - for (i in seq_along(authors)) { - auth <- authors[i] - if ("cre" %in% auth$role) { - return(list( - name = paste(auth$given, auth$family), - email = auth$email - )) - } + desc <- read.dcf(desc_file) + + # Try Authors@R first + if ("Authors@R" %in% colnames(desc)) { + authors_r <- desc[1, "Authors@R"] + # Parse the R expression + authors <- tryCatch( + eval(parse(text = authors_r)), + error = function(e) NULL + ) + + if (!is.null(authors)) { + # Find the maintainer (cre role) + if (inherits(authors, "person")) { + for (i in seq_along(authors)) { + auth <- authors[i] + if ("cre" %in% auth$role) { + return(list( + name = paste(auth$given, auth$family), + email = auth$email + )) + } + } + } } - } } - } - - # Fall back to Maintainer field - if ("Maintainer" %in% colnames(desc)) { - maint <- desc[1, "Maintainer"] - # Parse "Name " format - match <- regmatches(maint, regexec("^(.+?)\\s*<(.+)>$", maint)) [[1]] - if (length(match) == 3) { - return(list( - name = trimws(match[2]), - email = trimws(match[3]) - )) + + # Fall back to Maintainer field + if ("Maintainer" %in% colnames(desc)) { + maint <- desc[1, "Maintainer"] + # Parse "Name " format + match <- regmatches(maint, regexec("^(.+?)\\s*<(.+)>$", maint))[[1]] + if (length(match) == 3) { + return(list(name = trimws(match[2]), email = trimws(match[3]))) + } } - } - stop("Could not determine maintainer from DESCRIPTION", call. = FALSE) + stop("Could not determine maintainer from DESCRIPTION", call. = FALSE) } #' Check Package on Windows via win-builder @@ -141,43 +135,41 @@ maintainer <- function(path = ".") { #' check_win_devel() #' check_win_devel(r_version = "release") #' } -check_win_devel <- function( - path = ".", - r_version = c("devel", "release", "oldrelease") -) { - r_version <- match.arg(r_version) - - # Build the package - tmp_dir <- tempfile("tinypkgr_winbuild_") - dir.create(tmp_dir) - on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) - - tarball <- build(path, dest_dir = tmp_dir) - - # Get maintainer email for confirmation - maint <- maintainer(path) - message("Results will be emailed to: ", maint$email) - - # FTP URL based on R version - ftp_dir <- switch(r_version, - "devel" = "R-devel", - "release" = "R-release", - "oldrelease" = "R-oldrelease" - ) - ftp_url <- paste0("ftp://win-builder.r-project.org/", ftp_dir, "/") - - message("Uploading to win-builder (", r_version, ")...") - - # Upload via FTP using curl - result <- tryCatch({ - curl::curl_upload(tarball, ftp_url) - TRUE +check_win_devel <- function(path = ".", + r_version = c("devel", "release", "oldrelease")) { + r_version <- match.arg(r_version) + + # Build the package + tmp_dir <- tempfile("tinypkgr_winbuild_") + dir.create(tmp_dir) + on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) + + tarball <- build(path, dest_dir = tmp_dir) + + # Get maintainer email for confirmation + maint <- maintainer(path) + message("Results will be emailed to: ", maint$email) + + # FTP URL based on R version + ftp_dir <- switch(r_version, + "devel" = "R-devel", + "release" = "R-release", + "oldrelease" = "R-oldrelease" + ) + ftp_url <- paste0("ftp://win-builder.r-project.org/", ftp_dir, "/") + + message("Uploading to win-builder (", r_version, ")...") + + # Upload via FTP using curl + result <- tryCatch({ + curl::curl_upload(tarball, ftp_url) + TRUE }, error = function(e) { - stop("FTP upload failed: ", e$message, call. = FALSE) + stop("FTP upload failed: ", e$message, call. = FALSE) }) - message("Upload complete. Check your email for results (usually within 30 minutes).") - invisible(TRUE) + message("Upload complete. Check your email for results (usually within 30 minutes).") + invisible(TRUE) } #' Submit Package to CRAN @@ -196,121 +188,124 @@ check_win_devel <- function( #' \dontrun{ #' submit_cran() #' } -submit_cran <- function( - path = ".", - comments = "cran-comments.md" -) { - path <- normalizePath(path, mustWork = TRUE) - - # Get package info - desc_file <- file.path(path, "DESCRIPTION") - desc <- read.dcf(desc_file) - pkg_name <- desc[1, "Package"] - pkg_version <- desc[1, "Version"] - - # Get maintainer - maint <- maintainer(path) - - message("Package: ", pkg_name, " ", pkg_version) - message("Maintainer: ", maint$name, " <", maint$email, ">") - - # Confirm email - response <- readline(paste0("Is your email address correct? (y/n): ")) - if (!tolower(response) %in% c("y", "yes")) { - message("Submission cancelled. Update the maintainer email in DESCRIPTION.") - return(invisible(FALSE)) - } - - # Read comments - comment_text <- "" - comments_path <- file.path(path, comments) - if (!is.null(comments) && file.exists(comments_path)) { - comment_text <- paste(readLines(comments_path, warn = FALSE), collapse = "\n") - message("Including comments from: ", comments) - } - - # Build the package - tmp_dir <- tempfile("tinypkgr_cran_") - dir.create(tmp_dir) - on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) - - tarball <- build(path, dest_dir = tmp_dir) - tarball_size <- file.info(tarball) $size - message("Package size: ", format(structure(tarball_size, class = "object_size"), - units = "auto")) - - # Final confirmation - response <- readline(paste0("Ready to submit ", pkg_name, " ", pkg_version, - " to CRAN? (y/n): ")) - if (!tolower(response) %in% c("y", "yes")) { - message("Submission cancelled.") - return(invisible(FALSE)) - } - - # CRAN submission URL - cran_url <- "https://xmpalantir.wu.ac.at/cransubmit/index2.php" - - message("Uploading to CRAN...") - - # Submit - response <- tryCatch({ - h <- curl::new_handle() - curl::handle_setform(h, - name = maint$name, - email = maint$email, - uploaded_file = curl::form_file(tarball, type = "application/x-gzip"), - comment = comment_text, - upload = "Upload package" - ) - curl::curl_fetch_memory(cran_url, handle = h) +submit_cran <- function(path = ".", comments = "cran-comments.md") { + path <- normalizePath(path, mustWork = TRUE) + + # Get package info + desc_file <- file.path(path, "DESCRIPTION") + desc <- read.dcf(desc_file) + pkg_name <- desc[1, "Package"] + pkg_version <- desc[1, "Version"] + + # Get maintainer + maint <- maintainer(path) + + message("Package: ", pkg_name, " ", pkg_version) + message("Maintainer: ", maint$name, " <", maint$email, ">") + + # Confirm email + response <- readline(paste0("Is your email address correct? (y/n): ")) + if (!tolower(response) %in% c("y", "yes")) { + message("Submission cancelled. Update the maintainer email in DESCRIPTION.") + return(invisible(FALSE)) + } + + # Read comments + comment_text <- "" + comments_path <- file.path(path, comments) + if (!is.null(comments) && file.exists(comments_path)) { + comment_text <- paste(readLines(comments_path, warn = FALSE), + collapse = "\n") + message("Including comments from: ", comments) + } + + # Build the package + tmp_dir <- tempfile("tinypkgr_cran_") + dir.create(tmp_dir) + on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE) + + tarball <- build(path, dest_dir = tmp_dir) + tarball_size <- file.info(tarball)$size + message("Package size: ", format(structure(tarball_size, + class = "object_size"), + units = "auto")) + + # Final confirmation + response <- readline(paste0("Ready to submit ", pkg_name, " ", pkg_version, + " to CRAN? (y/n): ")) + if (!tolower(response) %in% c("y", "yes")) { + message("Submission cancelled.") + return(invisible(FALSE)) + } + + # CRAN submission URL + cran_url <- "https://xmpalantir.wu.ac.at/cransubmit/index2.php" + + message("Uploading to CRAN...") + + # Submit + response <- tryCatch({ + h <- curl::new_handle() + curl::handle_setform(h, + name = maint$name, + email = maint$email, + uploaded_file = curl::form_file(tarball, + type = "application/x-gzip"), + comment = comment_text, + upload = "Upload package" + ) + curl::curl_fetch_memory(cran_url, handle = h) }, error = function(e) { - stop("Upload failed: ", e$message, call. = FALSE) + stop("Upload failed: ", e$message, call. = FALSE) }) - # Check response - if (response$status_code == 200) { - # Parse response to get package ID for confirmation - response_text <- rawToChar(response$content) - - # Look for the package ID in the response - id_match <- regmatches(response_text, - regexec('name="pkg_id"[^>]*value="([^"]+)"', response_text)) [[1]] - - if (length(id_match) >= 2) { - pkg_id <- id_match[2] - - # Submit confirmation - h2 <- curl::new_handle() - curl::handle_setform(h2, - pkg_id = pkg_id, - name = maint$name, - email = maint$email, - policy_check = "1", - submit = "Submit package" - ) - confirm_response <- curl::curl_fetch_memory(cran_url, handle = h2) - - if (confirm_response$status_code == 200) { - message("\nSubmission uploaded successfully.") - message("Check your email (", maint$email, ") for a confirmation link.") - message("You must click the link to complete the submission.") + # Check response + if (response$status_code == 200) { + # Parse response to get package ID for confirmation + response_text <- rawToChar(response$content) + + # Look for the package ID in the response + id_match <- regmatches(response_text, + regexec('name="pkg_id"[^>]*value="([^"]+)"', response_text))[[1]] + + if (length(id_match) >= 2) { + pkg_id <- id_match[2] + + # Submit confirmation + h2 <- curl::new_handle() + curl::handle_setform(h2, + pkg_id = pkg_id, + name = maint$name, + email = maint$email, + policy_check = "1", + submit = "Submit package" + ) + confirm_response <- curl::curl_fetch_memory(cran_url, handle = h2) + + if (confirm_response$status_code == 200) { + message("\nSubmission uploaded successfully.") + message("Check your email (", maint$email, + ") for a confirmation link.") + message("You must click the link to complete the submission.") + return(invisible(TRUE)) + } + } + + # If we got here, something went wrong with confirmation + message("\nPackage uploaded, but confirmation step may have failed.") + message("Check your email for further instructions.") return(invisible(TRUE)) - } - } - # If we got here, something went wrong with confirmation - message("\nPackage uploaded, but confirmation step may have failed.") - message("Check your email for further instructions.") - return(invisible(TRUE)) - - } else if (response$status_code == 404) { - # CRAN might be in maintenance mode - response_text <- rawToChar(response$content) - message("CRAN submission system returned 404.") - message("The system may be in maintenance mode. Try again later.") - return(invisible(FALSE)) - - } else { - stop("Submission failed with status: ", response$status_code, call. = FALSE) - } + } else if (response$status_code == 404) { + # CRAN might be in maintenance mode + response_text <- rawToChar(response$content) + message("CRAN submission system returned 404.") + message("The system may be in maintenance mode. Try again later.") + return(invisible(FALSE)) + + } else { + stop("Submission failed with status: ", response$status_code, + call. = FALSE) + } } + diff --git a/R/use.R b/R/use.R new file mode 100644 index 0000000..c5a4009 --- /dev/null +++ b/R/use.R @@ -0,0 +1,163 @@ +#' Bump Package Version +#' +#' Increments the Version field in DESCRIPTION and prepends a new section +#' header to NEWS.md (if present) so the two never drift apart. +#' +#' @param which Which component to bump: "patch" (0.2.0 -> 0.2.1), +#' "minor" (0.2.0 -> 0.3.0), "major" (0.2.0 -> 1.0.0), or +#' "dev" (0.2.0 -> 0.2.0.1, or 0.2.0.1 -> 0.2.0.2). +#' @param path Path to package root directory. +#' +#' @return The new version string (invisibly). +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' use_version("patch") +#' use_version("dev") +#' } +use_version <- function(which = c("patch", "minor", "major", "dev"), + path = ".") { + which <- match.arg(which) + path <- normalizePath(path, mustWork = TRUE) + desc_file <- file.path(path, "DESCRIPTION") + if (!file.exists(desc_file)) { + stop("No DESCRIPTION file found in ", path, call. = FALSE) + } + + desc_lines <- readLines(desc_file, warn = FALSE) + ver_idx <- grep("^Version:", desc_lines) + if (length(ver_idx) != 1) { + stop("Could not find Version field in DESCRIPTION", call. = FALSE) + } + current <- trimws(sub("^Version:", "", desc_lines[ver_idx])) + new_version <- bump_version(current, which) + desc_lines[ver_idx] <- paste0("Version: ", new_version) + writeLines(desc_lines, desc_file) + + pkg_name <- read.dcf(desc_file)[1, "Package"] + message("Bumped ", pkg_name, ": ", current, " -> ", new_version) + + # Update NEWS.md only on release-style bumps + news_file <- file.path(path, "NEWS.md") + if (file.exists(news_file) && which != "dev") { + news <- readLines(news_file, warn = FALSE) + header <- paste0("# ", pkg_name, " ", new_version) + new_news <- c(header, "", "* ", "", news) + writeLines(new_news, news_file) + message("Added NEWS.md header for ", new_version) + } + + invisible(new_version) +} + +#' Add a GitHub Actions CI Workflow +#' +#' Writes `.github/workflows/ci.yaml` using the r-ci template (Ubuntu and +#' macOS, via `eddelbuettel/github-actions/r-ci@master`). Adds `^\.github$` +#' to `.Rbuildignore` if not already present. +#' +#' @param path Path to package root directory. +#' +#' @return Path to the created YAML file (invisibly). +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' use_github_action() +#' } +use_github_action <- function(path = ".") { + path <- normalizePath(path, mustWork = TRUE) + workflows_dir <- file.path(path, ".github", "workflows") + dir.create(workflows_dir, recursive = TRUE, showWarnings = FALSE) + yaml_file <- file.path(workflows_dir, "ci.yaml") + if (file.exists(yaml_file)) { + stop("File already exists: ", yaml_file, call. = FALSE) + } + + yaml_lines <- c( + "name: ci", + "", + "on:", + " push:", + " pull_request:", + "", + "env:", + " _R_CHECK_FORCE_SUGGESTS_: \"false\"", + "", + "jobs:", + " ci:", + " strategy:", + " matrix:", + " include:", + " - {os: macos-latest}", + " - {os: ubuntu-latest}", + "", + " runs-on: ${{ matrix.os }}", + "", + " steps:", + " - uses: actions/checkout@v6", + "", + " - name: Setup", + " uses: eddelbuettel/github-actions/r-ci@master", + "", + " - name: Dependencies", + " run: ./run.sh install_deps", + "", + " - name: Test", + " run: ./run.sh run_tests" + ) + writeLines(yaml_lines, yaml_file) + message("Created ", yaml_file) + + # Make sure .github is in .Rbuildignore + rbi <- file.path(path, ".Rbuildignore") + rbi_entry <- "^\\.github$" + if (file.exists(rbi)) { + lines <- readLines(rbi, warn = FALSE) + if (!rbi_entry %in% lines) { + writeLines(c(lines, rbi_entry), rbi) + } + } else { + writeLines(rbi_entry, rbi) + } + + invisible(yaml_file) +} + +# Bump a version string by component. +bump_version <- function(current, which) { + parts <- strsplit(current, ".", fixed = TRUE)[[1]] + if (which == "dev") { + if (length(parts) == 4) { + parts[4] <- as.character(as.integer(parts[4]) + 1) + } else if (length(parts) == 3) { + parts <- c(parts, "1") + } else { + stop("Cannot bump dev version from: ", current, call. = FALSE) + } + } else { + if (length(parts) == 4) { + parts <- parts[1:3] + } + if (length(parts) != 3) { + stop("Cannot parse version: ", current, call. = FALSE) + } + nums <- as.integer(parts) + if (which == "patch") { + nums[3] <- nums[3] + 1 + } else if (which == "minor") { + nums[2] <- nums[2] + 1 + nums[3] <- 0 + } else if (which == "major") { + nums[1] <- nums[1] + 1 + nums[2] <- 0 + nums[3] <- 0 + } + parts <- as.character(nums) + } + paste(parts, collapse = ".") +} + diff --git a/README.md b/README.md index 9652442..1558a36 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ remotes::install_github("cornball-ai/tinypkgr") ## Usage +For creating a new package skeleton, use [pkgKitten](https://github.com/eddelbuettel/pkgkitten): + +```r +pkgKitten::kitten("mypkg") +``` + ### Development ```r @@ -49,6 +55,8 @@ submit_cran() | Function | Purpose | |----------|---------| +| `use_version()` | Bump DESCRIPTION version + NEWS.md header | +| `use_github_action()` | Write `.github/workflows/ci.yaml` (r-ci) | | `install()` | R CMD INSTALL wrapper | | `load_all()` | Source R/ files for dev | | `reload()` | Reinstall and reload | diff --git a/inst/tinytest/test_dev.R b/inst/tinytest/test_dev.R index e5d074b..54cfeca 100644 --- a/inst/tinytest/test_dev.R +++ b/inst/tinytest/test_dev.R @@ -25,15 +25,19 @@ if (at_home()) { expect_true(result) } -# Test load_all -files <- tinypkgr::load_all(tmp_pkg, quiet = TRUE) -expect_equal(length(files), 1) -expect_true(any(grepl("tinypkgr:testpkg", search()))) +# load_all returns an environment populated with sourced functions, +# without touching the search path. +e <- tinypkgr::load_all(tmp_pkg, quiet = TRUE) +expect_true(is.environment(e)) +expect_true("add" %in% ls(e)) +expect_equal(e$add(2, 3), 5) +expect_false(any(grepl("testpkg", search()))) -# Clean up search path -if ("tinypkgr:testpkg" %in% search()) { - detach("tinypkgr:testpkg", character.only = TRUE) -} +# Caller may supply their own env +target <- new.env() +e2 <- tinypkgr::load_all(tmp_pkg, env = target, quiet = TRUE) +expect_identical(e2, target) +expect_true("add" %in% ls(target)) # Clean up temp package unlink(tmp_pkg, recursive = TRUE) diff --git a/inst/tinytest/test_use_github_action.R b/inst/tinytest/test_use_github_action.R new file mode 100644 index 0000000..c49e6f1 --- /dev/null +++ b/inst/tinytest/test_use_github_action.R @@ -0,0 +1,48 @@ +# Tests for use_github_action() + +# Inline minimal scaffold (no create_package dependency) +tmp_pkg <- file.path(tempdir(), "ghapkg") +if (dir.exists(tmp_pkg)) unlink(tmp_pkg, recursive = TRUE) +dir.create(tmp_pkg) +writeLines(c( + "Package: ghapkg", + "Title: Test", + "Version: 0.0.1", + "Authors@R: person('A', 'B', email = 'a@b.com', role = c('aut', 'cre'))", + "Description: Test.", + "License: GPL-3" +), file.path(tmp_pkg, "DESCRIPTION")) +writeLines("^cran-comments\\.md$", file.path(tmp_pkg, ".Rbuildignore")) + +yaml <- tinypkgr::use_github_action(path = tmp_pkg) +expect_true(file.exists(yaml)) +expect_equal(basename(yaml), "ci.yaml") +expect_equal(basename(dirname(yaml)), "workflows") + +content <- readLines(yaml) +expect_true(any(grepl("^name: ci", content))) +expect_true(any(grepl("eddelbuettel/github-actions/r-ci@master", content))) +expect_true(any(grepl("macos-latest", content))) +expect_true(any(grepl("ubuntu-latest", content))) + +# .Rbuildignore got the .github entry appended +rbi <- readLines(file.path(tmp_pkg, ".Rbuildignore")) +expect_true("^\\.github$" %in% rbi) +expect_equal(sum(rbi == "^\\.github$"), 1) + +# Calling again errors +expect_error(tinypkgr::use_github_action(path = tmp_pkg)) + +# Works on a package without an .Rbuildignore +bare <- file.path(tempdir(), "bare_for_ghaction") +if (dir.exists(bare)) unlink(bare, recursive = TRUE) +dir.create(bare) +writeLines(c("Package: bare", "Version: 0.0.0.1", + "Title: Bare", "Description: x.", "License: GPL-3"), + file.path(bare, "DESCRIPTION")) +tinypkgr::use_github_action(path = bare) +expect_true(file.exists(file.path(bare, ".Rbuildignore"))) +expect_true("^\\.github$" %in% readLines(file.path(bare, ".Rbuildignore"))) + +unlink(tmp_pkg, recursive = TRUE) +unlink(bare, recursive = TRUE) diff --git a/inst/tinytest/test_use_version.R b/inst/tinytest/test_use_version.R new file mode 100644 index 0000000..9442b09 --- /dev/null +++ b/inst/tinytest/test_use_version.R @@ -0,0 +1,69 @@ +# Tests for use_version() + +# bump_version() unit tests +bump <- tinypkgr:::bump_version +expect_equal(bump("0.2.0", "patch"), "0.2.1") +expect_equal(bump("0.2.0", "minor"), "0.3.0") +expect_equal(bump("0.2.0", "major"), "1.0.0") +expect_equal(bump("0.2.0", "dev"), "0.2.0.1") +expect_equal(bump("0.2.0.1", "dev"), "0.2.0.2") +expect_equal(bump("0.2.0.2", "dev"), "0.2.0.3") +# Release bump from a dev version strips the dev suffix +expect_equal(bump("0.2.0.5", "patch"), "0.2.1") +expect_equal(bump("0.2.0.5", "minor"), "0.3.0") +expect_equal(bump("0.2.0.5", "major"), "1.0.0") +# Garbage +expect_error(bump("foo", "patch")) +expect_error(bump("0.2", "patch")) + +# End-to-end on a temp package (inline minimal scaffold) +tmp_pkg <- file.path(tempdir(), "vpkg") +if (dir.exists(tmp_pkg)) unlink(tmp_pkg, recursive = TRUE) +dir.create(tmp_pkg) +writeLines(c( + "Package: vpkg", + "Title: Test Package", + "Version: 0.1.0", + "Authors@R: person('A', 'B', email = 'a@b.com', role = c('aut', 'cre'))", + "Description: Test.", + "License: GPL-3" +), file.path(tmp_pkg, "DESCRIPTION")) +writeLines(c("# vpkg 0.1.0", "", "* Initial."), + file.path(tmp_pkg, "NEWS.md")) + +desc_file <- file.path(tmp_pkg, "DESCRIPTION") + +# Patch bump updates DESCRIPTION and NEWS.md +v <- tinypkgr::use_version("patch", path = tmp_pkg) +expect_equal(v, "0.1.1") +expect_equal(unname(read.dcf(desc_file)[1, "Version"]), "0.1.1") +news <- readLines(file.path(tmp_pkg, "NEWS.md")) +expect_true(grepl("^# vpkg 0.1.1", news[1])) + +# Minor bump +v <- tinypkgr::use_version("minor", path = tmp_pkg) +expect_equal(v, "0.2.0") + +# Major bump +v <- tinypkgr::use_version("major", path = tmp_pkg) +expect_equal(v, "1.0.0") + +# Dev bump does NOT touch NEWS.md +news_before <- readLines(file.path(tmp_pkg, "NEWS.md")) +v <- tinypkgr::use_version("dev", path = tmp_pkg) +expect_equal(v, "1.0.0.1") +news_after <- readLines(file.path(tmp_pkg, "NEWS.md")) +expect_equal(news_before, news_after) + +# Successive dev bumps +v <- tinypkgr::use_version("dev", path = tmp_pkg) +expect_equal(v, "1.0.0.2") + +# Missing DESCRIPTION +empty_dir <- file.path(tempdir(), "empty_for_use_version") +if (dir.exists(empty_dir)) unlink(empty_dir, recursive = TRUE) +dir.create(empty_dir) +expect_error(tinypkgr::use_version("patch", path = empty_dir)) + +unlink(tmp_pkg, recursive = TRUE) +unlink(empty_dir, recursive = TRUE) diff --git a/man/load_all.Rd b/man/load_all.Rd index 0c6fe35..5741f6f 100644 --- a/man/load_all.Rd +++ b/man/load_all.Rd @@ -3,15 +3,21 @@ \alias{load_all} \title{Load All Package Code} \usage{ -load_all(path = ".", quiet = TRUE) +load_all(path = ".", env = new.env(parent = globalenv()), quiet = TRUE) } \arguments{ \item{path}{Path to package root directory.} +\item{env}{Environment to source files into. Defaults to a fresh +environment whose parent is the global environment.} + \item{quiet}{Logical. Suppress file sourcing messages? Default TRUE.} } \value{ -Character vector of sourced files (invisibly). +The environment into which files were sourced (invisibly). + Does not modify the search path. If you want search-path behavior, + call `attach()` yourself: + \preformatted{attach(tinypkgr::load_all(), name = "tinypkgr:mypkg")} } \description{ Sources all R files in a package for interactive development, @@ -19,7 +25,13 @@ without requiring a full install. } \examples{ \dontrun{ -load_all() -load_all(quiet = FALSE) # Show each file being sourced +e <- load_all() +e$my_function(123) + +# Or attach to the search path explicitly: +attach(load_all(), name = "tinypkgr:mypkg") +} } +\seealso{ +\code{\link[pkgKitten]{kitten}} for scaffolding a new package. } diff --git a/man/use_github_action.Rd b/man/use_github_action.Rd new file mode 100644 index 0000000..bffa56f --- /dev/null +++ b/man/use_github_action.Rd @@ -0,0 +1,23 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{use_github_action} +\alias{use_github_action} +\title{Add a GitHub Actions CI Workflow} +\usage{ +use_github_action(path = ".") +} +\arguments{ +\item{path}{Path to package root directory.} +} +\value{ +Path to the created YAML file (invisibly). +} +\description{ +Writes `.github/workflows/ci.yaml` using the r-ci template (Ubuntu and +macOS, via `eddelbuettel/github-actions/r-ci@master`). Adds `^\.github$` +to `.Rbuildignore` if not already present. +} +\examples{ +\dontrun{ +use_github_action() +} +} diff --git a/man/use_version.Rd b/man/use_version.Rd new file mode 100644 index 0000000..4a3991d --- /dev/null +++ b/man/use_version.Rd @@ -0,0 +1,27 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{use_version} +\alias{use_version} +\title{Bump Package Version} +\usage{ +use_version(which = c("patch", "minor", "major", "dev"), path = ".") +} +\arguments{ +\item{which}{Which component to bump: "patch" (0.2.0 -> 0.2.1), +"minor" (0.2.0 -> 0.3.0), "major" (0.2.0 -> 1.0.0), or +"dev" (0.2.0 -> 0.2.0.1, or 0.2.0.1 -> 0.2.0.2).} + +\item{path}{Path to package root directory.} +} +\value{ +The new version string (invisibly). +} +\description{ +Increments the Version field in DESCRIPTION and prepends a new section +header to NEWS.md (if present) so the two never drift apart. +} +\examples{ +\dontrun{ +use_version("patch") +use_version("dev") +} +}