07 · Package Development Basics¶
Every function you've written so far has lived in a single script, loaded
with library() calls at the top and source() if it's spread across
files. An R package is the standard way to bundle functions,
documentation, and tests into something installable and shareable — this
module builds a small real package from scratch: the file layout,
roxygen2 documentation, and R CMD build/INSTALL/check.
Minimum package layout¶
mystats/
├── DESCRIPTION
├── NAMESPACE
├── R/
│ └── mean_ci.R
└── tests/
├── testthat.R
└── testthat/
└── test-mean_ci.R
DESCRIPTION is metadata (name, version, dependencies, license) as a
plain key-value text file:
Package: mystats
Title: Small Statistics Helpers
Version: 0.1.0
Authors@R: person("Jane", "Doe", email = "jane@example.com", role = c("aut", "cre"))
Description: A minimal example package with a couple of statistics helpers.
License: MIT + file LICENSE
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.0
Suggests: testthat (>= 3.0.0)
Config/testthat/edition: 3
NAMESPACE declares which functions the package exposes to users
(export(...)) and which functions it imports from other packages — you
almost never hand-write this file; it's generated for you.
Documenting functions with roxygen2¶
#' Compute a confidence interval for a mean
#'
#' @param x A numeric vector.
#' @param conf Confidence level, default 0.95.
#' @return A named numeric vector with `lower` and `upper`.
#' @export
#' @examples
#' mean_ci(c(1, 2, 3, 4, 5))
mean_ci <- function(x, conf = 0.95) {
x <- x[!is.na(x)]
n <- length(x)
se <- sd(x) / sqrt(n)
alpha <- 1 - conf
margin <- se * qt(1 - alpha / 2, df = n - 1)
m <- mean(x)
c(lower = m - margin, upper = m + margin)
}
The #' comment block directly above the function is not decoration —
roxygen2::roxygenise() parses it to generate both the NAMESPACE
export() entry (from @export) and a proper .Rd help file (from
@param, @return, @examples):
The generated man/mean_ci.Rd is what powers ?mean_ci once the package
is installed — write the roxygen block once, get both the namespace entry
and the help page from it, rather than maintaining either by hand.
Building, installing, and checking¶
* checking for file 'mystats/DESCRIPTION' ... OK
* preparing 'mystats':
* checking DESCRIPTION meta-information ... OK
* building 'mystats_0.1.0.tar.gz'
R CMD build bundles the package directory into a single .tar.gz — the
same format you'd submit to CRAN or hand to a colleague. R CMD INSTALL
mystats_0.1.0.tar.gz then installs it into your R library so it can be
loaded with a normal library(mystats) call, exactly like any
CRAN package:
R CMD check (not run here, but standard practice before any real
release) runs a much stricter battery of validations — documentation
matches function signatures, examples actually run, NAMESPACE is
consistent, no undeclared dependencies — catching problems that a plain
build/INSTALL cycle would silently let through.
Tests live inside the package¶
# tests/testthat/test-mean_ci.R
test_that("mean_ci returns lower < upper", {
ci <- mean_ci(c(1, 2, 3, 4, 5))
expect_lt(ci["lower"], ci["upper"])
})
test_that("mean_ci handles NA", {
ci <- mean_ci(c(1, 2, NA, 4, 5))
expect_false(any(is.na(ci)))
})
This is the same testthat machinery from Level 2 — the only difference
inside a package is that tests live under tests/testthat/ by convention
and are wired up by test_check(), which R CMD check runs
automatically as part of validating the package.
The @export trap¶
Trap: a function defined in R/ but missing the @export roxygen
tag is still usable inside the package's own code, but stays invisible
to anyone who calls library(mystats) — mean_ci without @export
would exist in the package's internal namespace but raise "could not find
function" for an external caller. This is deliberate (it's how you keep
genuinely internal helper functions out of your package's public
surface), but it's also the single most common reason "I wrote the
function but users can't call it" bugs happen — check for a missing
@export before anything more exotic.
Cheat sheet¶
| Task | Command |
|---|---|
| Generate NAMESPACE + docs from roxygen comments | roxygen2::roxygenise(".") |
| Bundle the package into a distributable file | R CMD build <pkg-dir> |
| Install the built package locally | R CMD INSTALL <pkg>_<version>.tar.gz |
| Run the full CRAN-style validation suite | R CMD check <pkg>_<version>.tar.gz |
| Make a function visible outside the package | @export roxygen tag |
| Run the package's test suite | testthat::test_dir("tests/testthat") or via R CMD check |
| View a function's generated help | ?function_name (after install) |
How It Actually Works¶
R CMD check, the gate CRAN and most CI pipelines run, doesn't just "look
for errors" — it performs a fixed sequence of independent checks: parsing
DESCRIPTION for valid metadata, byte-compiling every function in R/ to
catch syntax errors, cross-referencing NAMESPACE exports against actual
function names, running every example in your .Rd docs as real R code,
executing your test suite, and (optionally) building the vignettes with
Pandoc — each check is its own subprocess, which is why a single check
failing (like an undocumented argument) doesn't prevent the others from
running and reporting separately.
usethis::use_package("dplyr") doesn't install anything — it edits your
DESCRIPTION's Imports: field, which only records a declared
dependency; nothing forces you to actually call dplyr:: functions
correctly until R CMD check tries to load your namespace and verifies
every function referenced via pkg::fun() or @importFrom actually
exists in that dependency's installed namespace. This static
cross-referencing (not a runtime check) is exactly why a typo'd function
name from an imported package is caught at check time rather than only
when a user happens to trigger that code path.
Exercise¶
- Create a minimal package with one exported function of your own,
document it with roxygen2 (
@param,@return,@examples), runroxygen2::roxygenise("."), and confirm bothNAMESPACEand a.Rdfile underman/are generated correctly. - Add a second, un-exported helper function your exported function calls
internally — confirm it works from inside the package but is not
visible after
library()from outside. - Add a
tests/testthat/test for your exported function, install the package, and runtest_dir()against it to confirm it passes.