10 Desenvolvimento de pacotes

Este capítulo foi inicialmente escrito por mim em inglês, como um post de blog. No futuro, o traduzirei.

10.1 Introdução

Esse capítulo é uma versão em html de meu script de desenvolvimento de pactotes. Não é altamente explicativo, mas apresenta as funções e o flow de desenvolvimento que utilizo.

This script provides an organization of the several utility functions for developing R packages provided by devtools and friends.

It conditions the usage of best practices as recommended by the devtools family of packages and the books “R Packages (2e)” by Hadley Wickham and “rOpenSci Packages: Development, Maintenance, and Peer Review” by the rOpenSci software review editorial team.

The script is organized from end to beggining:

  1. Set the package up (basically run once per package).
  2. update DESCRIPTION, dependencies, version, and releases.
  3. rebuilding and testing the package.
  4. creating new files and a blank canvas for informal debugging. Not shown here.
  5. Appendix:
    • Function documentation.
    • Other tips for package development, including packages you should use.
    • Utility functions used in the script.

10.2 Setup

10.2.1 Name and Creation

available::available("predicater")

usethis::create_package("../predicater", check_name = FALSE)
# Also consider `create_tidy_package()`, but I prefer to set up manually

10.2.2 Version Control

usethis::use_git() # Or manually via `git init`
# Setup github manually or via `use_github()`
usethis::use_github_links() # Adds links to DESCRIPTION
# Be wary about sensitive data, consider using `git_vaccinate()`
# Consider using the git/github helpers usethis::pr_* and the ones at
# (https://usethis.r-lib.org/reference/index.html#git-and-github)

10.2.3 Documentation

usethis::use_mit_license() # Consider other licenses
usethis::use_readme_rmd()
usethis::use_news_md()
usethis::use_package_doc() # Then, populate this file with package-level docs
# If you have a logo, conisder `use_logo()`
usethis::use_lifecycle_badge("experimental")
# If your package is released in some repository, consider using the other
# badge functions

10.2.4 Other Infrastructure

usethis::use_spell_check()
usethis::use_revdep()
usethis::use_lifecycle()
usethis::use_testthat()
usethis::use_pkgdown()
renv::install("ricardo-semiao/ricardo-semiao:themes/rspkgdown")
# Consider the content-related helpers:
# `use_cpp11()`, `use_c()` and friends, `use_make()`
# `use_pipe()`, `use_tibble()`

10.2.5 renv

renv::init()
renv::settings$external.libraries()
renv::settings$snapshot.type("explicit")
package.dependency.fields <- c("Imports", "Depends", "LinkingTo") #, "Suggests"
renv::install(c("languageserver", "httpgd"))

10.2.6 CI/CD

usethis::use_coverage()
usethis::use_github_action("check-standard")
usethis::use_github_action("test-coverage")
# Consider the {air} for styling with `use_air()`. I prefer to do it manually
# If you want to use pkgdown with the standard Github pages scheme, consider
# using `use_pkgdown_github_pages()`

10.2.7 Others

# - Helpers to set configurations options at
# (https://usethis.r-lib.org/reference/index.html#configuration)
# - Packages to switch practices: roxygen2md, prefixer, changer
# - Add a tutorial via `use_tutorial()`
# - If your package becomes big enough, consider adding a MAINTENANCE.md,
# a code of conduct (`use_code_of_conduct()`), a contributing guide
# (`use_tidy_contributing()`), and a citation file
# - Add a `use_cran_comments()` before submitting to CRAN
# - Add a cheatsheet

# Initial commit:
# At the end, check if no file is being committed/builded when it should not:
usethis::use_build_ignore(c("personal/", "predicater.code-workspace/"))

10.3 Update Description, Packages, Version, and Releases

10.3.1 Versioning and Realeases

# Versioning:
desc <- desc::description$new()
desc$get_version()
desc$set_version(version)
desc$bump_version(which = c("patch", "minor", "major", "dev"))

# Releases:
devtools::submit_cran() # Also consider `release()`
usethis::use_github_release()

10.3.2 Package imports

