diff --git a/NAMESPACE b/NAMESPACE index 8f9fa4c31..f45607658 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,8 @@ S3method("|",S7_class) S3method(Ops,S7_object) S3method(Ops,S7_super) S3method(c,S7_class) +S3method(format,S7_signature) +S3method(format,S7_signature_list) S3method(print,S7_S3_class) S3method(print,S7_any) S3method(print,S7_base_class) @@ -21,6 +23,8 @@ S3method(print,S7_method) S3method(print,S7_missing) S3method(print,S7_object) S3method(print,S7_property) +S3method(print,S7_signature) +S3method(print,S7_signature_list) S3method(print,S7_super) S3method(print,S7_union) S3method(str,S7_S3_class) @@ -40,9 +44,12 @@ export("props<-") export(S4_register) export(S7_class) export(S7_class_desc) +export(S7_classes) export(S7_data) export(S7_dispatch) +export(S7_generics) export(S7_inherits) +export(S7_methods) export(S7_object) export(S7_on_build) export(S7_on_load) diff --git a/NEWS.md b/NEWS.md index 0f174ea02..3eff5e79f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -29,6 +29,7 @@ * `prop()` no longer leaves an object in a broken state when a custom getter signals an error (#520, #640, #638). * `prop<-()` no longer fails when assigning a call or symbol to a property (#511, #633, #638). * New `prop_info()` returns a data frame summarising the properties of an S7 object or class, with one row per property and columns for name, default, class, getter, setter, and validator (#551). +* New `S7_classes()`, `S7_generics()`, and `S7_methods()` introspection helpers. `S7_classes()` and `S7_generics()` list the S7 classes / generics defined in a given environment/package (#335). `S7_methods()` list methods methods registered on a generic or all methods associated with a class (across generics in attached packages) (#435). * `S7_dispatch()` now gives a clear error when called from a function that is not an S7 generic, e.g. `unclass(generic)()`, instead of failing with a confusing message (#684). * `S7_class()` now returns a class specification for any R object, not just S7 objects. It returns the matching `class_*` for base types, a `new_S3_class()` wrapper for S3 objects, and the S4 class for S4 objects, so the result can be passed directly to `method()` or other S7 dispatch helpers (#559). * `S7_class_desc()` is a new exported helper that formats a class specification as a short human-readable string (#594). diff --git a/R/introspect.R b/R/introspect.R new file mode 100644 index 000000000..243942a27 --- /dev/null +++ b/R/introspect.R @@ -0,0 +1,155 @@ +#' Find S7 classes and generics in an environment +#' +#' @description +#' * `S7_classes()` returns the names of S7 classes defined in `env`. +#' * `S7_generics()` returns the names of S7 generics defined in `env`. +#' +#' @param env An environment. Defaults to the caller's environment. +#' To inspect a package, pass `asNamespace("pkg")`; to inspect the global +#' environment, pass `globalenv()`. +#' @returns A character vector of names. +#' @export +#' @examples +#' # List S7 classes exported by the S7 package itself +#' S7_classes(asNamespace("S7")) +#' S7_generics(asNamespace("S7")) +S7_classes <- function(env = parent.frame()) { + find_objects(env, is_class) +} + +#' @export +#' @rdname S7_classes +S7_generics <- function(env = parent.frame()) { + find_objects(env, is_S7_generic) +} + +#' List S7 methods +#' +#' List the methods registered on an S7 `generic`, or the methods registered +#' for a given `class` across all S7 generics defined in attached packages. +#' +#' @param generic An S7 generic. +#' @param class A class specification (anything accepted by [as_class()]). +#' When supplied, every S7 generic in every attached package is searched +#' for methods with this class in their signature. +#' @returns A data frame with one row per matching method and columns: +#' +#' * `generic`: the generic's name. +#' * `package`: the package the generic is defined in, or `NA` for generics +#' found in the global environment (or when `generic` is supplied +#' directly). +#' * `signature`: a list column of `S7_signature` objects describing the +#' dispatch signature. `format()` them for a human-readable description. +#' @export +#' @examples +#' Foo <- new_class("Foo", package = NULL) +#' Bar <- new_class("Bar", package = NULL) +#' my_gen <- new_generic("my_gen", "x") +#' method(my_gen, Foo) <- function(x) "foo" +#' method(my_gen, Bar) <- function(x) "bar" +#' +#' S7_methods(generic = my_gen) +#' S7_methods(class = Foo) +S7_methods <- function(generic = NULL, class = NULL) { + if (!is.null(generic)) { + if (!is_S7_generic(generic)) { + stop("`generic` must be an S7 generic.") + } + generics <- list(list(generic = generic, package = NA_character_)) + } else { + generics <- attached_generics() + } + + if (!is.null(class)) { + target <- class_register(as_class(class)) + } else { + target <- NULL + } + + rows <- lapply(generics, function(g) { + generic_method_rows(g$generic, g$package, target) + }) + out <- do.call(rbind, rows) + out$signature <- new_signature_list(out$signature) + out +} + +generic_method_rows <- function(generic, package, class) { + ms <- methods(generic) + if (!is.null(class)) { + has_class <- function(m) any(vcapply(m@signature, class_register) == class) + ms <- Filter(has_class, ms) + } + + data.frame( + generic = rep(generic@name, length(ms)), + package = rep(package, length(ms)), + signature = I(lapply(ms, \(m) new_signature(m@signature))) + ) +} + +# A list column of S7_signatures, used by S7_methods(). Needs its own class +# so that print.data.frame() formats it per-element: data frames format whole +# columns, so the scalar format.S7_signature() method is never reached. +new_signature_list <- function(x) { + class(x) <- "S7_signature_list" + x +} + +#' @export +format.S7_signature_list <- function(x, ...) { + vcapply(unclass(x), format) +} + +#' @export +print.S7_signature_list <- function(x, ...) { + print(format(x), quote = FALSE) + invisible(x) +} + +# All S7 generics reachable from attached packages and the global env, +# each tagged with the package it was found in (`NA` for the global env). +attached_generics <- function() { + out <- list() + for (env in attached_envs()) { + package <- env_package(env) + for (generic in unname(find_matches(env, is_S7_generic))) { + out[[length(out) + 1L]] <- list(generic = generic, package = package) + } + } + out +} + +env_package <- function(env) { + if (identical(env, globalenv())) { + NA_character_ + } else { + sub("^package:", "", environmentName(env)) + } +} + +attached_envs <- function() { + envs <- search() + pkgs <- envs[grepl("^package:", envs)] + pkgs <- setdiff(pkgs, "package:base") + + c(lapply(pkgs, as.environment), globalenv()) +} + +find_objects <- function(env, predicate) { + names(find_matches(env, predicate)) +} + +# Named list of objects in `env` satisfying `predicate`. +find_matches <- function(env, predicate) { + if (isNamespace(env)) { + # Not attached; use exported values + names <- getNamespaceExports(env) + } else { + # Attached or global; use all values + names <- ls(envir = env) + } + + objs <- mget(names, envir = env, inherits = FALSE) + Filter(predicate, objs) +} diff --git a/R/method-register.R b/R/method-register.R index 7afeff0d6..6d2ee4c32 100644 --- a/R/method-register.R +++ b/R/method-register.R @@ -239,6 +239,17 @@ new_signature <- function(x) { x } +#' @export +format.S7_signature <- function(x, ...) { + paste0(vcapply(unclass(x), class_desc), collapse = ", ") +} + +#' @export +print.S7_signature <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + check_method <- function( method, generic, diff --git a/_pkgdown.yml b/_pkgdown.yml index cdd46c2fd..8f091ee1f 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -35,6 +35,8 @@ reference: - method_explain - S7_class - S7_class_desc + - S7_classes + - S7_methods - title: Packages desc: > diff --git a/man/S7_classes.Rd b/man/S7_classes.Rd new file mode 100644 index 000000000..facfb7fc4 --- /dev/null +++ b/man/S7_classes.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/introspect.R +\name{S7_classes} +\alias{S7_classes} +\alias{S7_generics} +\title{Find S7 classes and generics in an environment} +\usage{ +S7_classes(env = parent.frame()) + +S7_generics(env = parent.frame()) +} +\arguments{ +\item{env}{An environment. Defaults to the caller's environment. +To inspect a package, pass \code{asNamespace("pkg")}; to inspect the global +environment, pass \code{globalenv()}.} +} +\value{ +A character vector of names. +} +\description{ +\itemize{ +\item \code{S7_classes()} returns the names of S7 classes defined in \code{env}. +\item \code{S7_generics()} returns the names of S7 generics defined in \code{env}. +} +} +\examples{ +# List S7 classes exported by the S7 package itself +S7_classes(asNamespace("S7")) +S7_generics(asNamespace("S7")) +} diff --git a/man/S7_methods.Rd b/man/S7_methods.Rd new file mode 100644 index 000000000..810b12b0e --- /dev/null +++ b/man/S7_methods.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/introspect.R +\name{S7_methods} +\alias{S7_methods} +\title{List S7 methods} +\usage{ +S7_methods(generic = NULL, class = NULL) +} +\arguments{ +\item{generic}{An S7 generic.} + +\item{class}{A class specification (anything accepted by \code{\link[=as_class]{as_class()}}). +When supplied, every S7 generic in every attached package is searched +for methods with this class in their signature.} +} +\value{ +A data frame with one row per matching method and columns: +\itemize{ +\item \code{generic}: the generic's name. +\item \code{package}: the package the generic is defined in, or \code{NA} for generics +found in the global environment (or when \code{generic} is supplied +directly). +\item \code{signature}: a list column of \code{S7_signature} objects describing the +dispatch signature. \code{format()} them for a human-readable description. +} +} +\description{ +List the methods registered on an S7 \code{generic}, or the methods registered +for a given \code{class} across all S7 generics defined in attached packages. +} +\examples{ +Foo <- new_class("Foo", package = NULL) +Bar <- new_class("Bar", package = NULL) +my_gen <- new_generic("my_gen", "x") +method(my_gen, Foo) <- function(x) "foo" +method(my_gen, Bar) <- function(x) "bar" + +S7_methods(generic = my_gen) +S7_methods(class = Foo) +} diff --git a/tests/testthat/_snaps/introspect.md b/tests/testthat/_snaps/introspect.md new file mode 100644 index 000000000..40e3b9ad4 --- /dev/null +++ b/tests/testthat/_snaps/introspect.md @@ -0,0 +1,17 @@ +# S7_methods() prints the signature column readably + + Code + print(S7_methods(generic = gen)) + Output + generic package signature + 1 gen + 2 gen + +# S7_methods() validates inputs + + Code + S7_methods(generic = "not a generic") + Condition + Error in `S7_methods()`: + ! `generic` must be an S7 generic. + diff --git a/tests/testthat/_snaps/method-register.md b/tests/testthat/_snaps/method-register.md index 0b971e640..64c04221c 100644 --- a/tests/testthat/_snaps/method-register.md +++ b/tests/testthat/_snaps/method-register.md @@ -69,6 +69,13 @@ Error: ! `signature` must be length 2. +# S7_signature has format and print methods + + Code + print(sig) + Output + , + # check_method complains if the functions are not compatible Code diff --git a/tests/testthat/test-introspect.R b/tests/testthat/test-introspect.R new file mode 100644 index 000000000..d3f756d3a --- /dev/null +++ b/tests/testthat/test-introspect.R @@ -0,0 +1,123 @@ +test_that("S7_classes() / S7_generics() inspect a single environment", { + # Namespace: restricted to exports + expect_equal(S7_classes(asNamespace("S7")), "S7_object") + expect_equal(S7_generics(asNamespace("S7")), "convert") +}) + +test_that("default `env` is the caller's environment", { + local({ + Foo <- new_class("Foo", package = NULL) + Bar <- new_class("Bar", package = NULL) + my_gen <- new_generic("my_gen", "x") + + expect_setequal(S7_classes(), c("Foo", "Bar")) + expect_setequal(S7_generics(), "my_gen") + }) + + expect_setequal(S7_classes(), character()) + expect_setequal(S7_generics(), character()) +}) + +test_that("S7_methods(generic) lists registered methods", { + Foo <- new_class("Foo", package = NULL) + Bar <- new_class("Bar", package = NULL) + gen <- new_generic("gen", "x") + method(gen, Foo) <- function(x) "foo" + method(gen, Bar) <- function(x) "bar" + + res <- S7_methods(generic = gen) + expect_s3_class(res, "data.frame") + expect_named(res, c("generic", "package", "signature")) + expect_equal(res$generic, c("gen", "gen")) + expect_setequal(vcapply(res$signature, format), c("", "")) +}) + +test_that("S7_methods() prints the signature column readably", { + Foo <- new_class("Foo", package = NULL) + Bar <- new_class("Bar", package = NULL) + gen <- new_generic("gen", "x") + method(gen, Foo) <- function(x) "foo" + method(gen, Bar) <- function(x) "bar" + + expect_snapshot(print(S7_methods(generic = gen))) +}) + +test_that("S7_signature_list formats per element", { + foo <- new_generic("foo", c("x", "y")) + sigs <- new_signature_list(list( + as_signature(list(class_integer, class_character), foo), + as_signature(list(class_double, class_logical), foo) + )) + + expect_equal( + format(sigs), + c(", ", ", ") + ) +}) + +test_that("S7_methods(generic) handles multi-dispatch", { + Foo <- new_class("Foo", package = NULL) + Bar <- new_class("Bar", package = NULL) + gen <- new_generic("gen", c("x", "y")) + method(gen, list(Foo, Bar)) <- function(x, y) "fb" + + res <- S7_methods(generic = gen) + expect_equal(vcapply(res$signature, format), ", ") +}) + +test_that("S7_methods(generic) returns empty df when no methods", { + gen <- new_generic("gen", "x") + res <- S7_methods(generic = gen) + expect_s3_class(res, "data.frame") + expect_equal(nrow(res), 0) + expect_named(res, c("generic", "package", "signature")) +}) + +test_that("S7_methods(class) scans attached generics", { + Foo <- new_class("Foo", package = NULL) + Bar <- new_class("Bar", package = NULL) + g1 <- new_generic("S7_introspect_g1_xyzzy", "x") + g2 <- new_generic("S7_introspect_g2_xyzzy", "x") + method(g1, Foo) <- function(x) "foo" + method(g2, Bar) <- function(x) "bar" + + assign("S7_introspect_g1_xyzzy", g1, envir = globalenv()) + assign("S7_introspect_g2_xyzzy", g2, envir = globalenv()) + defer(rm( + list = c("S7_introspect_g1_xyzzy", "S7_introspect_g2_xyzzy"), + envir = globalenv() + )) + + res <- S7_methods(class = Foo) + expect_true("S7_introspect_g1_xyzzy" %in% res$generic) + expect_false("S7_introspect_g2_xyzzy" %in% res$generic) + expect_equal( + res$package[res$generic == "S7_introspect_g1_xyzzy"], + NA_character_ + ) +}) + +test_that("S7_methods() reports the generic's package", { + Foo <- new_class("Foo", package = NULL) + gen <- new_generic("gen", "x") + method(gen, Foo) <- function(x) "foo" + + res <- S7_methods(generic = gen) + expect_equal(res$package, NA_character_) +}) + +test_that("S7_methods() validates inputs", { + expect_snapshot(error = TRUE, { + S7_methods(generic = "not a generic") + }) +}) + +test_that("find_objects() returns matching names", { + env <- new.env(parent = emptyenv()) + env$Foo <- new_class("Foo", package = NULL) + env$bar <- 1L + env$Baz <- new_class("Baz", package = NULL) + + expect_setequal(find_objects(env, is_class), c("Foo", "Baz")) + expect_setequal(find_objects(env, is.integer), "bar") +}) diff --git a/tests/testthat/test-method-register.R b/tests/testthat/test-method-register.R index 993a160ff..ba910deac 100644 --- a/tests/testthat/test-method-register.R +++ b/tests/testthat/test-method-register.R @@ -175,6 +175,14 @@ describe("as_signature()", { }) }) +test_that("S7_signature has format and print methods", { + foo <- new_generic("foo", c("x", "y")) + sig <- as_signature(list(class_integer, class_character), foo) + + expect_equal(format(sig), ", ") + expect_snapshot(print(sig)) +}) + test_that("check_method returns TRUE if the functions are compatible", { foo := new_generic("x", function(x, ...) S7_dispatch()) expect_true(check_method(function(x, ...) x, foo))