From 562caf9cb59a99e1376003e6c3d37339b93993d2 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 10:32:22 -0800 Subject: [PATCH 1/6] add use_s7() --- NEWS.md | 1 + R/s7.R | 105 ++++++++++++++++++++++++++++++++++ inst/templates/zzz.R | 4 ++ tests/testthat/_snaps/s7.md | 7 +++ tests/testthat/test-s7.R | 110 ++++++++++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 R/s7.R create mode 100644 inst/templates/zzz.R create mode 100644 tests/testthat/_snaps/s7.md create mode 100644 tests/testthat/test-s7.R diff --git a/NEWS.md b/NEWS.md index 0b87d4c5c..4f0b4a179 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # usethis (development version) +* Adds `use_s7()` helper to add required S7 infrastructure (@josiahparry) * Removes deprecated `use_tidy_style()` from to-do's from upkeep (@edgararuiz) * `pr_resume()` (without a specific `branch`) and `pr_fetch()` (without a specific `number`) no longer error when a branch name contains curly braces (#2107, @jonthegeek). diff --git a/R/s7.R b/R/s7.R new file mode 100644 index 000000000..56740d30c --- /dev/null +++ b/R/s7.R @@ -0,0 +1,105 @@ +#' Use S7 +#' +#' Sets up a package to use [S7](https://rconsortium.github.io/S7/) classes. +#' * Adds S7 to `Imports` in `DESCRIPTION` +#' * Creates `R/zzz.R` with a call to `S7::methods_register()` in `.onLoad()` +#' * Optionally adds a `@rawNamespace` directive to enable the use of +#' `@name` syntax in package code for R versions prior to 4.3.0 +#' (see [Using S7 in a Package](https://rconsortium.github.io/S7/articles/packages.html)) +#' +#' @param backwards_compat If `TRUE` (the default), adds a `@rawNamespace` +#' directive to the package-level documentation that conditionally imports +#' the `@` operator from S7 for R versions prior to 4.3.0. +#' +#' @export +#' @examples +#' \dontrun{ +#' use_s7() +#' } +use_s7 <- function(backwards_compat = TRUE) { + check_is_package("use_s7()") + check_uses_roxygen("use_s7()") + check_installed("S7") + + use_dependency("S7", "Imports") + + use_zzz() + ensure_s7_methods_register() + + if (backwards_compat) { + check_has_package_doc("use_s7()") + changed <- roxygen_ns_append( + '@rawNamespace if (getRversion() < "4.3.0") importFrom("S7", "@")' + ) + if (changed) { + roxygen_remind() + } + } + + ui_bullets( + c( + "_" = "Run {.run devtools::document()} to update {.path NAMESPACE}." + ) + ) + + invisible(TRUE) +} + + +use_zzz <- function() { + check_is_package("use_zzz()") + + zzz_path <- proj_path("R", "zzz.R") + + if (file_exists(zzz_path)) { + return(invisible(FALSE)) + } + + msg <- c( + "!" = "{.path R/zzz.R} does not exist.", + " " = "Would you like to create it now?" + ) + + if (is_interactive() && ui_yep(msg)) { + use_template("zzz.R", path("R", "zzz.R")) + return(invisible(TRUE)) + } + + ui_abort(c( + "{.path R/zzz.R} does not exist.", + "Create it manually or run this function interactively." + )) +} + + +ensure_s7_methods_register <- function() { + zzz_path <- proj_path("R", "zzz.R") + lines <- read_utf8(zzz_path) + + # Check if S7::methods_register() is already present (uncommented) + if (any(grepl("^\\s*S7::methods_register\\(\\)", lines))) { + return(invisible(TRUE)) + } + + # If file is identical to template, overwrite with S7 enabled version + template_lines <- render_template("zzz.R") + if (identical(lines, template_lines)) { + write_utf8(zzz_path, c( + ".onLoad <- function(libname, pkgname) {", + " S7::methods_register()", + "}" + )) + ui_bullets(c( + "v" = "Added {.code S7::methods_register()} to {.path {pth(zzz_path)}}." + )) + return(invisible(TRUE)) + } + + # File has been modified - prompt user to add it manually + ui_bullets(c( + "_" = "Ensure {.code S7::methods_register()} is called in {.code .onLoad()} + in {.path {pth(zzz_path)}}." + )) + edit_file(zzz_path) + invisible(FALSE) +} diff --git a/inst/templates/zzz.R b/inst/templates/zzz.R new file mode 100644 index 000000000..87dddd697 --- /dev/null +++ b/inst/templates/zzz.R @@ -0,0 +1,4 @@ +.onLoad <- function(libname, pkgname) { + # Uncomment the below to add S7 support + # S7::methods_register() +} diff --git a/tests/testthat/_snaps/s7.md b/tests/testthat/_snaps/s7.md new file mode 100644 index 000000000..e2584faf8 --- /dev/null +++ b/tests/testthat/_snaps/s7.md @@ -0,0 +1,7 @@ +# ensure_s7_methods_register() prompts if file differs from template + + Code + ensure_s7_methods_register() + Message + [ ] Ensure `S7::methods_register()` is called in `.onLoad()` in 'R/zzz.R'. + diff --git a/tests/testthat/test-s7.R b/tests/testthat/test-s7.R new file mode 100644 index 000000000..7c153fa8a --- /dev/null +++ b/tests/testthat/test-s7.R @@ -0,0 +1,110 @@ +test_that("use_s7() requires a package", { + create_local_project() + expect_usethis_error(use_s7(), "not an R package") +}) + +test_that("use_s7() creates zzz.R and edits DESCRIPTION", { + create_local_package() + use_roxygen_md() + use_package_doc() + + local_interactive(TRUE) + local_mocked_bindings( + ui_yep = function(...) TRUE + ) + + use_s7() + + expect_match(desc::desc_get("Imports"), "S7") + expect_proj_file("R", "zzz.R") + + zzz_contents <- read_utf8(proj_path("R", "zzz.R")) + expect_true(any(grepl("S7::methods_register\\(\\)", zzz_contents))) + expect_false(any(grepl("^\\s*#\\s*S7::methods_register", zzz_contents))) +}) + +test_that("use_s7() adds rawNamespace directive when backwards_compat = TRUE", { + create_local_package() + use_roxygen_md() + use_package_doc() + + local_interactive(TRUE) + local_mocked_bindings( + ui_yep = function(...) TRUE + ) + + use_s7(backwards_compat = TRUE) + + ns_show <- roxygen_ns_show() + expect_true(any(grepl("@rawNamespace", ns_show))) + expect_true(any(grepl('importFrom\\("S7", "@"\\)', ns_show))) +}) + +test_that("use_s7() skips rawNamespace when backwards_compat = FALSE", { + create_local_package() + use_roxygen_md() + use_package_doc() + + local_interactive(TRUE) + local_mocked_bindings( + ui_yep = function(...) TRUE + ) + + use_s7(backwards_compat = FALSE) + + ns_show <- roxygen_ns_show() + expect_false(any(grepl("@rawNamespace", ns_show))) +}) + +test_that("use_s7() is idempotent", { + create_local_package() + use_roxygen_md() + use_package_doc() + + local_interactive(TRUE) + local_mocked_bindings( + ui_yep = function(...) TRUE + ) + + use_s7() + zzz_before <- read_utf8(proj_path("R", "zzz.R")) + + use_s7() + zzz_after <- read_utf8(proj_path("R", "zzz.R")) + + expect_identical(zzz_before, zzz_after) +}) + +test_that("use_zzz() does nothing if zzz.R already exists", { + create_local_package() + + write_utf8( + proj_path("R", "zzz.R"), + ".onLoad <- function(libname, pkgname) {}" + ) + + result <- use_zzz() + expect_false(result) +}) + +test_that("ensure_s7_methods_register() prompts if file differs from template", { + create_local_package() + local_interactive(FALSE) + + # if the zzz.R differes from the template we need to promp + write_utf8( + proj_path("R", "zzz.R"), + c( + ".onLoad <- function(libname, pkgname) {", + " cat('hello, world!')", + "}" + ) + ) + + local_mocked_bindings( + edit_file = function(...) invisible() + ) + + withr::local_options(usethis.quiet = FALSE) + expect_snapshot(ensure_s7_methods_register()) +}) From 885ce3ed91b62deddd895b2bb08664167720a756 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 10:37:48 -0800 Subject: [PATCH 2/6] change test language --- tests/testthat/test-s7.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-s7.R b/tests/testthat/test-s7.R index 7c153fa8a..1ef11809b 100644 --- a/tests/testthat/test-s7.R +++ b/tests/testthat/test-s7.R @@ -56,7 +56,7 @@ test_that("use_s7() skips rawNamespace when backwards_compat = FALSE", { expect_false(any(grepl("@rawNamespace", ns_show))) }) -test_that("use_s7() is idempotent", { +test_that("use_s7() can be called twice without changing zzz.R", { create_local_package() use_roxygen_md() use_package_doc() From c3e582f3f055a3c2628cba6f93b253e28c64de78 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 10:40:17 -0800 Subject: [PATCH 3/6] tidy up --- R/s7.R | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/R/s7.R b/R/s7.R index 56740d30c..dd53c0309 100644 --- a/R/s7.R +++ b/R/s7.R @@ -76,30 +76,33 @@ ensure_s7_methods_register <- function() { zzz_path <- proj_path("R", "zzz.R") lines <- read_utf8(zzz_path) - # Check if S7::methods_register() is already present (uncommented) if (any(grepl("^\\s*S7::methods_register\\(\\)", lines))) { return(invisible(TRUE)) - } + } - # If file is identical to template, overwrite with S7 enabled version template_lines <- render_template("zzz.R") if (identical(lines, template_lines)) { - write_utf8(zzz_path, c( - ".onLoad <- function(libname, pkgname) {", - " S7::methods_register()", - "}" - )) - ui_bullets(c( - "v" = "Added {.code S7::methods_register()} to {.path {pth(zzz_path)}}." - )) + write_utf8( + zzz_path, + c( + ".onLoad <- function(libname, pkgname) {", + " S7::methods_register()", + "}" + ) + ) + ui_bullets( + c( + "v" = "Added {.code S7::methods_register()} to {.path {pth(zzz_path)}}." + ) + ) return(invisible(TRUE)) } - # File has been modified - prompt user to add it manually - ui_bullets(c( - "_" = "Ensure {.code S7::methods_register()} is called in {.code .onLoad()} - in {.path {pth(zzz_path)}}." - )) + ui_bullets( + c( + "_" = "Ensure {.code S7::methods_register()} is called in {.code .onLoad()} in {.path {pth(zzz_path)}}." + ) + ) edit_file(zzz_path) invisible(FALSE) } From b909273591b4f814214c00c17e3eb945286d356c Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 10:42:26 -0800 Subject: [PATCH 4/6] document s7 --- NAMESPACE | 1 + man/use_s7.Rd | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 man/use_s7.Rd diff --git a/NAMESPACE b/NAMESPACE index 91a15d28e..94751cf3b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -166,6 +166,7 @@ export(use_rmarkdown_template) export(use_roxygen_md) export(use_rstudio) export(use_rstudio_preferences) +export(use_s7) export(use_spell_check) export(use_standalone) export(use_template) diff --git a/man/use_s7.Rd b/man/use_s7.Rd new file mode 100644 index 000000000..2444abe24 --- /dev/null +++ b/man/use_s7.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/s7.R +\name{use_s7} +\alias{use_s7} +\title{Use S7} +\usage{ +use_s7(backwards_compat = TRUE) +} +\arguments{ +\item{backwards_compat}{If \code{TRUE} (the default), adds a \verb{@rawNamespace} +directive to the package-level documentation that conditionally imports +the \code{@} operator from S7 for R versions prior to 4.3.0.} +} +\description{ +Sets up a package to use \href{https://rconsortium.github.io/S7/}{S7} classes. +\itemize{ +\item Adds S7 to \code{Imports} in \code{DESCRIPTION} +\item Creates \code{R/zzz.R} with a call to \code{S7::methods_register()} in \code{.onLoad()} +\item Optionally adds a \verb{@rawNamespace} directive to enable the use of +\verb{@name} syntax in package code for R versions prior to 4.3.0 +(see \href{https://rconsortium.github.io/S7/articles/packages.html}{Using S7 in a Package}) +} +} +\examples{ +\dontrun{ +use_s7() +} +} From 5497cc2fbdc925b81c5abd2bf04b3b4a7b05bdd9 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 11:00:28 -0800 Subject: [PATCH 5/6] ensure tests in non-interactive settings --- R/s7.R | 1 - tests/testthat/_snaps/s7.md | 7 ------- tests/testthat/test-s7.R | 28 ++++++++-------------------- 3 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 tests/testthat/_snaps/s7.md diff --git a/R/s7.R b/R/s7.R index dd53c0309..ae3cfd684 100644 --- a/R/s7.R +++ b/R/s7.R @@ -19,7 +19,6 @@ use_s7 <- function(backwards_compat = TRUE) { check_is_package("use_s7()") check_uses_roxygen("use_s7()") - check_installed("S7") use_dependency("S7", "Imports") diff --git a/tests/testthat/_snaps/s7.md b/tests/testthat/_snaps/s7.md deleted file mode 100644 index e2584faf8..000000000 --- a/tests/testthat/_snaps/s7.md +++ /dev/null @@ -1,7 +0,0 @@ -# ensure_s7_methods_register() prompts if file differs from template - - Code - ensure_s7_methods_register() - Message - [ ] Ensure `S7::methods_register()` is called in `.onLoad()` in 'R/zzz.R'. - diff --git a/tests/testthat/test-s7.R b/tests/testthat/test-s7.R index 1ef11809b..b70120e89 100644 --- a/tests/testthat/test-s7.R +++ b/tests/testthat/test-s7.R @@ -3,15 +3,11 @@ test_that("use_s7() requires a package", { expect_usethis_error(use_s7(), "not an R package") }) -test_that("use_s7() creates zzz.R and edits DESCRIPTION", { +test_that("use_s7() edits zzz.R and DESCRIPTION", { create_local_package() use_roxygen_md() use_package_doc() - - local_interactive(TRUE) - local_mocked_bindings( - ui_yep = function(...) TRUE - ) + use_template("zzz.R", "R/zzz.R") use_s7() @@ -27,11 +23,7 @@ test_that("use_s7() adds rawNamespace directive when backwards_compat = TRUE", { create_local_package() use_roxygen_md() use_package_doc() - - local_interactive(TRUE) - local_mocked_bindings( - ui_yep = function(...) TRUE - ) + use_template("zzz.R", "R/zzz.R") use_s7(backwards_compat = TRUE) @@ -44,8 +36,9 @@ test_that("use_s7() skips rawNamespace when backwards_compat = FALSE", { create_local_package() use_roxygen_md() use_package_doc() + use_template("zzz.R", "R/zzz.R") - local_interactive(TRUE) + local_interactive(FALSE) local_mocked_bindings( ui_yep = function(...) TRUE ) @@ -60,8 +53,9 @@ test_that("use_s7() can be called twice without changing zzz.R", { create_local_package() use_roxygen_md() use_package_doc() + use_template("zzz.R", "R/zzz.R") - local_interactive(TRUE) + local_interactive(FALSE) local_mocked_bindings( ui_yep = function(...) TRUE ) @@ -100,11 +94,5 @@ test_that("ensure_s7_methods_register() prompts if file differs from template", "}" ) ) - - local_mocked_bindings( - edit_file = function(...) invisible() - ) - - withr::local_options(usethis.quiet = FALSE) - expect_snapshot(ensure_s7_methods_register()) + expect_error(use_s7()) }) From 820de6f4b370244b19ddc6210f967fed4dbe507a Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sat, 10 Jan 2026 11:28:03 -0800 Subject: [PATCH 6/6] add S7 to suggests for GHA --- DESCRIPTION | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION b/DESCRIPTION index 3e61801c3..423fe7497 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -53,6 +53,7 @@ Suggests: quarto (>= 1.5.1), rmarkdown, roxygen2 (>= 7.1.2), + S7, spelling (>= 1.2), testthat (>= 3.1.8) Config/Needs/website: r-lib/asciicast, tidyverse/tidytemplate, xml2