deps <- tibble::tribble( # Annotate dependencies here
  ~Type, ~Package, ~MinVersion,
  "Depends", "R", "4.3",
  "Imports", "rlang", NA, # Full import

  "Imports", "vctrs", NA,
  "Imports", "cli", NA,
  "Imports", "glue", NA,
  "Imports", "methods", NA,

  "Suggests", "utils", NA

  # Others included in commands i.e. thestthat
)
# purrr::walk(c("rlang", "purrr"), library, character.only = TRUE)
# cli_abort <- cli::cli_abort; cli_warn <- cli::cli_warn
# cli_inform <- cli::cli_inform; glue <- glue::glue

# Check the complexity of possible dependencies:
pak::pkg_deps_tree("pkg_you_are_thinking_to_use")
pak::pkg_deps_explain("pkg1", "pkg2")
itdepends::dep_weight(
setdiff(
    subset(deps, Type == "Imports")$Package,
    c("utils", "methods", "compiler")
)
)

# Dependencies:
use_packages(deps)
usethis::use_import_from("glue", "glue")
usethis::use_import_from("cli", c("cli_abort", "cli_warn", "cli_inform"))
#usethis::use_import_from("vctrs", c("vec_c"))

# Installation via renv:
renv::status()
renv::snapshot(packages = subset(deps, Type == "Imports")$Package)
renv::update(
packages = subset(deps, Type == "Imports")$Package |> setdiff("methods")
)
renv::restore()
renv::dependencies()$Package |> unique() |> sort()

# Consider adding standalone files via `use_standalone()`, `use_github_file()`

10.3.3 DESCRIPTION file

desc <- desc::description$new()

# Main fields:
desc$set("Title",
"Modern and consistent validation for R"
)
desc$set("Description", glue::glue(
"Validation of R objects with more consistency and flexibility than base \\
'is' functions, having predictable and well documented behavior. \\
Provides simple 'is'/'are'/'has' predicates, and complex assertions for \\
common input validation needs. Includes improved object comparison, \\
prototypes, if-else chains, and pattern matching."
))
desc$set_authors(c(
person(
    "Ricardo", "Semião", role = c("aut", "cre"),
    email = "ricardo.semiao@outlook.com"
)
))
# Consider adding `comment = c(ORCID = "...")`
desc$add_urls("URL", "https://example.com")

# Mathjax, if required:
usethis::use_package("mathjaxr", type = "Imports")
desc$set("RdMacros", "mathjaxr")
desc$set("BuildManual", "TRUE")
# Finally, add `@importFrom mathjaxr preview_rd` somewhere

# Consider other fields (https://r-pkgs.org/description.html#other-fields)

# Writing: (maybe commit before)
desc$normalize()
desc$write()

10.4 Rebuilding and Testing

10.4.1 Load and Document

devtools::document()
devtools::load_all() # Or maybe `reload()`
devtools::build_readme()
devtools::build_vignettes() # Uninstall with `clean_vignettes()`

10.4.2 Essencial Tests

testthat::set_max_fails(Inf)
devtools::check_man()
devtools::test() # filter = "conditions"
devtools::run_examples()
devtools::check(check_dir = "personal/", args = c("--no-tests")) # "--no-examples"
revdepcheck::revdep_check()

10.4.3 Installing and Building Site/Manual

devtools::install(upgrade = FALSE) # Unistall with `uninstall()`
devtools::build(path = "personal/")
devtools::build(path = "personal/", binary = TRUE)

devtools::build_manual(path = "personal")
pkgdown::clean_site(force = TRUE)
pkgdown::build_site(preview = FALSE)

10.4.4 Additional Checks

# Coverage, spelling, and linting:
report_coverage <- covr::package_coverage(".")
covr::report(coverage_report)

report_spell <- spelling::spell_check_package(".")
report_spell
spelling::update_wordlist()

report_lint <- lintr::lint_package(".")
print(report_lint)

summarize_reports(report_lint, report_spell, report_lint)

# Override style - warning commit before using!
retport_style <- styler::style_pkg(
scope = "line_breaks",
style = styler::tidyverse_style,
strict = TRUE
)
print(retport_style)

# Development environment report, and others:
devtools::dev_sitrep()
devtools::missing_s3()
pak::pkg_deps_tree(".")

