From e685fab7969ba064691f2a8e5a7efc7b561baec4 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 28 Jul 2026 16:18:48 -0500 Subject: [PATCH] Add deprecated_generic(), deprecated_class(), and deprecated_property() Provides a standard way to deprecate parts of an S7 API while keeping old code working: * deprecated_generic() wraps a generic: calls warn then delegate, and method registrations are silently redirected to the replacement. * deprecated_class() aliases a class: the constructor warns, and every other context (signatures, parent, property classes, external class references) silently resolves to the replacement. * deprecated_property() warns on read/write, delegating storage to the replacement property. All three require a `when` version, support deprecation without a replacement (via `old` for generics/classes), and signal with a .Deprecated()-style warning by default; method = "lifecycle(warn)" or "lifecycle(stop)" uses the lifecycle package instead. Also relaxes resolve_external_class_req() so new_external_class() references resolve through exported aliases (plain or deprecated), allowing classes to be renamed without breaking downstream packages. Fixes #727. Fixes #730. --- DESCRIPTION | 1 + NAMESPACE | 5 + NEWS.md | 2 + R/class-spec.R | 4 + R/deprecated.R | 503 ++++++++++++++++++++++++ R/external-class.R | 13 +- R/external-generic.R | 4 + R/generic-spec.R | 4 + R/introspect.R | 3 + R/method-introspect.R | 6 + _pkgdown.yml | 9 + man/deprecated_class.Rd | 80 ++++ man/deprecated_generic.Rd | 78 ++++ man/deprecated_property.Rd | 72 ++++ tests/testthat/_snaps/deprecated.md | 241 ++++++++++++ tests/testthat/_snaps/external-class.md | 3 +- tests/testthat/test-deprecated.R | 243 ++++++++++++ tests/testthat/test-external-class.R | 20 + vignettes/classes-objects.Rmd | 23 +- 19 files changed, 1287 insertions(+), 27 deletions(-) create mode 100644 R/deprecated.R create mode 100644 man/deprecated_class.Rd create mode 100644 man/deprecated_generic.Rd create mode 100644 man/deprecated_property.Rd create mode 100644 tests/testthat/_snaps/deprecated.md create mode 100644 tests/testthat/test-deprecated.R diff --git a/DESCRIPTION b/DESCRIPTION index 1bfbc4730..1cc03df38 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -34,6 +34,7 @@ Suggests: callr, covr, knitr, + lifecycle, methods, rmarkdown, testthat (>= 3.2.0), diff --git a/NAMESPACE b/NAMESPACE index f533d19d5..298a6ce01 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -18,6 +18,8 @@ S3method(print,S7_any) S3method(print,S7_base_class) S3method(print,S7_class) S3method(print,S7_constructor) +S3method(print,S7_deprecated_class) +S3method(print,S7_deprecated_generic) S3method(print,S7_external_class) S3method(print,S7_external_generic) S3method(print,S7_generic) @@ -87,6 +89,9 @@ export(class_raw) export(class_vector) export(convert) export(convert_lazy) +export(deprecated_class) +export(deprecated_generic) +export(deprecated_property) export(method) export(method_explain) export(methods_register) diff --git a/NEWS.md b/NEWS.md index 85646ac95..9d0054dfb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,6 +13,7 @@ * `convert()` accepts a single unnamed list of property overrides when downcasting, as a shortcut for individual name-value pairs (#497). * `convert()` no longer errors when `from` is a base or S3 object and `to` is an S7 class that inherits from `from`'s class. The base/S3 value is now passed as `.data` to the `to` constructor (#537). * New `convert_lazy()` is a non-strict variant of `convert()` that returns `from` unchanged if it already inherits from `to`, preserving any extra properties instead of stripping them (#428). +* New `deprecated_generic()`, `deprecated_class()`, and `deprecated_property()` provide a standard way to deprecate a generic, class, or property. Old code keeps working (calls delegate to the replacement and method registrations are silently redirected to it) but signals a deprecation warning. They use `.Deprecated()`-style warnings by default; set `method` to `"lifecycle(warn)"` or `"lifecycle(stop)"` to use the lifecycle package instead (#727, #730). * `method<-` now works for double-dispatch operators (e.g. `+`, `==`, `%*%`) with plain S3 or S4 classes, even when neither operand is an S7 object (#544). * `method<-` no longer embeds a copy of a generic owned by another package in your package namespace. Instead it returns a sentinel value that the new `S7_on_build()` removes from the namespace at build time; call `S7_on_build()` at the top level of `zzz.R` (see `vignette("packages")`) (#364). * `method<-` now accepts `NULL` to unregister an existing method, e.g. `method(foo, class_character) <- NULL` (#613). @@ -28,6 +29,7 @@ * `new_class()` generates a working default constructor for a subclass of a class with a custom constructor: the subclass takes `...` and forwards it to the parent constructor (followed by a named argument for each property the subclass adds), so the parent's argument defaults are matched and evaluated by the parent itself. This is a breaking change: such a subclass constructor no longer exposes the parent's properties as named or positional arguments, only via `...` (#609, #317). * `new_class()`'s default constructor now respects properties overridden in a subclass: the subclass's default is used (#467) and its setter is run during construction (#585). Values for overridden properties are passed to both the parent constructor and the new object, so a subclass can override a parent property whose default is mandatory. * `new_external_class()` creates a delayed reference to an S7 class in another package (or your own package, but not yet defined). It is useful for registering methods on classes from suggested packages (#573), for creating self-referential or mutually recursive classes (#250), and for extending class from other packages (#317). +* `new_external_class()` references now resolve through exported aliases: the exported object no longer needs matching `@name` and `@package` properties, so a class renamed with an alias (`Foo <- Bar` or `deprecated_class()`) keeps working for downstream packages (#727). * `new_object()` now allows an abstract class's constructor to run when it is building the parent part of a subclass, so a subclass of an abstract class from another package (via `new_external_class()`) can be constructed (#717). * `new_object()` is now substantially faster, because each class caches its own name and dispatch vector, and class type detection has moved to C. Construction is 1.4x faster for a class that directly extends `S7_object`, rising to 2.4x faster for a class with 10 ancestors; `S7_inherits()` is 1.5x faster and `super()` is 2.1x faster (#723). * `new_object()` now gives an informative error when `.parent` is a class specification rather than an instance of the parent class (#409). diff --git a/R/class-spec.R b/R/class-spec.R index 63f5f9c08..45dd4d289 100644 --- a/R/class-spec.R +++ b/R/class-spec.R @@ -23,6 +23,10 @@ as_class <- function(x, arg = deparse(substitute(x))) { error_base <- sprintf("Can't convert `%s` to a valid class.", arg) + if (is_deprecated_class(x)) { + x <- deprecated_target(x) + } + if (is_foundation_class(x)) { x } else if (is.null(x)) { diff --git a/R/deprecated.R b/R/deprecated.R new file mode 100644 index 000000000..92d7c5472 --- /dev/null +++ b/R/deprecated.R @@ -0,0 +1,503 @@ +#' Deprecate a generic +#' +#' @description +#' If you rename a generic, move it to another package, or retire it without +#' a replacement, `deprecated_generic()` lets old code keep working while +#' warning users that they need to update. It creates a function that you +#' export under the old name: +#' +#' * Calling it signals a deprecation warning, then delegates to `new`. +#' * Methods registered on it with [method<-] are silently registered on +#' `new`, so downstream packages continue to work. +#' +#' To deprecate a generic that has no replacement, supply the generic itself +#' as `old`: it continues to power the deprecated name, but calls warn. +#' +#' @param name The old name of the generic, as a string. As with +#' [new_generic()], the result should be assigned to a variable with this +#' name, most easily with [:=]. +#' @param new The replacement: an S7 generic, usually the renamed generic, or +#' a generic that now lives in another package. +#' @param old For a deprecation without a replacement: the existing generic, +#' which continues to power the deprecated name. +#' @param when The package version when the deprecation began, e.g. +#' `"1.2.0"`. +#' @param method How to signal the deprecation: +#' +#' * `"base"` (the default): a [.Deprecated()]-style warning. +#' * `"lifecycle(warn)"`: [lifecycle::deprecate_warn()], a warning that's +#' only displayed once every eight hours. +#' * `"lifecycle(stop)"`: [lifecycle::deprecate_stop()], an error. +#' +#' The lifecycle options require the lifecycle package to be installed, +#' and to be a dependency of your package. +#' @returns A function with class `S7_deprecated_generic`. +#' @seealso [deprecated_class()] and [deprecated_property()] to deprecate +#' other parts of your API. +#' @export +#' @examples +#' # A generic renamed from summarise() to summarize(): +#' summarize := new_generic("x") +#' method(summarize, class_double) <- function(x) mean(x) +#' summarise := deprecated_generic(new = summarize, when = "1.1.0") +#' # Calling the old name warns, then delegates: +#' summarise(c(1, 2, 3)) +#' +#' # Registering a method on the old name registers it on the new generic: +#' method(summarise, class_character) <- function(x) unique(x) +#' summarize(c("a", "b", "a")) +#' +#' # A generic deprecated without a replacement keeps working: +#' shout := new_generic("x") +#' method(shout, class_character) <- function(x) toupper(x) +#' shout := deprecated_generic(old = shout, when = "2.0.0") +#' shout("hi") +deprecated_generic <- function( + name, + new = NULL, + old = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)") +) { + check_name(name) + check_when(when) + method <- check_deprecate_method(method) + env <- parent.frame() + package <- topNamespaceName(env) + + if (is.null(new) == is.null(old)) { + stop2("Must supply exactly one of `new` and `old`.") + } + + if (is.null(new)) { + if (!is_S7_generic(old)) { + msg <- sprintf("`old` must be an S7 generic, not %s.", obj_desc(old)) + stop2(msg) + } + if (!identical(old@name, name)) { + msg <- c( + sprintf( + "`old@name` (\"%s\") must match `name` (\"%s\").", + old@name, + name + ), + "* To deprecate in favor of a renamed generic, use `new`." + ) + stop2(msg) + } + target <- old + with <- NULL + } else { + if (is_deprecated_generic(new)) { + new <- deprecated_target(new) + } + if (!is_S7_generic(new)) { + msg <- sprintf("`new` must be an S7 generic, not %s.", obj_desc(new)) + stop2(msg) + } + target <- new + with <- target_label(package_name(target), target@name, package) + } + + new_deprecated_fun( + target = target, + what = paste0(name, "()"), + with = with, + when = when, + package = package, + method = method, + env = env, + class = "S7_deprecated_generic" + ) +} + +is_deprecated_generic <- function(x) inherits(x, "S7_deprecated_generic") + +#' Deprecate a class +#' +#' @description +#' If you rename a class or retire it without a replacement, +#' `deprecated_class()` lets old code keep working while warning users that +#' they need to update. It creates an alias that you export under the old +#' name: +#' +#' * Calling the constructor signals a deprecation warning, then constructs +#' an instance of `new`. +#' * In every other context (method signatures, `parent`, property classes, +#' [new_external_class()] references) it is silently treated as `new`. +#' +#' To deprecate a class that has no replacement, supply the class itself as +#' `old`: it continues to power the deprecated name, but constructing an +#' instance warns. +#' +#' @param name The old name of the class, as a string. As with [new_class()], +#' the result should be assigned to a variable with this name, most easily +#' with [:=]. +#' @param new The replacement: an S7 class, usually the renamed class. +#' @param old For a deprecation without a replacement: the existing class, +#' which continues to power the deprecated name. Its name must match +#' `name`. +#' @inheritParams deprecated_generic +#' @returns A function with class `S7_deprecated_class`. +#' @seealso [deprecated_generic()] and [deprecated_property()] to deprecate +#' other parts of your API. +#' @export +#' @examples +#' # A class renamed from Dog to Pet: +#' Pet := new_class(properties = list(name = class_character)) +#' Dog := deprecated_class(new = Pet, when = "2.0.0") +#' +#' # Calling the old constructor warns, then constructs the new class: +#' Dog(name = "Fido") +#' +#' # In method signatures the old name silently means the new class: +#' speak := new_generic("x") +#' method(speak, Dog) <- function(x) "Woof!" +#' speak(Pet(name = "Rex")) +#' +#' # A class deprecated without a replacement keeps working: +#' Cat := new_class(properties = list(lives = class_double)) +#' Cat := deprecated_class(old = Cat, when = "3.0.0") +#' Cat(lives = 9) +deprecated_class <- function( + name, + new = NULL, + old = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)") +) { + check_name(name) + check_when(when) + method <- check_deprecate_method(method) + env <- parent.frame() + package <- topNamespaceName(env) + + if (is.null(new) == is.null(old)) { + stop2("Must supply exactly one of `new` and `old`.") + } + + if (is.null(new)) { + if (!is_class(old)) { + msg <- sprintf("`old` must be an S7 class, not %s.", obj_desc(old)) + stop2(msg) + } + if (!identical(old@name, name)) { + msg <- c( + sprintf( + "`old@name` (\"%s\") must match `name` (\"%s\").", + old@name, + name + ), + "* To deprecate in favor of a renamed class, use `new`." + ) + stop2(msg) + } + target <- old + with <- NULL + } else { + if (is_deprecated_class(new)) { + new <- deprecated_target(new) + } + if (!is_class(new)) { + msg <- sprintf("`new` must be an S7 class, not %s.", obj_desc(new)) + stop2(msg) + } + target <- new + with <- target_label(target@package, target@name, package) + } + + new_deprecated_fun( + target = target, + what = paste0(name, "()"), + with = with, + when = when, + package = package, + method = method, + env = env, + class = "S7_deprecated_class" + ) +} + +is_deprecated_class <- function(x) inherits(x, "S7_deprecated_class") + +#' Deprecate a property +#' +#' @description +#' If you rename a property or retire it without a replacement, +#' `deprecated_property()` lets old code keep working while warning users +#' that they need to update. It creates a property that signals a +#' deprecation warning when it is read or written, delegating storage to the +#' property named `new`. +#' +#' To deprecate a property that has no replacement, omit `new`: the property +#' stores data as usual, but warns when read or written. (Unlike a property +#' with a replacement, supplying a value to the constructor does not warn, +#' because S7 can't distinguish a user-supplied value from the default.) +#' +#' @param old The name of the deprecated property, as a string. Because the +#' name is part of the property itself, the `properties` list entry doesn't +#' need to be named. +#' @param new The name of the replacement property, as a string. If `NULL`, +#' the property is deprecated without a replacement. +#' @param class,default The property `class` and `default`, as in +#' [new_property()]. When `new` is supplied, `default` defaults to the +#' value of the replacement property so that construction only warns when +#' the deprecated argument is actually used. +#' @inheritParams deprecated_generic +#' @returns An [S7 property][new_property]. +#' @seealso [deprecated_generic()] and [deprecated_class()] to deprecate +#' other parts of your API. +#' @export +#' @examples +#' # A property renamed from count to size: +#' Basket := new_class(properties = list( +#' size = class_double, +#' deprecated_property("count", new = "size", when = "1.5.0") +#' )) +#' +#' # Using the new name is silent, using the old name warns: +#' basket <- Basket(size = 3) +#' basket@count +deprecated_property <- function( + old, + new = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)"), + class = class_any, + default = NULL +) { + check_name(old, arg = "old") + check_when(when) + method <- check_deprecate_method(method) + if (!is.null(new)) { + check_name(new, arg = "new") + } + package <- topNamespaceName(parent.frame()) + env <- parent.frame() + + # Property labels aren't function calls, so wrap them in I() to protect + # them from lifecycle's spec parser + signal <- function(self, with) { + deprecate_signal( + when = when, + what = I(prop_label(self, old)), + with = if (!is.null(with)) I(with), + package = package, + method = method, + env = env, + # `what` embeds the class of `self`, so it isn't a stable lifecycle id + id = paste(c(package, old), collapse = "::") + ) + } + + if (is.null(new)) { + storage <- prop_storage_rename(old) + getter <- function(self) { + signal(self, with = NULL) + attr(self, storage, exact = TRUE) + } + setter <- function(self, value) { + current <- attr(self, storage, exact = TRUE) + # An unset property is being initialized by the constructor + if (!is.null(current) && !identical(value, current)) { + signal(self, with = NULL) + } + attr(self, storage) <- value + self + } + } else { + getter <- function(self) { + signal(self, with = prop_label(self, new)) + prop(self, new) + } + setter <- function(self, value) { + # No signal when set to the current value of the replacement, which is + # what the default constructor does when the old argument isn't used. + if (!identical(value, prop(self, new))) { + signal(self, with = prop_label(self, new)) + prop(self, new) <- value + } + self + } + default <- default %||% as.name(new) + } + + new_property( + class = class, + getter = getter, + setter = setter, + default = default, + name = old + ) +} + +# The wrapper's closure environment (the execution environment of +# new_deprecated_fun()) holds everything about the deprecation, so +# introspection reads from it rather than from duplicated attributes. +deprecated_target <- function(x) environment(x)$target + +# Build the wrapper exported under the old name: it signals the deprecation, +# then evaluates the user's call with the target in functional position, so +# all arguments are passed on lazily and unmodified. +new_deprecated_fun <- function( + target, + what, + with, + when, + package, + method, + env, + class +) { + out <- function(...) { + call <- sys.call() + deprecate_signal( + when = when, + what = what, + with = with, + package = package, + method = method, + env = env, + call = call, + user_env = parent.frame() + ) + call[[1L]] <- target + eval(call, parent.frame()) + } + # The body ignores its formals, but the target's formals give informative + # introspection (args(), autocomplete) and identical argument matching errors + formals(out) <- formals(target) + class(out) <- c(class, "function") + out +} + +# The pluggable deprecation signal. `what`/`with` are function specs like +# "gen1()"; specs that aren't function calls (property labels) must be +# wrapped in I() by the caller. +deprecate_signal <- function( + when, + what, + with = NULL, + package = NULL, + method = "base", + env = parent.frame(), + call = NULL, + user_env = NULL, + id = NULL +) { + if (method == "base") { + # Equivalent to .Deprecated(msg =, old =), but attributes the warning to + # the user's call rather than to S7 internals + warning(warningCondition( + deprecated_message(what, when, with, package), + old = as.character(what), + class = "deprecatedWarning", + call = call + )) + } else { + # `env` (the deprecation site) attributes the deprecation to the right + # package; `user_env` (the caller of the deprecated code) blames the + # right user. Find it now: lazy evaluation would walk the frame stack + # from inside lifecycle. + user_env <- user_env %||% user_frame() + switch( + method, + "lifecycle(warn)" = lifecycle::deprecate_warn( + when, + what, + with, + id = id %||% as.character(what), + env = env, + user_env = user_env + ), + "lifecycle(stop)" = lifecycle::deprecate_stop( + when, + what, + with, + env = env + ) + ) + } + invisible() +} + +# The frame of the nearest caller from outside S7. Used to attribute a +# deprecation to the right user when it's signalled from deep inside S7 +# machinery (e.g. a deprecated property accessed via `@`). +user_frame <- function() { + S7_ns <- topenv(environment()) + for (i in rev(seq_len(sys.nframe() - 1L))) { + fun_env <- environment(sys.function(i)) + if (is.null(fun_env) || !identical(topenv(fun_env), S7_ns)) { + return(sys.frame(i)) + } + } + globalenv() +} + +# How to refer to the replacement: qualified with its package, unless it +# lives in the same package as the deprecated alias. +target_label <- function(target_package, target_name, package) { + if (!is.null(target_package) && !identical(target_package, package)) { + sprintf("%s::%s()", target_package, target_name) + } else { + sprintf("%s()", target_name) + } +} + +check_when <- function(when, call = sys.call(-1L)) { + if (!is_string(when)) { + stop2("`when` must be a single string.", call = call) + } + version <- tryCatch(numeric_version(when), error = function(e) NULL) + if (is.null(version)) { + msg <- sprintf("`when` must be a version number, not \"%s\".", when) + stop2(msg, call = call) + } +} + +deprecate_methods <- c("base", "lifecycle(warn)", "lifecycle(stop)") + +check_deprecate_method <- function(method, call = sys.call(-1L)) { + if (identical(method, deprecate_methods)) { + return("base") + } + if (!is_string(method) || !method %in% deprecate_methods) { + msg <- sprintf( + "`method` must be one of %s.", + oxford_or(paste0('"', deprecate_methods, '"')) + ) + stop2(msg, call = call) + } + method +} + +#' @export +print.S7_deprecated_generic <- function(x, ...) { + cat(" ", deprecated_desc(x), "\n", sep = "") + invisible(x) +} + +#' @export +print.S7_deprecated_class <- function(x, ...) { + cat(" ", deprecated_desc(x), "\n", sep = "") + invisible(x) +} + +deprecated_desc <- function(x) { + env <- environment(x) + msg <- deprecated_message(env$what, env$when, env$with, env$package) + gsub("\n", " ", msg, fixed = TRUE) +} +deprecated_message <- function(what, when, with = NULL, package = NULL) { + msg <- sprintf( + "`%s` was deprecated in %s %s.", + what, + package %||% "version", + when + ) + if (!is.null(with)) { + msg <- paste0(msg, "\n", sprintf("Please use `%s` instead.", with)) + } + msg +} diff --git a/R/external-class.R b/R/external-class.R index 411a66150..44dc0a23b 100644 --- a/R/external-class.R +++ b/R/external-class.R @@ -171,19 +171,18 @@ resolve_external_class_req <- function(x, package = NULL) { obj <- NULL } - is_match <- is_class(obj) && - identical(obj@name, x$name) && - identical(obj@package, x$package) + if (is_deprecated_class(obj)) { + obj <- deprecated_target(obj) + } - if (!is_match) { + if (!is_class(obj)) { verb <- if (same_package) "bind" else "export" stop2( sprintf( - "Package '%s' must %s `%s` as the S7 class <%s>.", + "Package '%s' must %s `%s` as an S7 class.", x$package, verb, - x$name, - x$class_name + x$name ), call = NULL, class = error_class diff --git a/R/external-generic.R b/R/external-generic.R index 089d1b712..ca591979c 100644 --- a/R/external-generic.R +++ b/R/external-generic.R @@ -41,6 +41,10 @@ new_external_generic <- function(package, name, dispatch_args, version = NULL) { } as_external_generic <- function(x, env = parent.frame()) { + if (is_deprecated_generic(x)) { + x <- deprecated_target(x) + } + if (is_generic_sentinel(x)) { # Sentinels are external generic specs with an extra marker class; keep # this in sync with generic_sentinel(). diff --git a/R/generic-spec.R b/R/generic-spec.R index 3caf075b4..75a58a61d 100644 --- a/R/generic-spec.R +++ b/R/generic-spec.R @@ -6,6 +6,10 @@ is_generic <- function(x) { } as_generic <- function(x, call = sys.call(-1L)) { + if (is_deprecated_generic(x)) { + x <- deprecated_target(x) + } + if (is_generic(x)) { x } else if (is.function(x)) { diff --git a/R/introspect.R b/R/introspect.R index 243942a27..ecd742f6a 100644 --- a/R/introspect.R +++ b/R/introspect.R @@ -52,6 +52,9 @@ S7_generics <- function(env = parent.frame()) { #' S7_methods(class = Foo) S7_methods <- function(generic = NULL, class = NULL) { if (!is.null(generic)) { + if (is_deprecated_generic(generic)) { + generic <- deprecated_target(generic) + } if (!is_S7_generic(generic)) { stop("`generic` must be an S7 generic.") } diff --git a/R/method-introspect.R b/R/method-introspect.R index e0e1bec8a..fff514e23 100644 --- a/R/method-introspect.R +++ b/R/method-introspect.R @@ -38,6 +38,9 @@ #' try(method(bizarro, class = class_data.frame)) #' try(method(bizarro, object = "x")) method <- function(generic, class = NULL, object = NULL) { + if (is_deprecated_generic(generic)) { + generic <- deprecated_target(generic) + } check_is_S7(generic, S7_generic) dispatch <- as_dispatch(generic, class = class, object = object) @@ -80,6 +83,9 @@ method <- function(generic, class = NULL, object = NULL) { #' #' method_explain(add, list(Foo2, Foo2)) method_explain <- function(generic, class = NULL, object = NULL) { + if (is_deprecated_generic(generic)) { + generic <- deprecated_target(generic) + } check_is_S7(generic, S7_generic) dispatch <- as_dispatch(generic, class = class, object = object) dispatch <- lapply(dispatch, c, "ANY") diff --git a/_pkgdown.yml b/_pkgdown.yml index 511b68c2d..e9b011f26 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -48,6 +48,15 @@ reference: - new_external_class - new_external_generic +- title: Deprecation + desc: > + Helpers for gracefully retiring generics, classes, and properties: + old code keeps working, but warns users to update. + contents: + - deprecated_generic + - deprecated_class + - deprecated_property + - title: Compatibility desc: > These tools provide a layer of compatibility between S7 and S3 classes, S4 diff --git a/man/deprecated_class.Rd b/man/deprecated_class.Rd new file mode 100644 index 000000000..de027ded1 --- /dev/null +++ b/man/deprecated_class.Rd @@ -0,0 +1,80 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/deprecated.R +\name{deprecated_class} +\alias{deprecated_class} +\title{Deprecate a class} +\usage{ +deprecated_class( + name, + new = NULL, + old = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)") +) +} +\arguments{ +\item{name}{The old name of the class, as a string. As with \code{\link[=new_class]{new_class()}}, +the result should be assigned to a variable with this name, most easily +with \link{:=}.} + +\item{new}{The replacement: an S7 class, usually the renamed class.} + +\item{old}{For a deprecation without a replacement: the existing class, +which continues to power the deprecated name. Its name must match +\code{name}.} + +\item{when}{The package version when the deprecation began, e.g. +\code{"1.2.0"}.} + +\item{method}{How to signal the deprecation: +\itemize{ +\item \code{"base"} (the default): a \code{\link[=.Deprecated]{.Deprecated()}}-style warning. +\item \code{"lifecycle(warn)"}: \code{\link[lifecycle:deprecate_warn]{lifecycle::deprecate_warn()}}, a warning that's +only displayed once every eight hours. +\item \code{"lifecycle(stop)"}: \code{\link[lifecycle:deprecate_stop]{lifecycle::deprecate_stop()}}, an error. +} + +The lifecycle options require the lifecycle package to be installed, +and to be a dependency of your package.} +} +\value{ +A function with class \code{S7_deprecated_class}. +} +\description{ +If you rename a class or retire it without a replacement, +\code{deprecated_class()} lets old code keep working while warning users that +they need to update. It creates an alias that you export under the old +name: +\itemize{ +\item Calling the constructor signals a deprecation warning, then constructs +an instance of \code{new}. +\item In every other context (method signatures, \code{parent}, property classes, +\code{\link[=new_external_class]{new_external_class()}} references) it is silently treated as \code{new}. +} + +To deprecate a class that has no replacement, supply the class itself as +\code{old}: it continues to power the deprecated name, but constructing an +instance warns. +} +\examples{ +# A class renamed from Dog to Pet: +Pet := new_class(properties = list(name = class_character)) +Dog := deprecated_class(new = Pet, when = "2.0.0") + +# Calling the old constructor warns, then constructs the new class: +Dog(name = "Fido") + +# In method signatures the old name silently means the new class: +speak := new_generic("x") +method(speak, Dog) <- function(x) "Woof!" +speak(Pet(name = "Rex")) + +# A class deprecated without a replacement keeps working: +Cat := new_class(properties = list(lives = class_double)) +Cat := deprecated_class(old = Cat, when = "3.0.0") +Cat(lives = 9) +} +\seealso{ +\code{\link[=deprecated_generic]{deprecated_generic()}} and \code{\link[=deprecated_property]{deprecated_property()}} to deprecate +other parts of your API. +} diff --git a/man/deprecated_generic.Rd b/man/deprecated_generic.Rd new file mode 100644 index 000000000..a54f2a0cd --- /dev/null +++ b/man/deprecated_generic.Rd @@ -0,0 +1,78 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/deprecated.R +\name{deprecated_generic} +\alias{deprecated_generic} +\title{Deprecate a generic} +\usage{ +deprecated_generic( + name, + new = NULL, + old = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)") +) +} +\arguments{ +\item{name}{The old name of the generic, as a string. As with +\code{\link[=new_generic]{new_generic()}}, the result should be assigned to a variable with this +name, most easily with \link{:=}.} + +\item{new}{The replacement: an S7 generic, usually the renamed generic, or +a generic that now lives in another package.} + +\item{old}{For a deprecation without a replacement: the existing generic, +which continues to power the deprecated name.} + +\item{when}{The package version when the deprecation began, e.g. +\code{"1.2.0"}.} + +\item{method}{How to signal the deprecation: +\itemize{ +\item \code{"base"} (the default): a \code{\link[=.Deprecated]{.Deprecated()}}-style warning. +\item \code{"lifecycle(warn)"}: \code{\link[lifecycle:deprecate_warn]{lifecycle::deprecate_warn()}}, a warning that's +only displayed once every eight hours. +\item \code{"lifecycle(stop)"}: \code{\link[lifecycle:deprecate_stop]{lifecycle::deprecate_stop()}}, an error. +} + +The lifecycle options require the lifecycle package to be installed, +and to be a dependency of your package.} +} +\value{ +A function with class \code{S7_deprecated_generic}. +} +\description{ +If you rename a generic, move it to another package, or retire it without +a replacement, \code{deprecated_generic()} lets old code keep working while +warning users that they need to update. It creates a function that you +export under the old name: +\itemize{ +\item Calling it signals a deprecation warning, then delegates to \code{new}. +\item Methods registered on it with \link{method<-} are silently registered on +\code{new}, so downstream packages continue to work. +} + +To deprecate a generic that has no replacement, supply the generic itself +as \code{old}: it continues to power the deprecated name, but calls warn. +} +\examples{ +# A generic renamed from summarise() to summarize(): +summarize := new_generic("x") +method(summarize, class_double) <- function(x) mean(x) +summarise := deprecated_generic(new = summarize, when = "1.1.0") +# Calling the old name warns, then delegates: +summarise(c(1, 2, 3)) + +# Registering a method on the old name registers it on the new generic: +method(summarise, class_character) <- function(x) unique(x) +summarize(c("a", "b", "a")) + +# A generic deprecated without a replacement keeps working: +shout := new_generic("x") +method(shout, class_character) <- function(x) toupper(x) +shout := deprecated_generic(old = shout, when = "2.0.0") +shout("hi") +} +\seealso{ +\code{\link[=deprecated_class]{deprecated_class()}} and \code{\link[=deprecated_property]{deprecated_property()}} to deprecate +other parts of your API. +} diff --git a/man/deprecated_property.Rd b/man/deprecated_property.Rd new file mode 100644 index 000000000..247bd5cdc --- /dev/null +++ b/man/deprecated_property.Rd @@ -0,0 +1,72 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/deprecated.R +\name{deprecated_property} +\alias{deprecated_property} +\title{Deprecate a property} +\usage{ +deprecated_property( + old, + new = NULL, + when, + method = c("base", "lifecycle(warn)", "lifecycle(stop)"), + class = class_any, + default = NULL +) +} +\arguments{ +\item{old}{The name of the deprecated property, as a string. Because the +name is part of the property itself, the \code{properties} list entry doesn't +need to be named.} + +\item{new}{The name of the replacement property, as a string. If \code{NULL}, +the property is deprecated without a replacement.} + +\item{when}{The package version when the deprecation began, e.g. +\code{"1.2.0"}.} + +\item{method}{How to signal the deprecation: +\itemize{ +\item \code{"base"} (the default): a \code{\link[=.Deprecated]{.Deprecated()}}-style warning. +\item \code{"lifecycle(warn)"}: \code{\link[lifecycle:deprecate_warn]{lifecycle::deprecate_warn()}}, a warning that's +only displayed once every eight hours. +\item \code{"lifecycle(stop)"}: \code{\link[lifecycle:deprecate_stop]{lifecycle::deprecate_stop()}}, an error. +} + +The lifecycle options require the lifecycle package to be installed, +and to be a dependency of your package.} + +\item{class, default}{The property \code{class} and \code{default}, as in +\code{\link[=new_property]{new_property()}}. When \code{new} is supplied, \code{default} defaults to the +value of the replacement property so that construction only warns when +the deprecated argument is actually used.} +} +\value{ +An \link[=new_property]{S7 property}. +} +\description{ +If you rename a property or retire it without a replacement, +\code{deprecated_property()} lets old code keep working while warning users +that they need to update. It creates a property that signals a +deprecation warning when it is read or written, delegating storage to the +property named \code{new}. + +To deprecate a property that has no replacement, omit \code{new}: the property +stores data as usual, but warns when read or written. (Unlike a property +with a replacement, supplying a value to the constructor does not warn, +because S7 can't distinguish a user-supplied value from the default.) +} +\examples{ +# A property renamed from count to size: +Basket := new_class(properties = list( + size = class_double, + deprecated_property("count", new = "size", when = "1.5.0") +)) + +# Using the new name is silent, using the old name warns: +basket <- Basket(size = 3) +basket@count +} +\seealso{ +\code{\link[=deprecated_generic]{deprecated_generic()}} and \code{\link[=deprecated_class]{deprecated_class()}} to deprecate +other parts of your API. +} diff --git a/tests/testthat/_snaps/deprecated.md b/tests/testthat/_snaps/deprecated.md new file mode 100644 index 000000000..4415d13c4 --- /dev/null +++ b/tests/testthat/_snaps/deprecated.md @@ -0,0 +1,241 @@ +# deprecated_generic() warns then delegates to the replacement + + Code + out <- old_gen(c(1, 2, 3)) + Condition + Warning in `old_gen()`: + `old_gen()` was deprecated in S7 1.1.0. + Please use `new_gen()` instead. + +# deprecated_generic() without a replacement still dispatches + + Code + out <- old_gen("hi") + Condition + Warning in `old_gen()`: + `old_gen()` was deprecated in S7 2.0.0. + +# deprecated_generic() validates its inputs + + Code + deprecated_generic(1, new = new_gen, when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! `name` must be a single string. + Code + deprecated_generic("old_gen", new = new_gen) + Condition + Error in `deprecated_generic()`: + ! argument "when" is missing, with no default + Code + deprecated_generic("old_gen", new = new_gen, when = "next year") + Condition + Error in `deprecated_generic()`: + ! `when` must be a version number, not "next year". + Code + deprecated_generic("old_gen", new = mean, when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! `new` must be an S7 generic, not . + Code + deprecated_generic("old_gen", when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! Must supply exactly one of `new` and `old`. + Code + deprecated_generic("old_gen", new = new_gen, old = new_gen, when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! Must supply exactly one of `new` and `old`. + Code + deprecated_generic("old_gen", old = mean, when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! `old` must be an S7 generic, not . + Code + deprecated_generic("old_gen", old = new_gen, when = "1.0.0") + Condition + Error in `deprecated_generic()`: + ! `old@name` ("new_gen") must match `name` ("old_gen"). + * To deprecate in favor of a renamed generic, use `new`. + Code + deprecated_generic("old_gen", new = new_gen, when = "1.0.0", method = "warn") + Condition + Error in `deprecated_generic()`: + ! `method` must be one of "base", "lifecycle(warn)", or "lifecycle(stop)". + +# deprecated_class() constructor warns then constructs the replacement + + Code + d <- Dog(name = "Fido") + Condition + Warning in `Dog()`: + `Dog()` was deprecated in S7 2.0.0. + Please use `Pet()` instead. + +# deprecated_class() without a replacement still constructs + + Code + felix <- Cat(lives = 9) + Condition + Warning in `Cat()`: + `Cat()` was deprecated in S7 3.0.0. + +# deprecated_class() validates its inputs + + Code + deprecated_class(1, new = Pet, when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! `name` must be a single string. + Code + deprecated_class("Old", new = Pet) + Condition + Error in `deprecated_class()`: + ! argument "when" is missing, with no default + Code + deprecated_class("Old", new = 1, when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! `new` must be an S7 class, not . + Code + deprecated_class("Old", when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! Must supply exactly one of `new` and `old`. + Code + deprecated_class("Old", new = Pet, old = Pet, when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! Must supply exactly one of `new` and `old`. + Code + deprecated_class("Old", old = 1, when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! `old` must be an S7 class, not . + Code + deprecated_class("Old", old = Pet, when = "1.0.0") + Condition + Error in `deprecated_class()`: + ! `old@name` ("Pet") must match `name` ("Old"). + * To deprecate in favor of a renamed class, use `new`. + +# deprecated_property() with a replacement delegates and warns + + Code + print(b@count) + Condition + Warning: + `@count` was deprecated in S7 1.5.0. + Please use `@size` instead. + Output + [1] 3 + Code + b@count <- 5 + Condition + Warning: + `@count` was deprecated in S7 1.5.0. + Please use `@size` instead. + +# deprecated_property() only warns at construction when actually used + + Code + b <- Basket(count = 7) + Condition + Warning: + `@count` was deprecated in S7 1.5.0. + Please use `@size` instead. + +# deprecated_property() without a replacement still stores data + + Code + print(h@brim) + Condition + Warning: + `@brim` was deprecated in S7 0.9.0. + Output + [1] 2 + Code + h@brim <- 3 + Condition + Warning: + `@brim` was deprecated in S7 0.9.0. + +# deprecated_property() validates its inputs + + Code + deprecated_property(1, when = "1.0.0") + Condition + Error in `deprecated_property()`: + ! `old` must be a single string. + Code + deprecated_property("count", new = 1, when = "1.0.0") + Condition + Error in `deprecated_property()`: + ! `new` must be a single string. + Code + deprecated_property("count", new = "size") + Condition + Error in `deprecated_property()`: + ! argument "when" is missing, with no default + Code + deprecated_property("count", new = "size", when = "next year") + Condition + Error in `deprecated_property()`: + ! `when` must be a version number, not "next year". + Code + deprecated_property("count", new = "size", when = "1.0.0", method = "warn") + Condition + Error in `deprecated_property()`: + ! `method` must be one of "base", "lifecycle(warn)", or "lifecycle(stop)". + +# deprecation warnings mention the package + + Code + invisible(pkg$old_gen(1)) + Condition + Warning in `pkg$old_gen()`: + `old_gen()` was deprecated in pkgA 1.1.0. + Please use `new_gen()` instead. + +# method = 'lifecycle(warn)' signals with lifecycle + + Code + invisible(pkg$old_gen(1)) + Condition + Warning: + `old_gen()` was deprecated in pkgA 1.1.0. + i Please use `new_gen()` instead. + +# method = 'lifecycle(stop)' errors + + Code + old_gen(1) + Condition + Error: + ! `old_gen()` was deprecated in S7 1.1.0 and is now defunct. + i Please use `new_gen()` instead. + +# deprecated_property() works with lifecycle + + Code + invisible(b@count) + Condition + Warning: + @count was deprecated in S7 1.5.0. + i Please use @size instead. + +# deprecated generics and classes print nicely + + Code + print(old_gen) + Output + `old_gen()` was deprecated in S7 1.1.0. Please use `new_gen()` instead. + Code + print(Dog) + Output + `Dog()` was deprecated in S7 2.0.0. Please use `Pet()` instead. + Code + print(Cat) + Output + `Cat()` was deprecated in S7 3.0.0. diff --git a/tests/testthat/_snaps/external-class.md b/tests/testthat/_snaps/external-class.md index 469882b47..e625a03fa 100644 --- a/tests/testthat/_snaps/external-class.md +++ b/tests/testthat/_snaps/external-class.md @@ -40,7 +40,7 @@ resolve_external_class_req(new_external_class("too.old", "X")) Condition Error: - ! Package 'too.old' must export `X` as the S7 class . + ! Package 'too.old' must export `X` as an S7 class. # external class works as a property type for self-reference @@ -68,4 +68,3 @@ Error: ! Can't find external class : * Package 'dep' needs version 2.0.0, but only 1.0.0 is available. - diff --git a/tests/testthat/test-deprecated.R b/tests/testthat/test-deprecated.R new file mode 100644 index 000000000..446aebd9b --- /dev/null +++ b/tests/testthat/test-deprecated.R @@ -0,0 +1,243 @@ +test_that("deprecated_generic() warns then delegates to the replacement", { + new_gen := new_generic("x") + method(new_gen, class_double) <- function(x) mean(x) + old_gen := deprecated_generic(new = new_gen, when = "1.1.0") + + expect_snapshot(out <- old_gen(c(1, 2, 3))) + expect_equal(out, 2) + expect_identical(formals(old_gen), formals(new_gen)) +}) + +test_that("method registration on a deprecated generic targets the replacement", { + new_gen := new_generic("x") + old_gen := deprecated_generic(new = new_gen, when = "1.1.0") + + method(old_gen, class_character) <- function(x) toupper(x) + expect_equal(new_gen("hi"), "HI") + + # method() and S7_methods() introspection unwrap too + expect_equal(method(old_gen, class_character)(x = "hi"), "HI") + expect_equal(S7_methods(old_gen)$generic, "new_gen") +}) + +test_that("deprecated_generic() without a replacement still dispatches", { + old_gen := new_generic("x") + old_gen := deprecated_generic(old = old_gen, when = "2.0.0") + method(old_gen, class_character) <- function(x) toupper(x) + + expect_snapshot(out <- old_gen("hi")) + expect_equal(out, "HI") +}) + +test_that("external generic registration resolves through a deprecated generic", { + local_package("pkgA", { + new_gen := new_generic("x") + old_gen := deprecated_generic(new = new_gen, when = "1.1.0") + }) + local_package("pkgB", { + old_gen := new_external_generic("pkgA", dispatch_args = "x") + method(old_gen, class_character) <- function(x) toupper(x) + }) + + expect_equal(asNamespace("pkgA")$new_gen("hi"), "HI") +}) + +test_that("deprecated_generic() validates its inputs", { + new_gen := new_generic("x") + expect_snapshot(error = TRUE, { + deprecated_generic(1, new = new_gen, when = "1.0.0") + deprecated_generic("old_gen", new = new_gen) + deprecated_generic("old_gen", new = new_gen, when = "next year") + deprecated_generic("old_gen", new = mean, when = "1.0.0") + deprecated_generic("old_gen", when = "1.0.0") + deprecated_generic("old_gen", new = new_gen, old = new_gen, when = "1.0.0") + deprecated_generic("old_gen", old = mean, when = "1.0.0") + deprecated_generic("old_gen", old = new_gen, when = "1.0.0") + deprecated_generic( + "old_gen", + new = new_gen, + when = "1.0.0", + method = "warn" + ) + }) +}) + +test_that("deprecated_class() constructor warns then constructs the replacement", { + Pet := new_class(properties = list(name = class_character)) + Dog := deprecated_class(new = Pet, when = "2.0.0") + + expect_snapshot(d <- Dog(name = "Fido")) + expect_identical(S7_class(d), Pet) + expect_identical(formals(Dog), formals(Pet)) +}) + +test_that("deprecated class is silently treated as the replacement in class contexts", { + Pet := new_class(properties = list(name = class_character)) + Dog := deprecated_class(new = Pet, when = "2.0.0") + + expect_identical(as_class(Dog), Pet) + + speak := new_generic("x") + method(speak, Dog) <- function(x) "Woof!" + expect_equal(speak(Pet(name = "Rex")), "Woof!") + + BigDog := new_class(parent = Dog) + expect_identical(BigDog@parent, Pet) + expect_no_warning(big <- BigDog(name = "Rex")) + expect_equal(big@name, "Rex") +}) + +test_that("deprecated_class() without a replacement still constructs", { + Cat := new_class(properties = list(lives = class_double)) + Cat := deprecated_class(old = Cat, when = "3.0.0") + + expect_snapshot(felix <- Cat(lives = 9)) + expect_equal(felix@lives, 9) + expect_equal(S7_class(felix)@name, "Cat") +}) + +test_that("deprecated_class() validates its inputs", { + Pet := new_class() + expect_snapshot(error = TRUE, { + deprecated_class(1, new = Pet, when = "1.0.0") + deprecated_class("Old", new = Pet) + deprecated_class("Old", new = 1, when = "1.0.0") + deprecated_class("Old", when = "1.0.0") + deprecated_class("Old", new = Pet, old = Pet, when = "1.0.0") + deprecated_class("Old", old = 1, when = "1.0.0") + deprecated_class("Old", old = Pet, when = "1.0.0") + }) +}) + +test_that("deprecated_property() with a replacement delegates and warns", { + Basket := new_class( + properties = list( + size = class_double, + deprecated_property("count", new = "size", when = "1.5.0") + ) + ) + + b <- Basket(size = 3) + expect_snapshot({ + print(b@count) + b@count <- 5 + }) + expect_equal(b@size, 5) +}) + +test_that("deprecated_property() only warns at construction when actually used", { + Basket := new_class( + properties = list( + size = class_double, + deprecated_property("count", new = "size", when = "1.5.0") + ) + ) + + expect_no_warning(Basket(size = 3)) + expect_snapshot(b <- Basket(count = 7)) + expect_equal(b@size, 7) +}) + +test_that("deprecated_property() without a replacement still stores data", { + Hat := new_class( + properties = list( + deprecated_property("brim", when = "0.9.0", class = class_double) + ) + ) + + expect_no_warning(h <- Hat(brim = 2)) + expect_snapshot({ + print(h@brim) + h@brim <- 3 + }) + expect_equal(attr(h, "brim"), 3) +}) + +test_that("deprecated_property() validates its inputs", { + expect_snapshot(error = TRUE, { + deprecated_property(1, when = "1.0.0") + deprecated_property("count", new = 1, when = "1.0.0") + deprecated_property("count", new = "size") + deprecated_property("count", new = "size", when = "next year") + deprecated_property( + "count", + new = "size", + when = "1.0.0", + method = "warn" + ) + }) +}) + +test_that("deprecation warnings mention the package", { + pkg <- local_package("pkgA", { + new_gen := new_generic("x") + method(new_gen, class_double) <- function(x) x + old_gen := deprecated_generic(new = new_gen, when = "1.1.0") + }) + + expect_snapshot(invisible(pkg$old_gen(1))) +}) + +test_that("method = 'lifecycle(warn)' signals with lifecycle", { + skip_if_not_installed("lifecycle") + + pkg <- local_package("pkgA", { + new_gen := new_generic("x") + method(new_gen, class_double) <- function(x) x + old_gen := deprecated_generic( + new = new_gen, + when = "1.1.0", + method = "lifecycle(warn)" + ) + }) + + expect_snapshot(invisible(pkg$old_gen(1))) +}) + +test_that("method = 'lifecycle(stop)' errors", { + skip_if_not_installed("lifecycle") + + new_gen := new_generic("x") + method(new_gen, class_double) <- function(x) x + old_gen := deprecated_generic( + new = new_gen, + when = "1.1.0", + method = "lifecycle(stop)" + ) + + expect_snapshot(old_gen(1), error = TRUE) +}) + +test_that("deprecated_property() works with lifecycle", { + skip_if_not_installed("lifecycle") + + Basket := new_class( + properties = list( + size = class_double, + deprecated_property( + "count", + new = "size", + when = "1.5.0", + method = "lifecycle(warn)" + ) + ) + ) + b <- Basket(size = 3) + + expect_snapshot(invisible(b@count)) +}) + +test_that("deprecated generics and classes print nicely", { + new_gen := new_generic("x") + old_gen := deprecated_generic(new = new_gen, when = "1.1.0") + Pet := new_class() + Dog := deprecated_class(new = Pet, when = "2.0.0") + Cat := new_class() + Cat := deprecated_class(old = Cat, when = "3.0.0") + + expect_snapshot({ + print(old_gen) + print(Dog) + print(Cat) + }) +}) diff --git a/tests/testthat/test-external-class.R b/tests/testthat/test-external-class.R index 9aa5b60dd..207fcd4bd 100644 --- a/tests/testthat/test-external-class.R +++ b/tests/testthat/test-external-class.R @@ -42,6 +42,26 @@ test_that("resolve_external_class_req() errors per failure mode", { }) }) +test_that("resolve_external_class_req() resolves an alias to a renamed class (#727)", { + pkg <- local_package("renamepkg", { + Bar := new_class() + Foo <- Bar + }) + + Foo := new_external_class("renamepkg") + expect_identical(resolve_external_class_req(Foo), pkg$Bar) +}) + +test_that("resolve_external_class_req() resolves a deprecated_class() alias", { + pkg <- local_package("deprpkg", { + Bar := new_class() + Foo := deprecated_class(new = Bar, when = "2.0.0") + }) + + Foo := new_external_class("deprpkg") + expect_identical(resolve_external_class_req(Foo), pkg$Bar) +}) + test_that("external class can be used as a union arm", { ec := new_external_class("foo") u <- NULL | ec diff --git a/vignettes/classes-objects.Rmd b/vignettes/classes-objects.Rmd index ed044ccef..4f0043d37 100644 --- a/vignettes/classes-objects.Rmd +++ b/vignettes/classes-objects.Rmd @@ -282,27 +282,12 @@ x #### Deprecated properties -A `setter` + `getter` can be used to to deprecate a property: +Use `deprecated_property()` to rename a property while keeping old code working. Reading or writing the deprecated name signals a deprecation warning, then delegates to the replacement: ```{r} Person := new_class(properties = list( - first_name = class_character, - firstName = new_property( - class_character, - default = quote(first_name), - getter = function(self) { - warning("@firstName is deprecated; please use @first_name instead", call. = FALSE) - self@first_name - }, - setter = function(self, value) { - if (identical(value, self@first_name)) { - return(self) - } - warning("@firstName is deprecated; please use @first_name instead", call. = FALSE) - self@first_name <- value - self - } - ) + first_name = class_character, + deprecated_property("firstName", new = "first_name", when = "1.1.0") )) args(Person) @@ -318,6 +303,8 @@ hadley@firstName <- "John" hadley@first_name # no warning ``` +Under the hood, this is implemented with a `getter` and `setter` that warn then delegate to the replacement, and a `default` that ensures the constructor only warns when the deprecated argument is actually supplied. See `deprecated_generic()` and `deprecated_class()` for deprecating other parts of your API. + #### Required properties You can make a property required by the constructor either by: