R/afni_cli_parser.R

Defines functions get_df_arg get_df_arg_new get_arg_info_from_function_signature parse_cli_info_str fix_help_string add_arg create_parser_from_help create_parser_from_function run_tool parser_error

#' @importFrom magrittr %>%
#' @export
magrittr::`%>%`


parser_error <- function(c){
  msg <- conditionMessage(c)
  error_pat <- "parse error:|help requested:"
  if (msg %>% stringr::str_detect(error_pat)){
    cat(msg %>% stringr::str_split(error_pat) %>% purrr::map_chr(2))
  }
  opt <- options(show.error.messages = FALSE)
  on.exit(options(opt))
  stop()
}

#' Run a tool
#' @export
run_tool <- function(toolname,package="afnistats",catch_error=TRUE,user_args=commandArgs(trailingOnly = TRUE)){

  parser <- create_parser_from_function(toolname,package)
  tryCatch(parsed_ops <- parser$parse_args(user_args),
           error = function(c){if (catch_error) parser_error(c) else{stop(c)}},
           quietly = TRUE
  )

  do.call(toolname,parsed_ops)
}

#'Generate a command line parser from function documentation
#'
#'If a section "CLI info" is contained in the help text for a function, this
#'tool parses the information in order to create a command line parser. The
#'formatting for the section should be input to \code{\link[tibble]{tribble}}
#'(all characters preceding the first '~' are discarded). The output is a
#'command-line parser that can process user arguments obtained from
#'\code{commandArgs(trailing=TRUE)}. Positional arguments in the function become
#'required arguments on the command line. Keyword arguments become long options
#'with the same default as the R function.
#'
#'The column headings represent values that are passed to the add_argument
#'method of \code{\link[argparse]{argparse}} and it must contain at least:
#'\enumerate{ \item param, the argument name. This becomes the argument/option
#'name in the command line tool \item nargs, the number of items expected to
#'follow the parameter. \item metavar, a representation of the arguments input.
#'This helps cue the user to what information they should provide. It is not
#'required when nargs is 0 \item type, the expected type of the input.}
#'
#'Other columns that might be useful \enumerate{\item group_inc, space separated
#'group labels to which the parameter belongs. All parameters in a group must be
#'provided together \item group_ex, same as group_inc but denotes that
#'parameters are mutually exclusive in their usage \item prefix, an alternative
#'string to denote options if you wish to make your interface deviate from the
#''--' of the conventional
#'\href{https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html}{
#'GNU/LINUX interface}. \item other arguments to
#'argparse.ArgumentParser$add_argument.}
#'
#'
#'
#'@param topic The help topic that should be parsed
#'@param package The package in which the help topic is contained
#'
#'@return argparse:ArgumentParser
#'@export
create_parser_from_function <- function(topic="MBA",package="afnistats"){
  parsed_help <- parse_help_topic(topic,package)
  parser <- create_parser_from_help(parsed_help)
  parser
}



create_parser_from_help <- function(parsed_help){

  parser <- argparse::ArgumentParser(description=fix_help_string(parsed_help$description),add_help=FALSE)
  parser$add_argument('-help',action='help') # add AFNI's help flag
  df_arg <- get_df_arg(parsed_help)
  df_arg %<>%
    dplyr::group_by(1:dplyr::n()) %>%
    tidyr::nest()
  df_arg %>%
    dplyr::mutate(parsed = purrr::map(data, ~ add_arg(.x,parser)))
  parser

}