10.4.5 Package Metadata

  repo_data <- repometrics::repometrics_data(".")

  pkg_data <- pkgstats::pkgstats()
  pkg_data$loc |>
    _[c("dir", "nfiles", "nlines", "ncode", "ndoc")] |>
    subset(dir %in% c("R", "tests/testthat")) |>
    as.data.frame() |>
    (\(x) rbind(x, c(dir = "Total", colSums(x[-1]))))()

  codemetar::write_codemeta(path = "personal/codemeta.json")

10.5 Appendix: Function documentation

In general:

  • Be concise and clear
  • If the prompt includes more than one function, write a documentation for both in the same topic. Add the first function’s arguments first, then the unique ones of the second function, and so on

Create the sections:

  • Title: in sentence case, omit the @title tag
  • Description: add the @description tag. Use markdown lists when useful, specially in topics with more than one function
  • Parameters: add type hints as described below. If ... are sent to a function, cite it
    • For inherited arguments, prefer to add a simple, not-too-specific description, with a type hint, and cite the function. Use @inheritParams when the function is in the same package
  • Return: use the @returns tag. Add a description of what is returned
  • @export by default, @noRd if the function is internal, or otherwise if specified
  • Add an empty line between sections. Don’t add other sections, unless specifically requested

10.5.1 Type hints

General rules:

  • Add to @param and @value tags, in the form of \[`ptype1` | `ptype2`]. Note the trailing \

  • A ptype is a representation of an object basic metadata: typeof, class, and length/dimensions. There are four options:

    1. Prefer to use an expression that returns a prototype (see examples below), often a function call
    2. For specific classes, without simple prototype expressions, use <class_name>
    3. A literal value, e.g. \[`"option1"` | `"option2"` | NULL], \[`TRUE` | `FALSE` | `NULL`]
    4. A reference to a topic e.g. [`rlang::args_data_masking`] to describe that ... will be data masking dots. Note the lack of \ in this case
  • General guidelines:

    • Use the most specific ptype possible
    • Avoid exceeding 30 characters, adding more details as the first thing in the description instead

