From b415cbeb472705ee2ff7a5796b0c693879097b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:45:34 +0000 Subject: [PATCH 1/3] fix: report a deprecated function passed as an argument igraph functions that take another function as an argument call it on the user's behalf. lifecycle attributes a deprecation to whoever called the deprecated function, and that caller is igraph, not the user: `deprecate_soft()` stays silent, and `deprecate_warn()` blames igraph and asks the user to report a bug against it. Either way `plot(g, layout = layout.circle)` never tells the user that the layout function they passed is deprecated, until the deprecation becomes hard and the call fails outright. `check_deprecated_function()` reads the deprecation off the function object and replays it against the environment igraph was called from, with the same signaller and the same message, so lifecycle recognizes the deprecation the function goes on to signal as the one already reported and does not repeat it. Every argument that takes a function now goes through the check: plotting parameters, layouts, vertex shapes, attribute combinations, scan statistics, printer callbacks and the search callbacks. `layout.spring()`, `layout.svd()` and `layout.fruchterman.reingold.grid()` are deprecated with a warning again, and lay out with `layout_with_fr()` as before. They are layout callbacks, so their hard deprecation -- made after 2.3.3 and never released -- would break `plot(layout = )` for users who were never warned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJx1uH2GvN5DcqdVzDLvwW --- NEWS.md | 3 - R/attributes.R | 6 + R/cliques.R | 2 + R/community.R | 1 + R/cycles.R | 1 + R/layout.R | 11 +- R/motifs.R | 1 + R/plot.common.R | 1 + R/plot.shapes.R | 3 + R/printr.R | 9 +- R/scan.R | 1 + R/structural-properties.R | 3 + R/tkplot.R | 1 + R/topology.R | 2 + R/utils-deprecated.R | 215 ++++++++++++++++++++++ tests/testthat/_snaps/utils-deprecated.md | 9 + tests/testthat/test-utils-deprecated.R | 193 +++++++++++++++++++ 17 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 R/utils-deprecated.R create mode 100644 tests/testthat/_snaps/utils-deprecated.md create mode 100644 tests/testthat/test-utils-deprecated.R diff --git a/NEWS.md b/NEWS.md index a24bf34615d..3a9a942edeb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -899,9 +899,6 @@ - `layout.kamada.kawai()` is now deprecated with a warning instead of a message. - `layout.lgl()` is now deprecated with a warning instead of a message. - `layout_with_mds()` is now deprecated with a warning instead of a message. -- `layout.spring()` is now defunct (errors instead of warning). -- `layout.svd()` is now defunct (errors instead of warning). -- `layout.fruchterman.reingold.grid()` is now defunct (errors instead of warning). - `layout.drl()` is now deprecated with a warning instead of a message. - `nei()` has been removed. - `innei()` has been removed. diff --git a/R/attributes.R b/R/attributes.R index 13824155c1c..2a19c5ecaa3 100644 --- a/R/attributes.R +++ b/R/attributes.R @@ -1327,6 +1327,12 @@ igraph.i.attribute.combination <- function(comb, allow_rename = FALSE) { "Attribute combination element must be a function or character scalar." ) } + # A plain loop, not lapply(): check_deprecated_function() reads the call + # stack to find out who igraph was called by, and a functional in between + # would hide the caller behind a frame of its own. + for (element in comb) { + check_deprecated_function(element) + } if (is.null(names(comb))) { names(comb) <- rep("", length(comb)) } diff --git a/R/cliques.R b/R/cliques.R index 29fefbe4a65..43865d3cb28 100644 --- a/R/cliques.R +++ b/R/cliques.R @@ -257,6 +257,7 @@ clique.number <- function(graph) { cliques <- function(graph, min = NULL, max = NULL, ..., callback = NULL) { ensure_igraph(graph) check_dots_empty() + check_deprecated_function(callback) if (is.null(callback)) { # Collector mode: use original implementation @@ -308,6 +309,7 @@ max_cliques <- function( ) { ensure_igraph(graph) check_dots_empty() + check_deprecated_function(callback) # Handle file and subset modes (original functionality) if (!is.null(file)) { diff --git a/R/community.R b/R/community.R index 783f7676000..3e0edfb13b9 100644 --- a/R/community.R +++ b/R/community.R @@ -2562,6 +2562,7 @@ cluster_leading_eigen <- function( # Argument checks ensure_igraph(graph) + check_deprecated_function(callback) steps <- as.numeric(steps) if (is.null(weights) && "weight" %in% edge_attr_names(graph)) { diff --git a/R/cycles.R b/R/cycles.R index 6859cdf1f3f..e2587465334 100644 --- a/R/cycles.R +++ b/R/cycles.R @@ -166,6 +166,7 @@ simple_cycles <- function( # Argument checks ensure_igraph(graph) check_dots_empty() + check_deprecated_function(callback) if (is.null(callback)) { simple_cycles_impl( diff --git a/R/layout.R b/R/layout.R index 5c028fe8959..1f803bc0808 100644 --- a/R/layout.R +++ b/R/layout.R @@ -1181,6 +1181,7 @@ layout_nicely <- function(graph, dim = 2, ...) { lay <- graph_attr(graph, "layout") if (is.function(lay)) { if (!identical(lay, layout_nicely)) { + check_deprecated_function(lay) return(lay(graph, ...)) } else { # nop, we'll deal with it later below @@ -3409,6 +3410,7 @@ layout_components <- function(graph, layout = NULL, ...) { if (is.null(layout)) { layout <- layout_with_kk } + check_deprecated_function(layout) V(graph)$id <- seq(vcount(graph)) gl <- decompose(graph) @@ -3433,7 +3435,8 @@ layout_components <- function(graph, layout = NULL, ...) { #' @export #' @keywords internal layout.spring <- function(graph, ...) { - lifecycle::deprecate_stop("2.1.0", "layout.spring()", "layout_with_fr()") + lifecycle::deprecate_warn("2.1.0", "layout.spring()", "layout_with_fr()") + layout_with_fr(graph) } #' SVD layout, this was removed from igraph @@ -3450,7 +3453,8 @@ layout.spring <- function(graph, ...) { #' @keywords internal #' @export layout.svd <- function(graph, ...) { - lifecycle::deprecate_stop("2.1.0", "layout.svd()", "layout_with_fr()") + lifecycle::deprecate_warn("2.1.0", "layout.svd()", "layout_with_fr()") + layout_with_fr(graph) } #' Grid Fruchterman-Reingold layout, this was removed from igraph @@ -3468,11 +3472,12 @@ layout.svd <- function(graph, ...) { #' @keywords internal #' @export layout.fruchterman.reingold.grid <- function(graph, ...) { - lifecycle::deprecate_stop( + lifecycle::deprecate_warn( "2.1.0", "layout.fruchterman.reingold.grid()", "layout_with_fr()" ) + layout_with_fr(graph) } #' The DrL graph layout generator diff --git a/R/motifs.R b/R/motifs.R index 97a526b2905..b026131caf2 100644 --- a/R/motifs.R +++ b/R/motifs.R @@ -221,6 +221,7 @@ motifs <- function( if (!is.null(cut.prob) && length(cut.prob) != size) { cli::cli_abort("{.arg cut.prob} must be the same length as {.arg size}") } + check_deprecated_function(callback) # If callback is provided, use the callback implementation if (!is.null(callback)) { diff --git a/R/plot.common.R b/R/plot.common.R index 9fb2c5783f6..bb87cbce48a 100644 --- a/R/plot.common.R +++ b/R/plot.common.R @@ -552,6 +552,7 @@ i.parse.plot.params <- function(graph, params) { ret <- function() { v <- p[[type]][[name]] if (is.function(v) && !dontcall) { + check_deprecated_function(v) v <- v(graph) } if (is.null(range)) { diff --git a/R/plot.shapes.R b/R/plot.shapes.R index dc1eb672675..3e6d1708c80 100644 --- a/R/plot.shapes.R +++ b/R/plot.shapes.R @@ -459,6 +459,9 @@ add_shape <- function( )) } + check_deprecated_function(clip) + check_deprecated_function(plot) + assign(shape, value = list(clip = clip, plot = plot), envir = .igraph.shapes) do.call(igraph_options, parameters) invisible(TRUE) diff --git a/R/printr.R b/R/printr.R index 7f5294859a8..7804d1cf7c6 100644 --- a/R/printr.R +++ b/R/printr.R @@ -35,6 +35,7 @@ printer_callback <- function(fun) { if (!is.function(fun)) { warning("'fun' is not a function") } + check_deprecated_function(fun) add_class(fun, "printer_callback") } @@ -58,7 +59,12 @@ print_footer <- function(footer) { } print_head_foot <- function(head_foot) { - if (is.function(head_foot)) head_foot() else cat(head_foot) + if (is.function(head_foot)) { + check_deprecated_function(head_foot) + head_foot() + } else { + cat(head_foot) + } } #' Print the only the head of an R object @@ -199,6 +205,7 @@ indent_print <- function(..., .indent = " ", .printer = NULL) { if (is.null(.printer)) { .printer <- print } + check_deprecated_function(.printer) if (length(.indent) != 1 || !is.character(.indent)) { indent <- .indent # cli literal cannot start with a dot diff --git a/R/scan.R b/R/scan.R index a719650c8d1..8370c55d767 100644 --- a/R/scan.R +++ b/R/scan.R @@ -130,6 +130,7 @@ local_scan <- function( stopifnot( is.null(FUN) || is.function(FUN) || (is.character(FUN) && length(FUN) == 1) ) + check_deprecated_function(FUN) ## Logical stopifnot(is.logical(weighted), length(weighted) == 1) diff --git a/R/structural-properties.R b/R/structural-properties.R index c2970ebc5a9..4fd56c9f653 100644 --- a/R/structural-properties.R +++ b/R/structural-properties.R @@ -3795,6 +3795,7 @@ bfs <- function( } if (!is.null(callback)) { callback <- as.function(callback) + check_deprecated_function(callback) } on.exit(.Call(Rx_igraph_finalizer)) @@ -4058,9 +4059,11 @@ dfs <- function( unreachable <- as.logical(unreachable) if (!is.null(in.callback)) { in.callback <- as.function(in.callback) + check_deprecated_function(in.callback) } if (!is.null(out.callback)) { out.callback <- as.function(out.callback) + check_deprecated_function(out.callback) } on.exit(.Call(Rx_igraph_finalizer)) diff --git a/R/tkplot.R b/R/tkplot.R index 9342a284939..e7297045886 100644 --- a/R/tkplot.R +++ b/R/tkplot.R @@ -700,6 +700,7 @@ tk_center <- function(tkp.id) { #' @export tk_reshape <- function(tkp.id, newlayout, ..., params) { # nocov start + check_deprecated_function(newlayout) tkp <- .tkplot.get(tkp.id) new_coords <- do_call( newlayout, diff --git a/R/topology.R b/R/topology.R index 1069c076de5..3c4745508fd 100644 --- a/R/topology.R +++ b/R/topology.R @@ -1060,6 +1060,7 @@ isomorphisms <- function(graph1, graph2, method = "vf2", ..., callback = NULL) { "Only {.arg method} = {.val vf2} is currently supported." ) } + check_deprecated_function(callback) if (is.null(callback)) { graph.get.isomorphisms.vf2(graph1, graph2, ...) @@ -1191,6 +1192,7 @@ subgraph_isomorphisms <- function( "Callback parameter is only supported for {.arg method} = {.val vf2}." ) } + check_deprecated_function(callback) if (method == "lad") { graph.subisomorphic.lad(pattern, target, all.maps = TRUE, ...)$maps diff --git a/R/utils-deprecated.R b/R/utils-deprecated.R new file mode 100644 index 00000000000..5a33831b21e --- /dev/null +++ b/R/utils-deprecated.R @@ -0,0 +1,215 @@ +# Signal the deprecation of a function that was passed as an argument. +# +# Many igraph functions take another function as an argument and call it on the +# user's behalf, for instance the `layout` argument of `plot.igraph()` or the +# `clip` and `plot` arguments of `add_shape()`. +# lifecycle attributes a deprecation to whoever called the deprecated function, +# and that caller is igraph itself here, not the user: `deprecate_soft()` +# therefore stays silent, and `deprecate_warn()` blames igraph and asks the +# user to report a bug against it. +# Either way the user never learns that the function they passed is deprecated, +# until the deprecation becomes hard and the call fails outright. +# +# `check_deprecated_function()` inspects `fn` before it is called and replays +# its deprecation against the environment igraph was called from, so that a +# deprecated function is reported the same way whether the user calls it +# themselves or hands it to igraph. +# +# The deprecation is replayed with the same signaller and the same message as +# the original, which keeps lifecycle's deduplication working: the deprecation +# `fn` signals when it is subsequently called is recognized as the one already +# reported here, and is not repeated. +# +# Anything but a deprecated function, `NULL` included, is left alone. +# Returns `fn`, invisibly. +check_deprecated_function <- function(fn) { + deprecation <- function_deprecation(fn) + + if (!is.null(deprecation)) { + replay_deprecation(deprecation, env = environment(fn)) + } + + invisible(fn) +} + +# The deprecation a function signals for itself, as the `signaller` name and +# the `when`, `what`, `with` and `details` arguments it passes, or NULL if `fn` +# is not a deprecated function. +# +# Only unconditional, function-level deprecations are found, that is, a +# `lifecycle::deprecate_*()` call among the statements of the body of `fn` +# whose `what` names `fn` itself. A deprecation nested deeper in the body is +# conditional on the arguments of the call -- it deprecates an argument or one +# of its values, not the function -- and only the call itself can tell whether +# it applies. +function_deprecation <- function(fn) { + if (!is.function(fn) || is.primitive(fn)) { + return(NULL) + } + + # Functions that live outside a package cannot be deprecated with lifecycle: + # it derives the package to name in the message from the environment. + ns <- topenv(environment(fn)) + if (!isNamespace(ns)) { + return(NULL) + } + + for (statement in body_statements(fn)) { + deprecation <- as_deprecation(statement) + if (!is.null(deprecation) && deprecates(deprecation, fn, ns)) { + return(deprecation) + } + } + + NULL +} + +body_statements <- function(fn) { + body <- body(fn) + if (rlang::is_call(body, "{")) as.list(body)[-1] else list(body) +} + +deprecation_signallers <- c( + "deprecate_soft", + "deprecate_warn", + "deprecate_stop" +) + +# The deprecation described by a call, or NULL if it is not a +# `lifecycle::deprecate_*()` call with constant arguments. +as_deprecation <- function(statement) { + if (!is.call(statement)) { + return(NULL) + } + + signaller <- statement[[1]] + if (rlang::is_call(signaller, "::")) { + if (!identical(signaller[[2]], quote(lifecycle))) { + return(NULL) + } + signaller <- signaller[[3]] + } + if (!rlang::is_symbol(signaller)) { + return(NULL) + } + + signaller <- rlang::as_string(signaller) + if (!signaller %in% deprecation_signallers) { + return(NULL) + } + + # A call that does not fit the signature is none of lifecycle's, whatever it + # is named. + statement <- tryCatch( + match.call( + definition = utils::getFromNamespace(signaller, "lifecycle"), + call = statement + ), + error = function(e) NULL + ) + if (is.null(statement)) { + return(NULL) + } + + arguments <- lapply( + stats::setNames(nm = c("when", "what", "with", "details")), + function(name) constant_value(statement[[name]]) + ) + # A deprecation whose message is assembled at run time cannot be replayed + # from the body alone. + if (any(vapply(arguments, identical, logical(1), y = not_constant))) { + return(NULL) + } + + c(list(signaller = signaller), arguments) +} + +# Distinguishable from every constant, so that an argument that is absent +# (`with` and `details` are optional) is not mistaken for one that could not be +# evaluated. +not_constant <- new.env(parent = emptyenv()) + +constant_value <- function(expr) { + # Evaluating in the base environment resolves literals and calls such as + # `c("one", "other")`, and fails for anything that depends on the state of + # the deprecated function. + tryCatch(eval(expr, baseenv()), error = function(e) not_constant) +} + +# Does a deprecation found in the body of `fn` deprecate `fn` as a whole? +deprecates <- function(deprecation, fn, ns) { + what <- deprecation$what + if (!rlang::is_string(what)) { + return(FALSE) + } + + # Function-level deprecations spell the function out as a call without + # arguments, e.g. "layout.circle()"; argument-level ones name the argument + # inside the parentheses, e.g. "bfs(father = )". + if (!grepl("^[^(]+\\(\\)$", what)) { + return(FALSE) + } + name <- sub("\\(\\)$", "", what) + name <- sub("^.*::", "", name) + + # Guard against a wrapper that signals the deprecation of some *other* + # function, e.g. of the one it is the replacement for. + deprecated <- get0(name, envir = ns, inherits = FALSE) + is.null(deprecated) || identical(deprecated, fn) +} + +replay_deprecation <- function(deprecation, env) { + user_env <- igraph_user_env() + + switch( + deprecation$signaller, + deprecate_soft = lifecycle::deprecate_soft( + when = deprecation$when, + what = deprecation$what, + with = deprecation$with, + details = deprecation$details, + env = env, + user_env = user_env + ), + deprecate_warn = lifecycle::deprecate_warn( + when = deprecation$when, + what = deprecation$what, + with = deprecation$with, + details = deprecation$details, + env = env, + user_env = user_env + ), + deprecate_stop = lifecycle::deprecate_stop( + when = deprecation$when, + what = deprecation$what, + with = deprecation$with, + details = deprecation$details, + env = env + ) + ) +} + +# The environment igraph was called from: the innermost caller that does not +# belong to igraph itself. +# +# lifecycle needs it to tell a deprecation the user is responsible for from one +# that happens behind their back, and to name the package to blame in the +# latter case. Walking the caller chain keeps this independent of how deeply a +# deprecated argument is passed on inside igraph before it is looked at, which +# a fixed `rlang::caller_env(n)` would have to track. Callers must not reach +# the check through a functional such as `lapply()`, whose frame would end the +# walk in place of the user's. +igraph_user_env <- function() { + ns <- topenv(environment(igraph_user_env)) + + generation <- 1L + repeat { + env <- parent.frame(generation) + # parent.frame() bottoms out at the global environment, so the walk + # terminates there even if every frame belongs to igraph. + if (identical(env, globalenv()) || !identical(topenv(env), ns)) { + return(env) + } + generation <- generation + 1L + } +} diff --git a/tests/testthat/_snaps/utils-deprecated.md b/tests/testthat/_snaps/utils-deprecated.md new file mode 100644 index 00000000000..f43315d6268 --- /dev/null +++ b/tests/testthat/_snaps/utils-deprecated.md @@ -0,0 +1,9 @@ +# check_deprecated_function() reports a defunct function + + Code + check_deprecated_function(hub.score) + Condition + Error: + ! `hub.score()` was deprecated in igraph 2.0.0 and is now defunct. + i Please use `hits_scores()` instead. + diff --git a/tests/testthat/test-utils-deprecated.R b/tests/testthat/test-utils-deprecated.R new file mode 100644 index 00000000000..2c5f6893caa --- /dev/null +++ b/tests/testthat/test-utils-deprecated.R @@ -0,0 +1,193 @@ +# lifecycle reports a deprecation only once per session, but not inside the +# test suite of the deprecating package: there it treats igraph as the user +# (see setup-lifecycle.R), so a deprecated function igraph calls after the +# check has reported it warns a second time. Capturing every warning keeps +# that second one from escaping the test, and asserts that nothing else did. +expect_deprecation <- function(expr, regexp) { + warnings <- testthat::capture_warnings(expr) + expect_gt(length(warnings), 0) + expect_match(warnings, regexp, all = TRUE) +} + +test_that("function_deprecation() reads the deprecation a function signals", { + expect_equal( + function_deprecation(layout.circle), + list( + signaller = "deprecate_warn", + when = "2.1.0", + what = "layout.circle()", + with = "layout_in_circle()", + details = NULL + ) + ) +}) + +test_that("function_deprecation() ignores anything that is not a deprecated function", { + expect_null(function_deprecation(layout_in_circle)) + expect_null(function_deprecation(NULL)) + expect_null(function_deprecation("layout.circle")) + expect_null(function_deprecation(sum)) + expect_null(function_deprecation(function(graph) NULL)) +}) + +test_that("function_deprecation() ignores deprecations of an argument", { + # bfs() deprecates its `father` argument, conditionally on it being passed; + # bfs() itself is not deprecated. + expect_null(function_deprecation(bfs)) +}) + +test_that("check_deprecated_function() returns its argument invisibly", { + expect_invisible(out <- check_deprecated_function(layout_in_circle)) + expect_identical(out, layout_in_circle) +}) + +test_that("check_deprecated_function() reports a deprecated function", { + lifecycle::expect_deprecated( + check_deprecated_function(layout.circle), + "layout_in_circle" + ) +}) + +test_that("check_deprecated_function() reports a defunct function", { + expect_snapshot(error = TRUE, { + check_deprecated_function(hub.score) + }) +}) + +test_that("igraph_user_env() skips igraph's own frames", { + igraph_frame <- function() igraph_user_env() + environment(igraph_frame) <- asNamespace("igraph") + + caller <- environment() + expect_identical(igraph_frame(), caller) +}) + +# ---- deprecated functions passed as arguments ------------------------- + +test_that("plot() reports a deprecated layout function", { + g <- make_ring(5) + withr::local_pdf(NULL) + + expect_deprecation(plot(g, layout = layout.circle), "layout_in_circle") +}) + +test_that("plot() reports a deprecated layout graph attribute", { + g <- make_ring(5) + g$layout <- layout.random + withr::local_pdf(NULL) + + expect_deprecation(plot(g), "layout_randomly") +}) + +test_that("plot() is silent about a current layout function", { + g <- make_ring(5) + withr::local_pdf(NULL) + + expect_no_condition(plot(g, layout = layout_in_circle)) +}) + +test_that("layout_nicely() reports a deprecated layout graph attribute", { + g <- make_ring(5) + g$layout <- layout.circle + + expect_deprecation(layout_nicely(g), "layout_in_circle") +}) + +test_that("layout_components() reports a deprecated layout function", { + g <- make_ring(5) + make_ring(4) + + expect_deprecation(layout_components(g, layout.circle), "layout_in_circle") +}) + +test_that("add_shape() reports deprecated clip and plot functions", { + expect_deprecation( + add_shape("test-clip", clip = igraph.shape.noclip), + "shape_noclip" + ) + expect_deprecation( + add_shape("test-plot", plot = igraph.shape.noplot), + "shape_noplot" + ) +}) + +test_that("local_scan() reports a deprecated FUN", { + g <- make_ring(5) + + expect_deprecation(local_scan(g, FUN = graph.density), "edge_density") +}) + +test_that("attribute combinations report a deprecated function", { + expect_deprecation( + igraph.i.attribute.combination(list(weight = graph.density)), + "edge_density" + ) +}) + +test_that("callbacks report a deprecated function", { + # A graph without cliques, so that the callback is reported but never called. + g <- make_empty_graph(0) + + expect_deprecation(cliques(g, callback = graph.density), "edge_density") +}) + +# ---- deprecation levels are preserved ---------------------------------- + +# A function that igraph deprecates, without the noise of one that also does +# something. Its name is what tells lifecycle's deduplication one deprecation +# apart from the other, so each test needs its own. +deprecated_igraph_function <- function(what, signaller = "deprecate_warn") { + rlang::new_function( + args = NULL, + body = rlang::call2(signaller, "2.0.0", what, .ns = "lifecycle"), + env = asNamespace("igraph") + ) +} + +test_that("a soft deprecation is only reported to the user that caused it", { + # Called from the global environment, as a test is, this is the user's doing. + lifecycle::expect_deprecated( + check_deprecated_function( + deprecated_igraph_function("soft_direct()", "deprecate_soft") + ) + ) + + # From another package it is not, and stays silent for lack of an audience. + fn <- deprecated_igraph_function("soft_indirect()", "deprecate_soft") + other_pkg <- function() check_deprecated_function(fn) + environment(other_pkg) <- rlang::env( + asNamespace("stats"), + fn = fn, + check_deprecated_function = check_deprecated_function + ) + expect_no_warning(other_pkg()) +}) + +test_that("a deprecation is reported once, not once per call site", { + fn <- deprecated_igraph_function("reported_once()") + + # The check replays the deprecation of `fn` verbatim, so that lifecycle + # recognizes the one `fn` signals when it is called next as a repeat. + # Deduplication is what a user sees; the test suite of the deprecating + # package is exempt from it, hence the explicit verbosity. + rlang::local_options(lifecycle_verbosity = "default") + expect_warning(check_deprecated_function(fn), "reported_once") + expect_no_warning(fn()) +}) + +# ---- un-deprecated layout callbacks ------------------------------------ + +test_that("the layout functions that plot() may be handed still work", { + g <- make_ring(5) + + expect_deprecation(coords <- layout.spring(g), "layout_with_fr") + expect_equal(dim(coords), c(5, 2)) + + expect_deprecation(coords <- layout.svd(g), "layout_with_fr") + expect_equal(dim(coords), c(5, 2)) + + expect_deprecation( + coords <- layout.fruchterman.reingold.grid(g), + "layout_with_fr" + ) + expect_equal(dim(coords), c(5, 2)) +}) From cd9fa09076af9b22a366dff475386235e3c4df6c Mon Sep 17 00:00:00 2001 From: krlmlr Date: Sun, 16 Aug 2026 09:56:25 +0000 Subject: [PATCH 2/3] chore: Auto-update from GitHub Actions Run: https://github.com/igraph/rigraph/actions/runs/31939918812 --- man/layout.fruchterman.reingold.grid.Rd | 4 ++++ man/layout.spring.Rd | 4 ++++ man/layout.svd.Rd | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/man/layout.fruchterman.reingold.grid.Rd b/man/layout.fruchterman.reingold.grid.Rd index 67368c0ed9a..c9bd2d65c89 100644 --- a/man/layout.fruchterman.reingold.grid.Rd +++ b/man/layout.fruchterman.reingold.grid.Rd @@ -19,4 +19,8 @@ Layout coordinates, a two column matrix. Now it calls the Fruchterman-Reingold layout \code{\link[=layout_with_fr]{layout_with_fr()}}. } +\section{Related documentation in the C library}{ +\href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_vcount}{\code{vcount()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_edges}{\code{edges()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_get_eids}{\code{get_eids()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_ecount}{\code{ecount()}} +} + \keyword{internal} diff --git a/man/layout.spring.Rd b/man/layout.spring.Rd index b87db5942d6..b876cffadcd 100644 --- a/man/layout.spring.Rd +++ b/man/layout.spring.Rd @@ -19,4 +19,8 @@ Layout coordinates, a two column matrix. Now it calls the Fruchterman-Reingold layout \code{\link[=layout_with_fr]{layout_with_fr()}}. } +\section{Related documentation in the C library}{ +\href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_vcount}{\code{vcount()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_edges}{\code{edges()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_get_eids}{\code{get_eids()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_ecount}{\code{ecount()}} +} + \keyword{internal} diff --git a/man/layout.svd.Rd b/man/layout.svd.Rd index c83ee8cc0a7..08f44ed6d11 100644 --- a/man/layout.svd.Rd +++ b/man/layout.svd.Rd @@ -19,4 +19,8 @@ Layout coordinates, a two column matrix. Now it calls the Fruchterman-Reingold layout \code{\link[=layout_with_fr]{layout_with_fr()}}. } +\section{Related documentation in the C library}{ +\href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_vcount}{\code{vcount()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_edges}{\code{edges()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_get_eids}{\code{get_eids()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_ecount}{\code{ecount()}} +} + \keyword{internal} From 383f3c7954e4fc31b616a73cbb28fb85b8ca8f2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:38:24 +0000 Subject: [PATCH 3/3] test: assert the attribution, not just the warning The tests ran in an environment that belongs to igraph, and lifecycle exempts the test suite of the deprecating package from its "is this the user's doing?" question. Both made the tests pass whether or not the check reported anything. Call from an environment that belongs to no package, with the exemption cleared, and assert that the deprecation does not carry lifecycle's "likely used in the igraph package, please report the issue" footer -- which is what a user sees today, and the point of the exercise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJx1uH2GvN5DcqdVzDLvwW --- tests/testthat/test-utils-deprecated.R | 146 ++++++++++++++++++------- 1 file changed, 107 insertions(+), 39 deletions(-) diff --git a/tests/testthat/test-utils-deprecated.R b/tests/testthat/test-utils-deprecated.R index 2c5f6893caa..eedd1eff75c 100644 --- a/tests/testthat/test-utils-deprecated.R +++ b/tests/testthat/test-utils-deprecated.R @@ -1,12 +1,31 @@ -# lifecycle reports a deprecation only once per session, but not inside the -# test suite of the deprecating package: there it treats igraph as the user -# (see setup-lifecycle.R), so a deprecated function igraph calls after the -# check has reported it warns a second time. Capturing every warning keeps -# that second one from escaping the test, and asserts that nothing else did. +# Call `fn(...)` the way a user would. +# +# Two things stand between a test and what a user sees. The environment a test +# runs in belongs to igraph, and lifecycle exempts the test suite of the +# deprecating package from its "is this the user's doing?" question, treating +# igraph's own frames as direct (see setup-lifecycle.R). Both would make these +# tests pass whether or not the check reports anything. So call from an +# environment that belongs to no package, with the exemption cleared. +as_user <- function(fn, ...) { + withr::local_envvar(TESTTHAT_PKG = "") + user <- rlang::new_function( + args = rlang::pairlist2(... = ), + body = quote(fn(...)), + env = rlang::env(globalenv(), fn = fn) + ) + user(...) +} + +# The deprecated function warns again when igraph goes on to call it, unless +# lifecycle recognizes the repeat. Capturing every warning keeps a second one +# from escaping the test, and asserts that nothing else did. expect_deprecation <- function(expr, regexp) { warnings <- testthat::capture_warnings(expr) expect_gt(length(warnings), 0) expect_match(warnings, regexp, all = TRUE) + # The point of the exercise: lifecycle appends this to a deprecation it holds + # igraph responsible for, and asks the user to report a bug about it. + expect_no_match(warnings, "likely used in") } test_that("function_deprecation() reads the deprecation a function signals", { @@ -68,7 +87,10 @@ test_that("plot() reports a deprecated layout function", { g <- make_ring(5) withr::local_pdf(NULL) - expect_deprecation(plot(g, layout = layout.circle), "layout_in_circle") + expect_deprecation( + as_user(plot, g, layout = layout.circle), + "layout_in_circle" + ) }) test_that("plot() reports a deprecated layout graph attribute", { @@ -76,7 +98,7 @@ test_that("plot() reports a deprecated layout graph attribute", { g$layout <- layout.random withr::local_pdf(NULL) - expect_deprecation(plot(g), "layout_randomly") + expect_deprecation(as_user(plot, g), "layout_randomly") }) test_that("plot() is silent about a current layout function", { @@ -90,22 +112,25 @@ test_that("layout_nicely() reports a deprecated layout graph attribute", { g <- make_ring(5) g$layout <- layout.circle - expect_deprecation(layout_nicely(g), "layout_in_circle") + expect_deprecation(as_user(layout_nicely, g), "layout_in_circle") }) test_that("layout_components() reports a deprecated layout function", { g <- make_ring(5) + make_ring(4) - expect_deprecation(layout_components(g, layout.circle), "layout_in_circle") + expect_deprecation( + as_user(layout_components, g, layout.circle), + "layout_in_circle" + ) }) test_that("add_shape() reports deprecated clip and plot functions", { expect_deprecation( - add_shape("test-clip", clip = igraph.shape.noclip), + as_user(add_shape, "test-clip", clip = igraph.shape.noclip), "shape_noclip" ) expect_deprecation( - add_shape("test-plot", plot = igraph.shape.noplot), + as_user(add_shape, "test-plot", plot = igraph.shape.noplot), "shape_noplot" ) }) @@ -113,13 +138,21 @@ test_that("add_shape() reports deprecated clip and plot functions", { test_that("local_scan() reports a deprecated FUN", { g <- make_ring(5) - expect_deprecation(local_scan(g, FUN = graph.density), "edge_density") + expect_deprecation( + as_user(local_scan, g, FUN = graph.density), + "edge_density" + ) }) test_that("attribute combinations report a deprecated function", { + g <- make_graph(c(1, 2, 1, 2)) + E(g)$weight <- c(1, 2) + + # `is.igraph()` is not a meaningful combiner, but it is softly deprecated and + # accepts the vector of attribute values that a combiner is handed. expect_deprecation( - igraph.i.attribute.combination(list(weight = graph.density)), - "edge_density" + as_user(simplify, g, edge.attr.comb = list(weight = is.igraph)), + "is_igraph" ) }) @@ -127,7 +160,10 @@ test_that("callbacks report a deprecated function", { # A graph without cliques, so that the callback is reported but never called. g <- make_empty_graph(0) - expect_deprecation(cliques(g, callback = graph.density), "edge_density") + expect_deprecation( + as_user(cliques, g, callback = graph.density), + "edge_density" + ) }) # ---- deprecation levels are preserved ---------------------------------- @@ -135,43 +171,75 @@ test_that("callbacks report a deprecated function", { # A function that igraph deprecates, without the noise of one that also does # something. Its name is what tells lifecycle's deduplication one deprecation # apart from the other, so each test needs its own. -deprecated_igraph_function <- function(what, signaller = "deprecate_warn") { +deprecated_igraph_function <- function(what, signaller = "deprecate_soft") { rlang::new_function( - args = NULL, + args = rlang::pairlist2(... = ), body = rlang::call2(signaller, "2.0.0", what, .ns = "lifecycle"), env = asNamespace("igraph") ) } -test_that("a soft deprecation is only reported to the user that caused it", { - # Called from the global environment, as a test is, this is the user's doing. - lifecycle::expect_deprecated( - check_deprecated_function( - deprecated_igraph_function("soft_direct()", "deprecate_soft") - ) +# An igraph function that calls a function argument, with and without the check +# under test. +igraph_function <- function(checked) { + fn <- if (checked) { + function(callback, ...) { + check_deprecated_function(callback) + callback(...) + } + } else { + function(callback, ...) callback(...) + } + environment(fn) <- asNamespace("igraph") + fn +} + +test_that("a soft deprecation reaches the user that caused it", { + # Silent as things stand: lifecycle sees an igraph frame as the caller, and + # does not report a package's own use of a softly deprecated function. + expect_no_warning( + as_user(igraph_function(FALSE), deprecated_igraph_function("soft_silent()")) ) - # From another package it is not, and stays silent for lack of an audience. - fn <- deprecated_igraph_function("soft_indirect()", "deprecate_soft") - other_pkg <- function() check_deprecated_function(fn) - environment(other_pkg) <- rlang::env( + expect_deprecation( + as_user(igraph_function(TRUE), deprecated_igraph_function("soft_heard()")), + "soft_heard" + ) +}) + +test_that("a soft deprecation stays silent for another package", { + fn <- deprecated_igraph_function("soft_other_package()") + call_igraph <- function() igraph_function(TRUE)(fn) + environment(call_igraph) <- rlang::env( asNamespace("stats"), fn = fn, - check_deprecated_function = check_deprecated_function + igraph_function = igraph_function ) - expect_no_warning(other_pkg()) -}) -test_that("a deprecation is reported once, not once per call site", { - fn <- deprecated_igraph_function("reported_once()") + withr::local_envvar(TESTTHAT_PKG = "") + expect_no_warning(call_igraph()) +}) - # The check replays the deprecation of `fn` verbatim, so that lifecycle - # recognizes the one `fn` signals when it is called next as a repeat. - # Deduplication is what a user sees; the test suite of the deprecating - # package is exempt from it, hence the explicit verbosity. - rlang::local_options(lifecycle_verbosity = "default") - expect_warning(check_deprecated_function(fn), "reported_once") - expect_no_warning(fn()) +test_that("a warning deprecation stops blaming igraph for the user's choice", { + blamed <- testthat::capture_warnings( + as_user( + igraph_function(FALSE), + deprecated_igraph_function("warn_blamed()", "deprecate_warn") + ) + ) + expect_match(blamed, "likely used in the .*igraph.* package") + + # Reported once, not twice: the check replays the deprecation of the function + # verbatim, so lifecycle recognizes the one the function itself signals when + # it is called next as a repeat. + attributed <- testthat::capture_warnings( + as_user( + igraph_function(TRUE), + deprecated_igraph_function("warn_attributed()", "deprecate_warn") + ) + ) + expect_length(attributed, 1) + expect_no_match(attributed, "likely used in") }) # ---- un-deprecated layout callbacks ------------------------------------