add_arg <- function(df,parser){
  # nargs can be a character
  if (!df$nargs %in% c("?","+","*")){
    df$nargs <- as.integer(df$nargs)
    # option flags that take no arguments are treated differently.
    if  (df$nargs == 0){
    df <- df %>%
      dplyr::select(-c(nargs,type,metavar,default)) %>%
      dplyr::mutate(action = 'store_true')
    }
    # required arguments can't have a default
    if (df$required == TRUE){
      df <- dplyr::select(df,-c(default))
    }
  }


  # "Not required" is used to specify that the value should not be used to create the parser and is dropped accordingly
  arg_list <- df %>%  dplyr::select_if(~ !is.character(.x) || stringr::str_detect(.x,"^Not required$",negate=TRUE))
  arg_list <- arg_list %>% purrr::transpose() %>% tibble::deframe()
  # arg_list <- df %>% purrr::transpose() %>% purrr::map(~purrr::discard(.x,is.na))
  do.call(parser$add_argument,c(stringr::str_c('-',df$dest),arg_list))
  # This can be useful for debugging...
  help_val <- capture.output(parser$print_help())
  if (sum(stringr::str_detect(help_val,'.{1,15}Error'))) {
    stop(stringr::str_c("parser broken for: ",arg_list$dest))
  }

  TRUE
}

fix_help_string <- function(string){
  stringr::str_replace_all(string,'\\n',' ')
}


parse_cli_info_str <- function(parsed_help){
  help_sections <-  parsed_help[names(parsed_help) == "section"]
  if (!length(help_sections)){return(tibble::tibble(dest=character()))}
  cli_section <- help_sections %>%
    purrr::keep(~ stringr::str_detect(.x,stringr::regex("cli ?info", ignore_case = TRUE)))
  if (!length(cli_section)){return(tibble::tibble(dest=character()))}

  tribble_str <- cli_section  %>%
    stringr::str_split('~',n=2) %>%
    purrr::flatten() %>%
    .[[2]]

  tribble_str %>%
    stringr::str_replace_all("~",'') %>%
    stringr::str_replace("\\n","empty\n") %>%
    readr::read_csv() %>%
    dplyr::select(-empty)
}


get_arg_info_from_function_signature <- function(funcname){
  formals(funcname) %>%
  purrr::map(~ rlang::maybe_missing(.x,"Not required"))  # the 'missing' symbol is nasty, get rid of it
}

get_df_arg_new <- function(parsed_help){
  arguments <- purrr::flatten(parsed_help['arguments'])
  help_vals <- purrr::map_chr(arguments,'description') %>%
    purrr::set_names(purrr::map(arguments,'arg'))

  # Parse the cli info section for argparse arguments
  df <- parse_cli_info_str(parsed_help)

  # parse the function signature for required arguments and defaults
  default_args <- get_arg_info_from_function_signature(parsed_help)

    # dplyr::mutate_all(as.character)

  # Construct full output
  df %>%
    dplyr::mutate(
      help= help_vals[dest],
      nargs = fix_nargs(nargs),
      default = default_args[dest], # Get the default values for the arguments

      required = ifelse(is.na(default),TRUE,FALSE),
    )
}

get_df_arg <- function(parsed_help){
  # Used the help + function signature for parser information
  arguments <- purrr::flatten(parsed_help['arguments'])
  cli_info <- parse_cli_info_str(parsed_help )
  arg_defaults <- formals(parsed_help$alias)

  df_init <- purrr::map(arguments,'description') %>%
    purrr::set_names(purrr::map(arguments,'arg')) %>%
    tibble::enframe(name="dest",value="help") %>%
    dplyr::left_join(cli_info,by="dest") %>%
    dplyr::mutate(nargs= if("nargs" %in% names(.)) nargs else "1")


  df_arg <- df_init %>%
    dplyr::mutate(
      help = fix_help_string(help),
      default = fix_default_val(arg_defaults,dest), # Get the default values for the arguments
      required = dplyr::if_else(suppressWarnings(stringr::str_detect(default,"Not required")),TRUE,FALSE)
    )

  check_func_sig_and_doc_args(parsed_help,df_init,arg_defaults)
  df_arg
}