Specific rules:

  • For multiple arguments:
    • For ...: add [`dynamic-dots`][rlang::dyn-dots] if the dots are passed to list2(...) (or a function that does it); \[`ptype` each] if all the dots have the same ptype; or no type hint otherwise
    • For @param arg1, arg2 or @returns with more than one function in the same topic, use \[`ptype1()`, `ptype2`]
    • If some argument have the same ptype as another argument arg, use \[`=arg].
  • Variations:
    • Lists or env with elements all of the same ptype can use \[`list[ptype]`]
    • Use | for union types, and & for intersection types. Shorthands:
    • atomic() meaning the sum ptype of any atomic type
    • any meaning any R object
    • numeric() meaning double or integer
    • vector() meaning list, atomic, or expression vectors
  • Examples:
    • integer(), integer(2)
    • list(), vector("list", 10), list(a = , b = )
    • matrix(), matrix(, 3, 4), matrix(character(), x, x+1) (relating to the value of the argument x)
    • \(x, y){}, or \(){} for any argument signature

10.5.2 Formatting

  • Use roxygen2 with markdown support: wrap code in backticks; math in dollar signs; [pkg::fun()] to cite existing functions, etc.
  • Title in Sentence case
  • Never remove whole lines, not even empty ones
  • List: use -, and do not add an empty line before the list block
  • In examples, denote the result of an code by expr #> result (or with a line break for long results)
  • Line wraps and breaks:
    • Always indent with one space before a line’s text #', but not for empty lines (e.g. between sections)
    • Always wrap lines at 80 characters
    • For inline tags, e.g. @param, add two additional spaces after a line wrap, e.g. #'; for multiline tags e.g. @examples, don’t

10.6 Appendix: Other Tips

Other package-related tips:

Other function-related tips:

  • Creating custom checkers to reduce code repetition and improve consistency. Consider using {checkmate} for this.
  • Use documentation helpers that run via inline roxygen code, to reduce code repetition and improve consistency.
  • Add lifecycle badges to your functions. No need to repeat experimental if your whole package is experimental. Also use deprecate_warn() and friends (https://lifecycle.r-lib.org/articles/communicate.html).
  • Use benchmarks and profiling: {bench}, {profvis}, {memtools}, {lobstr}, {log4r}, and {boomer}.
  • Use {cli} for nice command-line outputs, and {progressbar}. Use {pillar} and {downlint} for syntax highlighting and linking.
  • Try to add C/C++ code in performance-critical sections, preferably via {cleancall} and {cpp11}.

Opinionated paradigms:

  • Use the {rlang} frameworks for metaprogramming, type-checking, and other high-quality utilities.
  • Use {vctrs} for ptypes concept and coercion.
  • Consider using S7 for OOP, or R6 if you need modify-in-place (often for performance).
  • Use {purrr}, {slider}, and {rlist} for functional programming.
  • For proeminent classes’ manipulation, consider {stringr}, {forcats}, {lubridate}, {hms} from the tidyverse; consider {bignum}, {sparsevctrs}, {bit}, {blob}, and {fastmap} for more complex objects.
  • For data wrangling, consider {dplyr} + {tidyr}, but also more efficient alternatives like {data.table} or {collapse}, {r-polars}, {duckdb}, {sparklyr} {multidplyr}, note that all of them have transpilers from dplyr code.

Templates for .onLoad() and .onAttach():

.onAttach <- function(libname, pkgname) {
  packageStartupMessage("Welcome to my package")
}
.onLoad <- function(libname, pkgname) {
  rlang::run_on_load()
}

Then use rlang::on_load() to register functions to be run at load time. Also consider creating a .onDetach function.

10.7 Appendix: Utility Functions

Function to update package dependencies to minimum major version available pkgs should be a data.frame. The third column can have the value “latest_major” to set the minimum version to the latest major version

use_packages <- function(deps) {
  purrr::pwalk(deps, \(Type, Package, MinVersion) {
    if (is.na(MinVersion)) {
      MinVersion <- NULL
    } else if (MinVersion == "latest_major") {
      MinVersion <- gsub(
        "([0-9]+)\\.[0-9]+\\.[0-9]+", "\\1.0.0", packageVersion(Package)
      )
    }

    usethis::use_package(Package, type = Type, min_version = MinVersion)
  })
}

Function to summarize linting, coverage, and spelling reports into a single table and save it as a CSV file:

summarize_reports <- function(res_lint, res_covr, res_spell, path) {
  # Relevant files:
  filenames <- list(
    man = list.files("man"),
    R = list.files("R"),
    vignettes = list.files("vignettes"),
    c("README.Rmd", "NEWS.md", "DESCRIPTION") # Also consider LICENSE.md, LICENSE
  ) |>
    unlist() |>
    Filter(x = _, \(x) tools::file_ext(x) %in% c("R", "Rd", "Rmd", "qmd", "md", ""))
  folders <- substr(names(filenames), 1, nchar(names(filenames)) - 1)

  filenames <- filenames |>
    strsplit("/") |>
    vapply(character(1), FUN = \(x) tail(x, 1))
  names(filenames) <- paste0(folders, "/", filenames)

  # Lint, coverage, and spelling:
  lint_data <- summary(res_lint)
  lint_data <- rbind(
    lint_data,
    c(filenames = list("Total"), apply(lint_data[-1], 2, sum))
  )

  covr_data <- covr::coverage_to_list(res_covr)
  covr_data <- data.frame(
    file = c(names(covr_data$filecoverage), "Total"),
    coverage = c(covr_data$filecoverage, covr_data$totalcoverage)
  ) |>
    setNames(c("filenames", "coverage"))

  spell_data <- sapply(res_spell$found, FUN = \(x) {
    stringr::str_split_fixed(x, ":", 2)[, 1] |>
      factor(levels = filenames) |>
      table()
  }) |>
    apply(1, sum)
  spell_data <- data.frame(
    filenames = c(names(filenames), "Total"),
    spell = c(spell_data, sum(spell_data))
  )

  # Merging all:
  report <- lint_data |>
    merge(covr_data, all = TRUE, by = "filenames") |>
    merge(spell_data, all = TRUE, by = "filenames")
  report <- report[match(c(names(filenames), "Total"), report$filenames), ] |>
    `rownames<-`(NULL)

  cat("Diagnostics report: (saved at", path, ")")
  report |>
    apply(2, \(x) ifelse(is.na(x), "", x)) |>
    knitr::kable(align = "lccccc")

  write.csv(report, path)
  invisible(report)
}