garnish

Popular Python decorators ported to R with the adorn framework.

Decorator What it does Python equivalent
retry() Calls a function again when it fails tenacity
lru_cache() Caches results, evicting the least recently used functools.lru_cache
cache Caches results, never evicting functools.cache
disk_cache() Caches results as files, so they outlive the session joblib.Memory.cache
validate_args(), validate_return() Checks arguments and return values with any validator function pydantic validate_call
circuit_breaker() Stops calling a function after repeated failures, then retries after a cooldown pybreaker
rate_limit() Limits how often a function can be called in a time window ratelimit
contextmanager Writes setup and teardown once, as local_, with_ and decorator forms contextlib.contextmanager

Installation

# install.packages("pak")
# pak::pak("DyfanJones/garnish")
install.packages("garnish", repos = c('https://dyfanjones.r-universe.dev', 'https://cloud.r-project.org'))

Usage

retry() and lru_cache() build a decorator, which you apply to a function with %@% or by calling it on the function.

Retry

library(garnish)

n <- 0
flaky <- function() {
  n <<- n + 1
  if (n < 3) stop("not yet")
  n
}

retry(stop = stop_after_attempt(5))(flaky)()
#> [1] 3

Choose when to give up, how long to wait, and what to retry:

fetch := retry(
  stop = stop_after_attempt(4),
  wait = wait_exp(multiplier = 0.1) + wait_rand(0, 0.1),
  retry = retry_if_error_message(match = "timeout"),
  before_sleep = before_sleep_log()
) %@% function(url) {
  # ...
}

When stop fires, a retry_error condition is signalled. Set reraise = TRUE to re-throw the last error instead, or give retry_error_callback a function to return a fallback value:

poll := retry(
  stop = stop_after_attempt(3),
  retry = retry_if_result(is.null),
  retry_error_callback = function(state) "gave up"
) %@% function() NULL

poll()
#> [1] "gave up"
Strategy Functions
Stop (stop) stop_never(), stop_after_attempt(), stop_after_delay(), stop_before_delay(), stop_any(), stop_all()
Wait (wait) wait_none(), wait_fixed(), wait_rand(), wait_exp(), wait_exp_jitter(), wait_chain(), wait_combine() or a + b
Retry (retry) retry_all(), retry_never(), retry_if_error(), retry_if_error_message(), retry_if_result()
Hooks before_sleep_none(), before_sleep_log(), after_sleep_none(), after_sleep_log()

LRU cache

slow_square := lru_cache() %@% function(x) {
  Sys.sleep(0.2)
  x^2
}

slow_square(4) # slow
slow_square(4) # cached

cache is the same without a size limit. It takes no parameters, so apply it directly:

slow_cube := cache %@% function(x) {
  Sys.sleep(0.2)
  x^3
}

disk_cache() keeps results in files under dir, so they survive the session and can be shared between sessions. dir has no default, because a temporary directory would defeat the point:

slow_cube := disk_cache("~/.cache/my-analysis") %@% function(x) {
  Sys.sleep(0.2)
  x^3
}

Set format = "qs2" to use the qs2 package instead of saveRDS(). On a 1,000,000-row data frame it was about 7x faster to write and 3.5x faster to read back, and slightly smaller on disk (one machine, so measure your own). qs2 is optional and must be installed.

Editing a function's body invalidates its entries, but changing something it calls does not. Results are saved with saveRDS(), so environments and external pointers do not round-trip.

Context manager

R has no yield, so a context manager calls teardown() right after acquiring each resource and returns the resource. Unlike retry() and lru_cache(), contextmanager takes no parameters, so it is applied without calling it first.

local_tempdir := contextmanager %@% function(pattern = "tmp") {
  path <- tempfile(pattern)
  dir.create(path)
  teardown(unlink(path, recursive = TRUE))
  path
}

That one definition gives three forms. Called inside a function, cleanup runs when that function exits, like withr::local_*():

f <- function() {
  d <- local_tempdir()
  writeLines("hi", file.path(d, "x.txt"))
  d
}

d <- f()
dir.exists(d)
#> [1] FALSE

with_context() scopes it to a block, passing the value to a function:

with_context(local_tempdir(), function(d) list.files(d))
#> character(0)

as_decorator() runs a function inside the context:

g := as_decorator(local_tempdir) %@% function() "ran inside context"
g()
#> [1] "ran inside context"

Cleanups run last in, first out, and the ones registered before a failing setup step still run. A context manager called from another's setup is cleaned up with the outer one.

Validation

validate_args() takes one validator per argument and validate_return() takes one for the result. A validator is any function (or formula) that returns TRUE to pass. Returning a string fails with that string as the message, which is how checkmate's check_*() functions behave, so they work without garnish depending on them:

scale_by := validate_args(x = is.numeric, n = ~ .x > 0) %@% function(x, n = 1) {
  x * n
}

scale_by(2, 3)
#> [1] 6

scale_by("a", -1)
#> Error: Invalid arguments.
#> ✖ `x`: failed validation.
#> ✖ `n`: failed validation.

All failures are reported together as a garnish_validation_error. Defaults are validated too.

Learn more

Vignettes: vignette("retry"), vignette("caching") and vignette("contextmanager") and vignette("validation"). The help pages are ?retry, ?lru_cache (which also covers cache and disk_cache), ?contextmanager and ?validate_args. See the adorn documentation for how decorators are applied, stacked and removed.