fix_default_val <- function(arg_defaults,dest){
  unname(arg_defaults[dest]) %>%
    purrr::map(~ rlang::maybe_missing(.x,"Not required")) # the 'missing' symbol is nasty, get rid of it
}
check_func_sig_and_doc_args <- function(parsed_help,df_init,arg_defaults){
  x <- names(arg_defaults)
  y <- df_init$dest
  arg_diff <- c(setdiff(x,y),setdiff(y,x))
  undocumented_args_error <- paste(
    c('For the function ',
      parsed_help$alias,
      ', the following arguments were missing from either the function signature or the help documentation: ',
      arg_diff,
      '. All arguments must be documented if you wish to produce a command line interface.'),
  collapse = '')
  if (length(arg_diff)) stop(undocumented_args_error)

  # Check that NA was not specified as a default arg
  if (any(is.na(arg_defaults))){
    stop("A default of NA was specificied for an argument. This is not allowed when generating a CLI. NULL is a possibly alternative.")
  }
  }

#' parse_help_topic
#'
#' For a given function the help text is retrieved and returned as a list.
#'
#' @param topic Function for which to get help
#' @param package_name Package from which the function comes
#'
#' @return Parsed help as a list
parse_help_topic <- function(topic,package_name){
  help_rd <- get_help_topic(topic,package_name)
  help_val <- Rd2list(help_rd)
  help_val
}

#' An example function for testing parsing to generate a cli
#'
#' Description
#'
#' Details
#'
#' @param x Description for x
#' @param a Description of a
#' @param b
#'   Description of b
#' @param c Description of c
#' @section Important:
#' Don't run with scissors!
#' @export
#' @section Cli info:
#' See \code{\link{create_parser_from_function}} for details on this section.
#' \preformatted{
#' ~dest,  ~metavar,   ~type,         ~nargs,
#' "x",    "STRING",   "character",   "1",
#' "a",    "N",        "integer",     "1",
#' "b",    "N",        "integer",     "1",
#' "c",    "N",        "logical",     "+",
#' }
example_func_for_parsing <- function(x,a=NULL,b=5,c=TRUE){
  output <- list(x=x,a=a,b=b,c=c)
  output
}


Rd2list <- function(Rd){
  #from https://stackoverflow.com/questions/8918753/r-help-page-as-object
  names(Rd) <- substring(sapply(Rd, attr, "Rd_tag"),2)
  temp_args <- Rd$arguments

  Rd$arguments <- NULL
  myrd <- lapply(Rd, unlist)
  myrd <- lapply(myrd, paste, collapse="")

  temp_args <- temp_args[sapply(temp_args , attr, "Rd_tag") == "\\item"]
  temp_args <- lapply(temp_args, lapply, paste, collapse="")
  temp_args <- lapply(temp_args, "names<-", c("arg", "description"))
  myrd$arguments <- temp_args
  return(myrd)
}

#' get_help_topic
#'
#'First devtools installed packages are checked. If this fails, installed packages are checked
#'
#' @param topic Function for which to get help
#' @param package_name Package from which the function comes
#'
#' @return Rd format help. Can be parsed with tools::Rd2text and other similar functions
get_help_topic <- function(topic,package_name){
  help_rd <- tryCatch(get_dev_help(topic,package_name),
                      error = function(e) get_installed_help(topic,package_name),
                      quietly =TRUE
  )
  help_rd
}

get_installed_help <- function(topic,package_name){
  help_file <- utils::help(topic,package=eval(package_name))
  rd <- utils:::.getHelpFile(help_file)
  rd
}

get_dev_help <- function(topic,package_name){

  if (requireNamespace("pkgload", quietly = TRUE)) {
    help_file <- pkgload::dev_help(topic,package_name)
    # below fails because it is not rdx
    # rd <- utils:::.getHelpFile(help_file$path)
    rd <- tools::parse_Rd(help_file$path)
    rd
    }
}

expect_equal_param_df <- function(df1,df2){
  expect_equal(df1$default,df2$default)
  expect_equal(
    df1 %>% dplyr::select(-default) %>% .[,order(colnames(.))],
    df2 %>% dplyr::select(-default) %>% .[,order(colnames(.))]
  )
}
afni/afnistats documentation built on June 13, 2020, 12:22 a.m.