From 46953420779df639711bc0d741bb19546fc68ec7 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Wed, 24 Jun 2026 14:49:32 -0400 Subject: [PATCH 01/15] Keep S7 := ahead of competing search-path exports --- NEWS.md | 1 + R/compatibility.R | 65 ++++++++++++++++++++++++++++ R/zzz.R | 7 ++-- tests/testthat/test-bind.R | 86 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 8614a11ac..f2a825164 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). +* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index 52f80ef4e..58ef491b9 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -7,6 +7,71 @@ activate_backward_compatiblility <- function() { invisible() } +activate_attach_compatibility <- function(pkgname) { + if (getRversion() >= "4.3.0" && !search_has_bind_conflict(pkgname)) { + return(invisible()) + } + + env <- as.environment(paste0("package:", pkgname)) + env[[".conflicts.OK"]] <- TRUE + invisible() +} + +search_has_bind_conflict <- function(pkgname) { + pkg <- paste0("package:", pkgname) + env <- as.environment(pkg) + bind <- env[[":="]] + where <- setdiff(search(), c(pkg, "Autoloads", "CheckExEnv")) + + for (pos in where) { + other <- as.environment(pos) + if (!exists(":=", envir = other, inherits = FALSE)) { + next + } + + other_bind <- other[[":="]] + if (is.function(other_bind) && !identical(other_bind, bind)) { + return(TRUE) + } + } + + FALSE +} + +activate_bind_compatibility <- function() { + conflictRules <- get0("conflictRules", envir = baseenv(), inherits = FALSE) + if (is.null(conflictRules)) { + return(invisible()) + } + + for (package in packages_exporting_bind()) { + rule <- conflictRules(package) + conflictRules( + package, + mask.ok = rule$mask.ok, + exclude = union(rule$exclude, ":=") + ) + } + + invisible() +} + +packages_exporting_bind <- function(lib.loc = .libPaths()) { + paths <- unlist( + lapply(lib.loc, list.dirs, recursive = FALSE, full.names = TRUE), + use.names = FALSE + ) + paths <- paths[file.exists(file.path(paths, "Meta", "nsInfo.rds"))] + + exports_bind <- vapply( + file.path(paths, "Meta", "nsInfo.rds"), + function(path) ":=" %in% readRDS(path)$exports, + logical(1) + ) + + setdiff(unique(basename(paths)[exports_bind]), "S7") +} + #' @aliases @ #' @usage NULL #' @rawNamespace if (getRversion() < "4.3.0") export(`@`) diff --git a/R/zzz.R b/R/zzz.R index a97948093..e1b4c2e43 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -132,14 +132,13 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) # hooks ------------------------------------------------------------------- .onAttach <- function(libname, pkgname) { - env <- as.environment(paste0("package:", pkgname)) - if (getRversion() < "4.3.0") { - env[[".conflicts.OK"]] <- TRUE - } + activate_bind_compatibility() + activate_attach_compatibility(pkgname) } .onLoad <- function(...) { activate_backward_compatiblility() + activate_bind_compatibility() on_load_define_environment() on_load_define_S7_generic() diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index c9d1920d3..e44109d63 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -42,3 +42,89 @@ test_that(":= validates its inputs", { foo := no_name() }) }) + +test_that("S7 := wins search-path conflicts without attach warnings", { + skip_if(quick_test()) + + tmp_lib <- local_libpath() + install.packages( + pkgs = normalizePath(test_path("..", "..")), + lib = tmp_lib, + repos = NULL, + type = "source", + quiet = TRUE, + INSTALL_opts = c( + "--data-compress=none", + "--no-byte-compile", + "--no-data", + "--no-demo", + "--no-docs", + "--no-help", + "--no-html", + "--use-vanilla" + ) + ) + + alias_pkg <- tempfile("aliasbind") + dir.create(file.path(alias_pkg, "R"), recursive = TRUE) + writeLines( + c( + "Package: aliasbind", + "Version: 0.0.0", + "Title: Alias Bind", + "Description: Test package exporting a conflicting bind operator.", + "License: MIT", + "Encoding: UTF-8" + ), + file.path(alias_pkg, "DESCRIPTION") + ) + writeLines('export(":=")', file.path(alias_pkg, "NAMESPACE")) + writeLines( + c( + "`:=` <- function(lhs, rhs) {", + ' "aliasbind"', + "}" + ), + file.path(alias_pkg, "R", "bind.R") + ) + quick_install(alias_pkg, tmp_lib) + + check_order <- function(order) { + expect_no_error(callr::r( + function(order) { + messages <- character() + warnings <- character() + + withCallingHandlers( + { + if (identical(order, "S7-first")) { + library(S7) + library(aliasbind) + } else { + library(aliasbind) + library(S7) + } + }, + packageStartupMessage = function(cnd) { + messages <<- c(messages, conditionMessage(cnd)) + invokeRestart("muffleMessage") + }, + warning = function(cnd) { + warnings <<- c(warnings, conditionMessage(cnd)) + invokeRestart("muffleWarning") + } + ) + + stopifnot(exprs = { + identical(get(":=", mode = "function"), S7::`:=`) + !any(grepl(":=", messages, fixed = TRUE)) + !any(grepl(":=", warnings, fixed = TRUE)) + }) + }, + args = list(order = order) + )) + } + + check_order("S7-first") + check_order("alias-first") +}) From 54ebeebb83ad1fb17e58cb958e852e7cdc6cc935 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Wed, 24 Jun 2026 14:51:42 -0400 Subject: [PATCH 02/15] Use existing bind-exporting packages in conflict tests --- R/compatibility.R | 18 +-------------- tests/testthat/test-bind.R | 47 ++++++++++++++------------------------ 2 files changed, 18 insertions(+), 47 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index 58ef491b9..6fddfc5a9 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -44,7 +44,7 @@ activate_bind_compatibility <- function() { return(invisible()) } - for (package in packages_exporting_bind()) { + for (package in c("data.table", "rlang")) { rule <- conflictRules(package) conflictRules( package, @@ -56,22 +56,6 @@ activate_bind_compatibility <- function() { invisible() } -packages_exporting_bind <- function(lib.loc = .libPaths()) { - paths <- unlist( - lapply(lib.loc, list.dirs, recursive = FALSE, full.names = TRUE), - use.names = FALSE - ) - paths <- paths[file.exists(file.path(paths, "Meta", "nsInfo.rds"))] - - exports_bind <- vapply( - file.path(paths, "Meta", "nsInfo.rds"), - function(path) ":=" %in% readRDS(path)$exports, - logical(1) - ) - - setdiff(unique(basename(paths)[exports_bind]), "S7") -} - #' @aliases @ #' @usage NULL #' @rawNamespace if (getRversion() < "4.3.0") export(`@`) diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index e44109d63..06228f81e 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -65,33 +65,18 @@ test_that("S7 := wins search-path conflicts without attach warnings", { ) ) - alias_pkg <- tempfile("aliasbind") - dir.create(file.path(alias_pkg, "R"), recursive = TRUE) - writeLines( - c( - "Package: aliasbind", - "Version: 0.0.0", - "Title: Alias Bind", - "Description: Test package exporting a conflicting bind operator.", - "License: MIT", - "Encoding: UTF-8" - ), - file.path(alias_pkg, "DESCRIPTION") - ) - writeLines('export(":=")', file.path(alias_pkg, "NAMESPACE")) - writeLines( - c( - "`:=` <- function(lhs, rhs) {", - ' "aliasbind"', - "}" - ), - file.path(alias_pkg, "R", "bind.R") - ) - quick_install(alias_pkg, tmp_lib) + packages <- c("data.table", "rlang") + packages <- packages[vapply( + packages, + requireNamespace, + logical(1), + quietly = TRUE + )] + skip_if(length(packages) == 0, "rlang and data.table are not installed") - check_order <- function(order) { + check_order <- function(package, order) { expect_no_error(callr::r( - function(order) { + function(package, order) { messages <- character() warnings <- character() @@ -99,9 +84,9 @@ test_that("S7 := wins search-path conflicts without attach warnings", { { if (identical(order, "S7-first")) { library(S7) - library(aliasbind) + library(package, character.only = TRUE) } else { - library(aliasbind) + library(package, character.only = TRUE) library(S7) } }, @@ -121,10 +106,12 @@ test_that("S7 := wins search-path conflicts without attach warnings", { !any(grepl(":=", warnings, fixed = TRUE)) }) }, - args = list(order = order) + args = list(package = package, order = order) )) } - check_order("S7-first") - check_order("alias-first") + for (package in packages) { + check_order(package, "S7-first") + check_order(package, "alias-first") + } }) From 502e13216c56b13341b02b457af7c74a285e1c5d Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 10:38:38 -0400 Subject: [PATCH 03/15] Set .conflicts.OK directly in .onAttach() Inline the former activate_attach_compatibility() helper so the flag is only ever applied to S7's own attached environment, per review. --- R/compatibility.R | 10 ---------- R/zzz.R | 10 +++++++++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index 6fddfc5a9..d063f209e 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -7,16 +7,6 @@ activate_backward_compatiblility <- function() { invisible() } -activate_attach_compatibility <- function(pkgname) { - if (getRversion() >= "4.3.0" && !search_has_bind_conflict(pkgname)) { - return(invisible()) - } - - env <- as.environment(paste0("package:", pkgname)) - env[[".conflicts.OK"]] <- TRUE - invisible() -} - search_has_bind_conflict <- function(pkgname) { pkg <- paste0("package:", pkgname) env <- as.environment(pkg) diff --git a/R/zzz.R b/R/zzz.R index e1b4c2e43..bf83996ab 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -133,7 +133,15 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) .onAttach <- function(libname, pkgname) { activate_bind_compatibility() - activate_attach_compatibility(pkgname) + + # Setting `.conflicts.OK` makes library() skip masking messages for S7's + # exports: `@` masks base on R < 4.3.0, and `:=` masks rlang or data.table + # if they were attached first (conflictRules() only affects packages + # attached after S7). + if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { + env <- as.environment(paste0("package:", pkgname)) + env[[".conflicts.OK"]] <- TRUE + } } .onLoad <- function(...) { From 56993c317586de1d674389a22a55d3cb14b68271 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 10:41:10 -0400 Subject: [PATCH 04/15] Call conflictRules() unconditionally conflictRules() has been in base since R 3.6.0 and S7 now requires R >= 4.2.0, so the get0() availability check was dead code. --- R/compatibility.R | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index d063f209e..43aeffee2 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -28,12 +28,9 @@ search_has_bind_conflict <- function(pkgname) { FALSE } +# conflictRules() has been in base since R 3.6.0, so it is always available +# given our R >= 4.2.0 requirement. activate_bind_compatibility <- function() { - conflictRules <- get0("conflictRules", envir = baseenv(), inherits = FALSE) - if (is.null(conflictRules)) { - return(invisible()) - } - for (package in c("data.table", "rlang")) { rule <- conflictRules(package) conflictRules( From 47dc98bf27c8e421f9dff63e8fdba5b01a186260 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:04:18 -0400 Subject: [PATCH 05/15] Report unexpected conflicts when silencing := masking .conflicts.OK makes library() skip its conflict report wholesale, which also swallowed messages about genuine, unrelated conflicts. Re-emit the report ourselves, minus S7's deliberate masks (@ over base, := over rlang and data.table), reusing base's .maskedMsg formatter and message catalog so the output is identical to what library() would print. --- NEWS.md | 2 +- R/compatibility.R | 68 ++++++++++++++++++++++++++++++++++++ R/zzz.R | 4 ++- tests/testthat/helper.R | 35 +++++++++++++++++++ tests/testthat/test-bind.R | 70 +++++++++++++++++++++++++++----------- 5 files changed, 158 insertions(+), 21 deletions(-) diff --git a/NEWS.md b/NEWS.md index f2a825164..b084dad18 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). -* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. +* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other, unexpected conflicts are still emitted. * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index 43aeffee2..e4066ebeb 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -7,6 +7,74 @@ activate_backward_compatiblility <- function() { invisible() } +# The masking S7 performs deliberately: `@` over base (on R < 4.3.0, where +# S7 exports its own `@`), and `:=` over rlang and data.table. +s7_expected_masks <- list( + base = "@", + rlang = ":=", + data.table = ":=" +) + +# Re-emit the conflict report that library() would have produced (see +# checkConflicts() in base's library()), minus S7's expected masks. Used when +# `.conflicts.OK` makes library() skip its report, which is all-or-nothing. +report_unexpected_conflicts <- function(pkgname) { + # A user-configured conflicts.policy takes over conflict handling in + # library(); don't second-guess it. + if (!is.null(getOption("conflicts.policy"))) { + return(invisible()) + } + + sp <- search() + lib.pos <- match(paste0("package:", pkgname), sp) + ob <- names(as.environment(lib.pos)) + is_fun <- function(names, pos) { + vapply(names, exists, NA, where = pos, mode = "function", inherits = FALSE) + } + masked_msg <- get(".maskedMsg", envir = baseenv()) + + first <- TRUE + for (i in setdiff( + seq_along(sp), + c(lib.pos, match(c("Autoloads", "CheckExEnv"), sp, 0L)) + )) { + same <- intersect(names(as.environment(i)), ob) + same <- setdiff(same, s7_expected_masks[[sub("^package:", "", sp[i])]]) + same <- same[!startsWith(same, ".__")] + # Like library(), only report bindings of the same kind whose values + # actually differ. + same <- same[is_fun(same, i) == is_fun(same, lib.pos)] + same <- same[ + vapply( + same, + \(nm) !identical(get(nm, pos = i), get(nm, pos = lib.pos)), + NA + ) + ] + if (length(same) == 0L) { + next + } + + if (first) { + first <- FALSE + packageStartupMessage( + gettextf( + "\nAttaching package: %s\n", + sQuote(pkgname), + domain = "R-base" + ), + domain = NA + ) + } + packageStartupMessage( + masked_msg(sort(same), pkg = sQuote(sp[i]), by = i < lib.pos), + domain = NA + ) + } + + invisible() +} + search_has_bind_conflict <- function(pkgname) { pkg <- paste0("package:", pkgname) env <- as.environment(pkg) diff --git a/R/zzz.R b/R/zzz.R index bf83996ab..1075bca59 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -137,10 +137,12 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) # Setting `.conflicts.OK` makes library() skip masking messages for S7's # exports: `@` masks base on R < 4.3.0, and `:=` masks rlang or data.table # if they were attached first (conflictRules() only affects packages - # attached after S7). + # attached after S7). That suppression is all-or-nothing, so re-emit the + # report library() would have produced, minus S7's expected masks. if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { env <- as.environment(paste0("package:", pkgname)) env[[".conflicts.OK"]] <- TRUE + report_unexpected_conflicts(pkgname) } } diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index c8be317ed..64867cd85 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -126,6 +126,41 @@ local_libpath <- function(frame = parent.frame()) { lib } +# Install the development S7 into a session-scoped library (built once per +# test process) and prepend that library to .libPaths() until `frame` exits, +# so that callr subprocesses can `library(S7)`. +local_dev_S7_lib <- local({ + lib <- NULL + function(frame = parent.frame()) { + if (is.null(lib)) { + dir.create(new_lib <- tempfile("S7-dev-lib-")) + install.packages( + pkgs = normalizePath(test_path("..", "..")), + lib = new_lib, + repos = NULL, + type = "source", + quiet = TRUE, + INSTALL_opts = c( + "--data-compress=none", + "--no-byte-compile", + "--no-data", + "--no-demo", + "--no-docs", + "--no-help", + "--no-html", + "--use-vanilla" + ) + ) + lib <<- new_lib + } + + old <- .libPaths() + .libPaths(c(lib, old)) + defer(.libPaths(old), frame = frame) + lib + } +}) + # Install the package at `path` into `lib`, attach it, and detach (and unload) # it when `frame` exits. The package name is taken from `basename(path)`. local_install_and_attach <- function(path, lib, frame = parent.frame()) { diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 06228f81e..bd7490f42 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -46,25 +46,6 @@ test_that(":= validates its inputs", { test_that("S7 := wins search-path conflicts without attach warnings", { skip_if(quick_test()) - tmp_lib <- local_libpath() - install.packages( - pkgs = normalizePath(test_path("..", "..")), - lib = tmp_lib, - repos = NULL, - type = "source", - quiet = TRUE, - INSTALL_opts = c( - "--data-compress=none", - "--no-byte-compile", - "--no-data", - "--no-demo", - "--no-docs", - "--no-help", - "--no-html", - "--use-vanilla" - ) - ) - packages <- c("data.table", "rlang") packages <- packages[vapply( packages, @@ -74,6 +55,8 @@ test_that("S7 := wins search-path conflicts without attach warnings", { )] skip_if(length(packages) == 0, "rlang and data.table are not installed") + local_dev_S7_lib() + check_order <- function(package, order) { expect_no_error(callr::r( function(package, order) { @@ -115,3 +98,52 @@ test_that("S7 := wins search-path conflicts without attach warnings", { check_order(package, "alias-first") } }) + +test_that("unexpected conflicts are still reported when := masking is silenced", { + skip_if(quick_test()) + + packages <- c("data.table", "rlang") + packages <- packages[vapply( + packages, + requireNamespace, + logical(1), + quietly = TRUE + )] + skip_if(length(packages) == 0, "rlang and data.table are not installed") + + local_dev_S7_lib() + + # Attach an environment that shadows an unrelated S7 export, then attach S7 + # and collect the startup messages library() emits. + s7_attach_messages <- function(bind_package = NULL) { + callr::r( + function(bind_package) { + if (!is.null(bind_package)) { + library(bind_package, character.only = TRUE) + } + attach(list(props = function(...) NULL), name = "shadow") + + messages <- character() + withCallingHandlers( + library(S7), + packageStartupMessage = function(cnd) { + messages <<- c(messages, conditionMessage(cnd)) + invokeRestart("muffleMessage") + } + ) + messages + }, + args = list(bind_package = bind_package) + ) + } + + base_messages <- s7_attach_messages() + expect_match(base_messages, "masked from .shadow.", all = FALSE) + expect_match(base_messages, "props", all = FALSE) + + # With a `:=` conflict present, `.conflicts.OK` silences library()'s own + # report; S7 must re-emit it minus the `:=` masking, matching it exactly. + for (package in packages) { + expect_identical(s7_attach_messages(bind_package = package), base_messages) + } +}) From 91348471dea246541116260027ef5378d969cfde Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:08:30 -0400 Subject: [PATCH 06/15] Declare S7's expected masks via conflictRules() A strict conflicts.policy errors on undeclared conflicts and ignores .conflicts.OK, so register mask.ok rules for S7 (@ over base, := over rlang and data.table). library() reads conflictRules() before loading the namespace, so this takes effect once S7's namespace is already loaded, e.g. imported by another package; a cold library(S7) under a strict policy still requires user-declared rules, as that policy intends. --- NEWS.md | 2 +- R/compatibility.R | 12 ++++++++++++ tests/testthat/test-bind.R | 31 +++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index b084dad18..92e144cea 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). -* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other, unexpected conflicts are still emitted. +* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other, unexpected conflicts are still emitted. S7 also declares its expected masks via `conflictRules()`, so once its namespace is loaded it can attach under `options(conflicts.policy = "strict")`. * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index e4066ebeb..9f28ef478 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -108,6 +108,18 @@ activate_bind_compatibility <- function() { ) } + # Declare S7's expected masks so that a strict conflicts.policy (which + # errors on undeclared conflicts and ignores `.conflicts.OK`) still lets + # S7 attach. library() reads conflictRules() before loading the namespace, + # so this only takes effect once S7's namespace is already loaded (e.g. + # imported by another package); attaching S7 cold under a strict policy + # requires the user to declare the rules, as that policy intends. Don't + # override rules the user has already declared. + rule <- conflictRules("S7") + if (is.null(rule$mask.ok)) { + conflictRules("S7", mask.ok = s7_expected_masks, exclude = rule$exclude) + } + invisible() } diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index bd7490f42..3ae92d0d4 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -147,3 +147,34 @@ test_that("unexpected conflicts are still reported when := masking is silenced", expect_identical(s7_attach_messages(bind_package = package), base_messages) } }) + +test_that("S7 attaches over a := conflict under a strict conflicts.policy", { + skip_if(quick_test()) + + packages <- c("data.table", "rlang") + packages <- packages[vapply( + packages, + requireNamespace, + logical(1), + quietly = TRUE + )] + skip_if(length(packages) == 0, "rlang and data.table are not installed") + + local_dev_S7_lib() + + for (package in packages) { + expect_no_error(callr::r( + function(package) { + library(package, character.only = TRUE) + # library() reads conflictRules() before loading the namespace, so + # S7's rules only help once its namespace is already loaded, e.g. + # via another package that imports S7. + loadNamespace("S7") + options(conflicts.policy = "strict") + library(S7) + stopifnot(identical(get(":=", mode = "function"), S7::`:=`)) + }, + args = list(package = package) + )) + } +}) From bf18ce3d893b6992305f6391c579c0f5ef5675f2 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:12:02 -0400 Subject: [PATCH 07/15] Rename report_unexpected_conflicts() to report_other_conflicts() S7 has no expectations about these conflicts; they are simply conflicts other than the masks S7 itself creates. --- NEWS.md | 2 +- R/compatibility.R | 2 +- R/zzz.R | 2 +- tests/testthat/test-bind.R | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 92e144cea..cb23ab981 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). -* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other, unexpected conflicts are still emitted. S7 also declares its expected masks via `conflictRules()`, so once its namespace is loaded it can attach under `options(conflicts.policy = "strict")`. +* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other conflicts are still emitted. S7 also declares its expected masks via `conflictRules()`, so once its namespace is loaded it can attach under `options(conflicts.policy = "strict")`. * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index 9f28ef478..719fcce49 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -18,7 +18,7 @@ s7_expected_masks <- list( # Re-emit the conflict report that library() would have produced (see # checkConflicts() in base's library()), minus S7's expected masks. Used when # `.conflicts.OK` makes library() skip its report, which is all-or-nothing. -report_unexpected_conflicts <- function(pkgname) { +report_other_conflicts <- function(pkgname) { # A user-configured conflicts.policy takes over conflict handling in # library(); don't second-guess it. if (!is.null(getOption("conflicts.policy"))) { diff --git a/R/zzz.R b/R/zzz.R index 1075bca59..7536fe605 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -142,7 +142,7 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { env <- as.environment(paste0("package:", pkgname)) env[[".conflicts.OK"]] <- TRUE - report_unexpected_conflicts(pkgname) + report_other_conflicts(pkgname) } } diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 3ae92d0d4..94ee4bbaa 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -99,7 +99,7 @@ test_that("S7 := wins search-path conflicts without attach warnings", { } }) -test_that("unexpected conflicts are still reported when := masking is silenced", { +test_that("other conflicts are still reported when := masking is silenced", { skip_if(quick_test()) packages <- c("data.table", "rlang") From cec94696e8860f06f77ce6a3cc65a1024c323fb9 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:15:51 -0400 Subject: [PATCH 08/15] Rename report_other_conflicts() to report_conflicts() The mask filtering is an implementation detail already documented in the function comment; the qualifier only invited ambiguity. --- R/compatibility.R | 2 +- R/zzz.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index 719fcce49..bb58b75f0 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -18,7 +18,7 @@ s7_expected_masks <- list( # Re-emit the conflict report that library() would have produced (see # checkConflicts() in base's library()), minus S7's expected masks. Used when # `.conflicts.OK` makes library() skip its report, which is all-or-nothing. -report_other_conflicts <- function(pkgname) { +report_conflicts <- function(pkgname) { # A user-configured conflicts.policy takes over conflict handling in # library(); don't second-guess it. if (!is.null(getOption("conflicts.policy"))) { diff --git a/R/zzz.R b/R/zzz.R index 7536fe605..831352387 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -142,7 +142,7 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { env <- as.environment(paste0("package:", pkgname)) env[[".conflicts.OK"]] <- TRUE - report_other_conflicts(pkgname) + report_conflicts(pkgname) } } From 9fa535d3a3d3cbe6f97192cf8e3495fdaeefb75c Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:31:23 -0400 Subject: [PATCH 09/15] Trim comments to essential facts --- R/compatibility.R | 20 +++++--------------- R/zzz.R | 8 +++----- tests/testthat/helper.R | 3 +-- tests/testthat/test-bind.R | 8 +------- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index bb58b75f0..16e7998e8 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -7,8 +7,6 @@ activate_backward_compatiblility <- function() { invisible() } -# The masking S7 performs deliberately: `@` over base (on R < 4.3.0, where -# S7 exports its own `@`), and `:=` over rlang and data.table. s7_expected_masks <- list( base = "@", rlang = ":=", @@ -19,8 +17,7 @@ s7_expected_masks <- list( # checkConflicts() in base's library()), minus S7's expected masks. Used when # `.conflicts.OK` makes library() skip its report, which is all-or-nothing. report_conflicts <- function(pkgname) { - # A user-configured conflicts.policy takes over conflict handling in - # library(); don't second-guess it. + # A user-configured conflicts.policy takes over conflict handling. if (!is.null(getOption("conflicts.policy"))) { return(invisible()) } @@ -41,8 +38,6 @@ report_conflicts <- function(pkgname) { same <- intersect(names(as.environment(i)), ob) same <- setdiff(same, s7_expected_masks[[sub("^package:", "", sp[i])]]) same <- same[!startsWith(same, ".__")] - # Like library(), only report bindings of the same kind whose values - # actually differ. same <- same[is_fun(same, i) == is_fun(same, lib.pos)] same <- same[ vapply( @@ -96,8 +91,7 @@ search_has_bind_conflict <- function(pkgname) { FALSE } -# conflictRules() has been in base since R 3.6.0, so it is always available -# given our R >= 4.2.0 requirement. +# conflictRules() has been in base since R 3.6.0. activate_bind_compatibility <- function() { for (package in c("data.table", "rlang")) { rule <- conflictRules(package) @@ -108,13 +102,9 @@ activate_bind_compatibility <- function() { ) } - # Declare S7's expected masks so that a strict conflicts.policy (which - # errors on undeclared conflicts and ignores `.conflicts.OK`) still lets - # S7 attach. library() reads conflictRules() before loading the namespace, - # so this only takes effect once S7's namespace is already loaded (e.g. - # imported by another package); attaching S7 cold under a strict policy - # requires the user to declare the rules, as that policy intends. Don't - # override rules the user has already declared. + # Declare S7's masks for a strict conflicts.policy, which ignores + # `.conflicts.OK`. library() reads conflictRules() before loading the + # namespace, so this only helps once S7's namespace is already loaded. rule <- conflictRules("S7") if (is.null(rule$mask.ok)) { conflictRules("S7", mask.ok = s7_expected_masks, exclude = rule$exclude) diff --git a/R/zzz.R b/R/zzz.R index 831352387..35b3323c7 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -134,11 +134,9 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) .onAttach <- function(libname, pkgname) { activate_bind_compatibility() - # Setting `.conflicts.OK` makes library() skip masking messages for S7's - # exports: `@` masks base on R < 4.3.0, and `:=` masks rlang or data.table - # if they were attached first (conflictRules() only affects packages - # attached after S7). That suppression is all-or-nothing, so re-emit the - # report library() would have produced, minus S7's expected masks. + # `.conflicts.OK` silences library()'s masking messages for `@` (base, + # R < 4.3.0) and `:=` (rlang/data.table attached before S7), but it is + # all-or-nothing, so re-emit the report minus S7's expected masks. if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { env <- as.environment(paste0("package:", pkgname)) env[[".conflicts.OK"]] <- TRUE diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index 64867cd85..24512ae85 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -127,8 +127,7 @@ local_libpath <- function(frame = parent.frame()) { } # Install the development S7 into a session-scoped library (built once per -# test process) and prepend that library to .libPaths() until `frame` exits, -# so that callr subprocesses can `library(S7)`. +# test process) and prepend that library to .libPaths() until `frame` exits. local_dev_S7_lib <- local({ lib <- NULL function(frame = parent.frame()) { diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 94ee4bbaa..460745cd0 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -113,8 +113,6 @@ test_that("other conflicts are still reported when := masking is silenced", { local_dev_S7_lib() - # Attach an environment that shadows an unrelated S7 export, then attach S7 - # and collect the startup messages library() emits. s7_attach_messages <- function(bind_package = NULL) { callr::r( function(bind_package) { @@ -141,8 +139,6 @@ test_that("other conflicts are still reported when := masking is silenced", { expect_match(base_messages, "masked from .shadow.", all = FALSE) expect_match(base_messages, "props", all = FALSE) - # With a `:=` conflict present, `.conflicts.OK` silences library()'s own - # report; S7 must re-emit it minus the `:=` masking, matching it exactly. for (package in packages) { expect_identical(s7_attach_messages(bind_package = package), base_messages) } @@ -166,9 +162,7 @@ test_that("S7 attaches over a := conflict under a strict conflicts.policy", { expect_no_error(callr::r( function(package) { library(package, character.only = TRUE) - # library() reads conflictRules() before loading the namespace, so - # S7's rules only help once its namespace is already loaded, e.g. - # via another package that imports S7. + # S7's conflictRules() only take effect once its namespace is loaded. loadNamespace("S7") options(conflicts.policy = "strict") library(S7) From efabd8a8df6498c5ebc0e3cca83e3827bfb98ff5 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:34:14 -0400 Subject: [PATCH 10/15] Cut two more comments --- R/compatibility.R | 4 ---- 1 file changed, 4 deletions(-) diff --git a/R/compatibility.R b/R/compatibility.R index 16e7998e8..9629e0f36 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -91,7 +91,6 @@ search_has_bind_conflict <- function(pkgname) { FALSE } -# conflictRules() has been in base since R 3.6.0. activate_bind_compatibility <- function() { for (package in c("data.table", "rlang")) { rule <- conflictRules(package) @@ -102,9 +101,6 @@ activate_bind_compatibility <- function() { ) } - # Declare S7's masks for a strict conflicts.policy, which ignores - # `.conflicts.OK`. library() reads conflictRules() before loading the - # namespace, so this only helps once S7's namespace is already loaded. rule <- conflictRules("S7") if (is.null(rule$mask.ok)) { conflictRules("S7", mask.ok = s7_expected_masks, exclude = rule$exclude) From d93c4729614d9fa1a98a8efe31730d8e74b32c06 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 17 Aug 2026 11:34:41 -0400 Subject: [PATCH 11/15] Reword conflicts.policy comment --- R/compatibility.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/compatibility.R b/R/compatibility.R index 9629e0f36..d0efa9536 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -17,7 +17,7 @@ s7_expected_masks <- list( # checkConflicts() in base's library()), minus S7's expected masks. Used when # `.conflicts.OK` makes library() skip its report, which is all-or-nothing. report_conflicts <- function(pkgname) { - # A user-configured conflicts.policy takes over conflict handling. + # A user-configured conflicts.policy takes precedence. if (!is.null(getOption("conflicts.policy"))) { return(invisible()) } From a337d8db3eed0f1dc6e1d57216fc6cf5914de393 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sun, 23 Aug 2026 15:18:53 -0400 Subject: [PATCH 12/15] Handle := conflicts when S7 attaches --- NEWS.md | 2 +- R/compatibility.R | 91 +------------------------------------- R/zzz.R | 13 +++--- tests/testthat/test-bind.R | 61 ++++--------------------- vignettes/packages.Rmd | 10 +++++ 5 files changed, 29 insertions(+), 148 deletions(-) diff --git a/NEWS.md b/NEWS.md index cb23ab981..94c172837 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). -* The `:=` operator now stays ahead of other attached packages that export `:=`, such as rlang or data.table, without emitting attach-time masking messages. Masking messages for other conflicts are still emitted. S7 also declares its expected masks via `conflictRules()`, so once its namespace is loaded it can attach under `options(conflicts.policy = "strict")`. +* The `:=` operator now stays ahead of rlang and data.table regardless of attachment order, without emitting attach-time masking messages (#697). * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index d0efa9536..a554cfe50 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -7,92 +7,10 @@ activate_backward_compatiblility <- function() { invisible() } -s7_expected_masks <- list( - base = "@", - rlang = ":=", - data.table = ":=" -) - -# Re-emit the conflict report that library() would have produced (see -# checkConflicts() in base's library()), minus S7's expected masks. Used when -# `.conflicts.OK` makes library() skip its report, which is all-or-nothing. -report_conflicts <- function(pkgname) { - # A user-configured conflicts.policy takes precedence. - if (!is.null(getOption("conflicts.policy"))) { - return(invisible()) - } - - sp <- search() - lib.pos <- match(paste0("package:", pkgname), sp) - ob <- names(as.environment(lib.pos)) - is_fun <- function(names, pos) { - vapply(names, exists, NA, where = pos, mode = "function", inherits = FALSE) - } - masked_msg <- get(".maskedMsg", envir = baseenv()) - - first <- TRUE - for (i in setdiff( - seq_along(sp), - c(lib.pos, match(c("Autoloads", "CheckExEnv"), sp, 0L)) - )) { - same <- intersect(names(as.environment(i)), ob) - same <- setdiff(same, s7_expected_masks[[sub("^package:", "", sp[i])]]) - same <- same[!startsWith(same, ".__")] - same <- same[is_fun(same, i) == is_fun(same, lib.pos)] - same <- same[ - vapply( - same, - \(nm) !identical(get(nm, pos = i), get(nm, pos = lib.pos)), - NA - ) - ] - if (length(same) == 0L) { - next - } - - if (first) { - first <- FALSE - packageStartupMessage( - gettextf( - "\nAttaching package: %s\n", - sQuote(pkgname), - domain = "R-base" - ), - domain = NA - ) - } - packageStartupMessage( - masked_msg(sort(same), pkg = sQuote(sp[i]), by = i < lib.pos), - domain = NA - ) - } - - invisible() -} - -search_has_bind_conflict <- function(pkgname) { - pkg <- paste0("package:", pkgname) - env <- as.environment(pkg) - bind <- env[[":="]] - where <- setdiff(search(), c(pkg, "Autoloads", "CheckExEnv")) - - for (pos in where) { - other <- as.environment(pos) - if (!exists(":=", envir = other, inherits = FALSE)) { - next - } - - other_bind <- other[[":="]] - if (is.function(other_bind) && !identical(other_bind, bind)) { - return(TRUE) - } - } - - FALSE -} +bind_conflict_packages <- c("data.table", "rlang") activate_bind_compatibility <- function() { - for (package in c("data.table", "rlang")) { + for (package in bind_conflict_packages) { rule <- conflictRules(package) conflictRules( package, @@ -101,11 +19,6 @@ activate_bind_compatibility <- function() { ) } - rule <- conflictRules("S7") - if (is.null(rule$mask.ok)) { - conflictRules("S7", mask.ok = s7_expected_masks, exclude = rule$exclude) - } - invisible() } diff --git a/R/zzz.R b/R/zzz.R index 35b3323c7..c055933dc 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -134,19 +134,20 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) .onAttach <- function(libname, pkgname) { activate_bind_compatibility() - # `.conflicts.OK` silences library()'s masking messages for `@` (base, - # R < 4.3.0) and `:=` (rlang/data.table attached before S7), but it is - # all-or-nothing, so re-emit the report minus S7's expected masks. - if (getRversion() < "4.3.0" || search_has_bind_conflict(pkgname)) { + bind_conflict <- any( + paste0("package:", bind_conflict_packages) %in% search() + ) + + # `.conflicts.OK` is all-or-nothing, so this also silences other conflicts + # reported while S7 attaches. There is no selective equivalent. + if (getRversion() < "4.3.0" || bind_conflict) { env <- as.environment(paste0("package:", pkgname)) env[[".conflicts.OK"]] <- TRUE - report_conflicts(pkgname) } } .onLoad <- function(...) { activate_backward_compatiblility() - activate_bind_compatibility() on_load_define_environment() on_load_define_S7_generic() diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 460745cd0..66e03bb21 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -95,56 +95,11 @@ test_that("S7 := wins search-path conflicts without attach warnings", { for (package in packages) { check_order(package, "S7-first") - check_order(package, "alias-first") + check_order(package, "package-first") } }) -test_that("other conflicts are still reported when := masking is silenced", { - skip_if(quick_test()) - - packages <- c("data.table", "rlang") - packages <- packages[vapply( - packages, - requireNamespace, - logical(1), - quietly = TRUE - )] - skip_if(length(packages) == 0, "rlang and data.table are not installed") - - local_dev_S7_lib() - - s7_attach_messages <- function(bind_package = NULL) { - callr::r( - function(bind_package) { - if (!is.null(bind_package)) { - library(bind_package, character.only = TRUE) - } - attach(list(props = function(...) NULL), name = "shadow") - - messages <- character() - withCallingHandlers( - library(S7), - packageStartupMessage = function(cnd) { - messages <<- c(messages, conditionMessage(cnd)) - invokeRestart("muffleMessage") - } - ) - messages - }, - args = list(bind_package = bind_package) - ) - } - - base_messages <- s7_attach_messages() - expect_match(base_messages, "masked from .shadow.", all = FALSE) - expect_match(base_messages, "props", all = FALSE) - - for (package in packages) { - expect_identical(s7_attach_messages(bind_package = package), base_messages) - } -}) - -test_that("S7 attaches over a := conflict under a strict conflicts.policy", { +test_that("loading S7 does not affect := when S7 is not attached", { skip_if(quick_test()) packages <- c("data.table", "rlang") @@ -161,12 +116,14 @@ test_that("S7 attaches over a := conflict under a strict conflicts.policy", { for (package in packages) { expect_no_error(callr::r( function(package) { - library(package, character.only = TRUE) - # S7's conflictRules() only take effect once its namespace is loaded. loadNamespace("S7") - options(conflicts.policy = "strict") - library(S7) - stopifnot(identical(get(":=", mode = "function"), S7::`:=`)) + library(package, character.only = TRUE) + stopifnot( + identical( + get(":=", mode = "function"), + getExportedValue(package, ":=") + ) + ) }, args = list(package = package) )) diff --git a/vignettes/packages.Rmd b/vignettes/packages.Rmd index 64361f4f2..4a715d27a 100644 --- a/vignettes/packages.Rmd +++ b/vignettes/packages.Rmd @@ -26,6 +26,16 @@ library(S7) First, add `S7` to the `Imports` field of your `DESCRIPTION`. We then recommend importing all S7 functions into your package `NAMESPACE` with `import(S7)`, or, if you're using roxygen2, `@import S7`. +If your package also imports rlang or data.table, import `:=` from only S7. Otherwise, R warns that one import replaces the other when it loads your package. For example, to import all of rlang except `:=` with roxygen2: + +``` r +#' @import S7 +#' @rawNamespace import(rlang, except = ":=") +NULL +``` + +Use the same `except` argument when importing data.table. + Next, create a `zzz.R` with a `.onLoad()` that calls `S7_on_load()`, an `.onUnload()` that calls `S7_on_unload()`, and a top-level call to `S7_on_build()`: ```{r} From d620cc9291b099a116bb28a4f1f6e0ed262917d3 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 24 Aug 2026 10:44:38 -0400 Subject: [PATCH 13/15] Preserve unrelated attach conflict messages --- NEWS.md | 2 +- R/compatibility.R | 39 +++++++++++++++++++++++++++++++++++ R/zzz.R | 15 +++++++------- tests/testthat/test-bind.R | 42 ++++++++++++++++++++++++++++++-------- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/NEWS.md b/NEWS.md index 94c172837..4377a4766 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,7 @@ # S7 (development version) * New `:=` operator creates and names an object in one step, so `Foo := new_class()` is equivalent to `Foo <- new_class(name = "Foo")` (#658). -* The `:=` operator now stays ahead of rlang and data.table regardless of attachment order, without emitting attach-time masking messages (#697). +* The `:=` operator now stays ahead of rlang and data.table regardless of attachment order, without emitting `:=` masking messages (#697). * The class object that S7 stores on each instance now lives in the `_S7_class` attribute (previously `S7_class`), moving it into the `_`-prefixed namespace reserved for S7 internals so it can't collide with a user-defined property. Objects created by an older version of S7 (e.g. serialised to disk or baked into another package's lazy-load database) continue to work, as S7 falls back to the old attribute name when reading them (#677). * S7 and S4 now interoperate through inheritance. `new_class()` can use an S4 class as a parent, mapping S4 slots to S7 properties and registering the class with S4 automatically. Conversely, `S4_register()` registers an S7 class with S4, and `S4_contains()` returns an S4 class name suitable for `methods::setClass(contains = )`, exposing stored S7 properties as S4 slots for S4 subclasses. This support includes S4 initialization and validity integration, and S4/internal generic registration where needed; see `vignette("compatibility")` for caveats (#456). * Errors thrown by S7 now report the function where they occurred, making it easier to track down the source of a problem (#646). diff --git a/R/compatibility.R b/R/compatibility.R index a554cfe50..923d53b5f 100644 --- a/R/compatibility.R +++ b/R/compatibility.R @@ -9,14 +9,53 @@ activate_backward_compatiblility <- function() { bind_conflict_packages <- c("data.table", "rlang") +set_attached_bind <- function(env, value) { + get("unlockBinding", baseenv())(":=", env) + defer(lockBinding(":=", env)) + env[[":="]] <- value +} + activate_bind_compatibility <- function() { for (package in bind_conflict_packages) { + # These rules are session-wide. If S7 is detached, a package attached + # later may remain without its error-only := export on the search path. rule <- conflictRules(package) conflictRules( package, mask.ok = rule$mask.ok, exclude = union(rule$exclude, ":=") ) + + attached <- paste0("package:", package) + if (!attached %in% search()) { + next + } + + env <- as.environment(attached) + if ( + exists(":=", envir = env, inherits = FALSE) && + identical(env[[":="]], getExportedValue(package, ":=")) + ) { + # These exports are erroring sentinels for package-specific NSE syntax. + # An identical binding is not reported as a conflict by library(). + set_attached_bind(env, `:=`) + } + } + + invisible() +} + +restore_attached_bindings <- function() { + for (package in bind_conflict_packages) { + attached <- paste0("package:", package) + if (!attached %in% search()) { + next + } + + env <- as.environment(attached) + if (identical(env[[":="]], `:=`)) { + set_attached_bind(env, getExportedValue(package, ":=")) + } } invisible() diff --git a/R/zzz.R b/R/zzz.R index c055933dc..6dab90682 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -134,18 +134,17 @@ methods::setOldClass(c("S7_method", "function", "S7_object")) .onAttach <- function(libname, pkgname) { activate_bind_compatibility() - bind_conflict <- any( - paste0("package:", bind_conflict_packages) %in% search() - ) - - # `.conflicts.OK` is all-or-nothing, so this also silences other conflicts - # reported while S7 attaches. There is no selective equivalent. - if (getRversion() < "4.3.0" || bind_conflict) { + if (getRversion() < "4.3.0") { + # S7_at already supplies @ without conflicting with base::@. env <- as.environment(paste0("package:", pkgname)) - env[[".conflicts.OK"]] <- TRUE + rm(list = "@", envir = env) } } +.onDetach <- function(...) { + restore_attached_bindings() +} + .onLoad <- function(...) { activate_backward_compatiblility() diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 66e03bb21..7abc4605d 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -43,7 +43,7 @@ test_that(":= validates its inputs", { }) }) -test_that("S7 := wins search-path conflicts without attach warnings", { +test_that("S7 := wins without hiding unrelated attach conflicts", { skip_if(quick_test()) packages <- c("data.table", "rlang") @@ -58,8 +58,20 @@ test_that("S7 := wins search-path conflicts without attach warnings", { local_dev_S7_lib() check_order <- function(package, order) { - expect_no_error(callr::r( + output <- callr::r( function(package, order) { + if (identical(order, "package-first")) { + package_bind <- getExportedValue(package, ":=") + attach( + list(as_class = function(...) NULL), + name = "shadow", + warn.conflicts = FALSE + ) + suppressPackageStartupMessages( + library(package, character.only = TRUE) + ) + } + messages <- character() warnings <- character() @@ -69,7 +81,6 @@ test_that("S7 := wins search-path conflicts without attach warnings", { library(S7) library(package, character.only = TRUE) } else { - library(package, character.only = TRUE) library(S7) } }, @@ -83,14 +94,27 @@ test_that("S7 := wins search-path conflicts without attach warnings", { } ) - stopifnot(exprs = { - identical(get(":=", mode = "function"), S7::`:=`) - !any(grepl(":=", messages, fixed = TRUE)) - !any(grepl(":=", warnings, fixed = TRUE)) - }) + stopifnot(identical(get(":=", mode = "function"), S7::`:=`)) + + if (identical(order, "package-first")) { + stopifnot(identical(getExportedValue(package, ":="), package_bind)) + detach("package:S7") + stopifnot(identical( + get(":=", mode = "function"), + package_bind + )) + } + + list(messages = messages, warnings = warnings) }, args = list(package = package, order = order) - )) + ) + + expect_no_match(output$messages, ":=", fixed = TRUE) + expect_no_match(output$warnings, ":=", fixed = TRUE) + if (identical(order, "package-first")) { + expect_match(output$messages, "as_class", all = FALSE) + } } for (package in packages) { From c35f6cef83a2c42f81ac2cf73a78d4d5b064200a Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 24 Aug 2026 11:19:22 -0400 Subject: [PATCH 14/15] Remove redundant message suppression --- tests/testthat/test-bind.R | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/testthat/test-bind.R b/tests/testthat/test-bind.R index 7abc4605d..8ac33142a 100644 --- a/tests/testthat/test-bind.R +++ b/tests/testthat/test-bind.R @@ -67,9 +67,7 @@ test_that("S7 := wins without hiding unrelated attach conflicts", { name = "shadow", warn.conflicts = FALSE ) - suppressPackageStartupMessages( - library(package, character.only = TRUE) - ) + library(package, character.only = TRUE) } messages <- character() From c30196caef23f118017e4bdb6769949bf28090c9 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Thu, 27 Aug 2026 15:18:07 -0400 Subject: [PATCH 15/15] Apply suggestion from @hadley Co-authored-by: Hadley Wickham --- vignettes/packages.Rmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vignettes/packages.Rmd b/vignettes/packages.Rmd index 4a715d27a..e8a779d5f 100644 --- a/vignettes/packages.Rmd +++ b/vignettes/packages.Rmd @@ -30,7 +30,7 @@ If your package also imports rlang or data.table, import `:=` from only S7. Othe ``` r #' @import S7 -#' @rawNamespace import(rlang, except = ":=") +#' @import rlang, except = ":=" NULL ```