Add an argument to an existing step
Use this skill when adding a new argument to an existing step_*() or check_*() function, either in recipes itself or in a recipes extension package.
Overview
A step's state is stored in the object created by step_*_new(), so a new argument has to be threaded through several functions in order. Missing one of them produces a step that silently ignores the argument. Recipes are also frequently saved to disk and reloaded after a package upgrade, so a new argument must not break objects created by an older version.
Throughout, step_name() is used as a stand-in for the real step, and new_arg for the new argument.
- Add the argument to
step_name()and pass it tostep_name_new(). - Add the argument to
step_name_new()and pass it tostep(). - Pass the argument through
prep.step_name(). - Backfill a default in
prep.step_name()so older recipes keep working. - Use the argument in
bake.step_name()(or wherever it takes effect). - Document the argument.
- Add a backwards-compatibility test and a test that the argument works.
- Add a NEWS bullet.
Workflow
Step 1: Add the argument to step_name()
Add the argument to the user-facing constructor, with its default value, and pass it into the step_name_new() call inside add_step().
Place it with the other step-specific arguments: after ..., role, and trained, but before skip and id. skip and id stay last so that the argument order stays consistent across steps.
step_name <-
function(
recipe,
...,
role = NA,
trained = FALSE,
new_arg = default_value,
skip = FALSE,
id = rand_id("name")
) {
add_step(
recipe,
step_name_new(
terms = enquos(...),
trained = trained,
role = role,
new_arg = new_arg,
skip = skip,
id = id
)
)
}
If the argument needs validation, use the standard check_*() helpers (see the tidy-argument-checking skill). Validate in prep() rather than in the constructor, since the constructor runs before the data is available.
Step 2: Add the argument to step_name_new()
The constructor is a plain function with no defaults; every argument must be supplied by every caller. Add new_arg to the formals and pass it to step().
step_name_new <-
function(terms, role, trained, new_arg, skip, id) {
step(
subclass = "name",
terms = terms,
role = role,
trained = trained,
new_arg = new_arg,
skip = skip,
id = id
)
}
Step 3: Pass the argument through prep.step_name()
prep.step_name() rebuilds the step by calling step_name_new() again, this time with trained = TRUE. Every field must be carried over from x, or it is lost when the recipe is prepped.
prep.step_name <- function(x, training, info = NULL, ...) {
col_names <- recipes_eval_select(x$terms, training, info)
# ... estimation code ...
step_name_new(
terms = x$terms,
role = x$role,
trained = TRUE,
new_arg = x$new_arg,
skip = x$skip,
id = x$id
)
}
Search the whole file for other step_name_new() calls; some steps call it from more than one place.
Step 4: Handle backwards compatibility
A recipe created and saved by an older version of the package has no new_arg field, so x$new_arg is NULL. Backfill the default at the top of prep.step_name(), before the field is used:
prep.step_name <- function(x, training, info = NULL, ...) {
if (is.null(x$new_arg)) {
x$new_arg <- default_value
}
# rest of prep
}
Two things to keep in mind:
- The backfill value must be the same default used in
step_name(), so an old recipe behaves exactly as it did before. - An already-prepped old recipe never calls
prep()again, so ifbake.step_name()readsobject$new_argdirectly it also needs to tolerateNULL. Either guard there too, or use a helper that treatsNULLas the default (this is howsparseworks:sparse_is_yes(NULL)returnsFALSE).
Step 5: Document the argument
Add a @param entry to the step's roxygen block, wrapped at 80 characters. If the argument is shared across several steps, check man-roxygen/ for an existing template to use with @template instead of writing it out again.
Then re-document:
Rscript -e "devtools::document()"
Step 6: Add tests
Add both tests to tests/testthat/test-name.R, next to the existing tests for that step.
First, a backwards-compatibility test. It preps a recipe, deletes the new field to simulate an object made by an older version, and checks that baking still gives the same result:
test_that("new_arg argument is backwards compatible", {
rec <- recipe(~., data = data_set_of_your_choice) |>
step_name(all_predictors()) |>
prep()
exp <- bake(rec, data_set_of_your_choice)
# Simulate old recipe
rec$steps[[1]]$new_arg <- NULL
expect_identical(
bake(rec, data_set_of_your_choice),
exp
)
})
Second, a test that the argument actually changes behavior. Bake with a non-default value and check the result differs from the default in the expected way. If the argument is validated, add an expect_snapshot(error = TRUE) test for an invalid value.
Run the tests:
Rscript -e "devtools::test_active_file('R/name.R')"
Step 7: Add a NEWS bullet
Add a bullet to the development version section of NEWS.md, with the step name early in the bullet and the issue number in parentheses. Keep it on one line and keep bullets ordered alphabetically by function name.
* `step_name()` gained the `new_arg` argument, which ... (#1234)
Implementation checklist
- Add
new_argtostep_name()and pass it tostep_name_new()insideadd_step(). - Add
new_argtostep_name_new()and pass it tostep(). - Pass
new_argto everystep_name_new()call inprep.step_name(). - Backfill
if (is.null(x$new_arg))inprep.step_name()for older recipes. - Make sure
bake.step_name()tolerates aNULLvalue if it reads the field directly. - Use the argument where it takes effect.
- Document the argument with
@param(or an existing@template). - Run
devtools::document(). - Add a backwards-compatibility test.
- Add a test that the new argument works.
- Run the tests.
- Add a bullet to
NEWS.md. - Run
air format ..