Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move all plot watching logic into watchout #151

Merged
merged 11 commits into from
Jun 19, 2024
Merged
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# evaluate (development version)

* `watchout()` is no longer exported; it's really an implementation detail that should never have been leaked to the public interface.
* `evaluate()` gains an output class (`evaluate_evaluation`/`list`) and a basic print method.
* `evaluate()` now correctly captures plots created before messages/warnings/errors (#28).

Expand Down
35 changes: 7 additions & 28 deletions R/eval.R
Original file line number Diff line number Diff line change
Expand Up @@ -69,40 +69,18 @@ evaluate <- function(input,
}
local_inject_funs(envir)

if (new_device) {
# Ensure we have a graphics device available for recording, but choose
# one that's available on all platforms and doesn't write to disk
pdf(file = NULL)
dev.control(displaylist = "enable")
dev <- dev.cur()
on.exit(dev.off(dev))
}
# record the list of current devices
devs <- .env$dev_list; on.exit(.env$dev_list <- devs, add = TRUE)
devn <- length(.env$dev_list <- dev.list())
dev <- dev.cur()

# clean up the last_plot object after an evaluate() call (cf yihui/knitr#722)
on.exit(assign("last_plot", NULL, envir = environment(plot_snapshot)), add = TRUE)

# if this env var is set to true, always bypass messages
if (tolower(Sys.getenv('R_EVALUATE_BYPASS_MESSAGES')) == 'true')
keep_message = keep_warning = NA

# Capture output
watcher <- watchout(output_handler, debug = debug)
watcher <- watchout(output_handler, new_device = new_device, debug = debug)

out <- vector("list", nrow(parsed))
for (i in seq_along(out)) {
if (debug) {
message(parsed$src[[i]])
}

# if dev.off() was called, make sure to restore device to the one opened by
# evaluate() or existed before evaluate()
if (length(dev.list()) < devn) dev.set(dev)
devn <- length(dev.list())

out[[i]] <- evaluate_top_level_expression(
exprs = parsed$expr[[i]],
src = parsed$src[[i]],
Expand All @@ -117,6 +95,7 @@ evaluate <- function(input,
output_handler = output_handler,
include_timing = include_timing
)
watcher$check_devices()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: what does motivate the move for this refactored code after evaluate_top_level_expression ?

All tests are passing so seems good - but wanted to understand the thinking as I would have place it in the same place by precaution

Copy link
Member Author

@hadley hadley Jun 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over time, my plan is to move to more and more logic in to evaluate() so the logic is more clear. In this case specifically, I think it makes sense to check the devices after you've run the user code, rather than before. (Although the net effect is generally going to be the same.)


if (stop_on_error > 0L) {
errs <- vapply(out[[i]], is.error, logical(1))
Expand Down Expand Up @@ -154,12 +133,12 @@ evaluate_top_level_expression <- function(exprs,
source <- new_source(src, exprs[[1]], output_handler$source)
output <- list(source)

dev <- dev.cur()
handle_output <- function(plot = TRUE, incomplete_plots = FALSE) {
# if dev.cur() has changed, we should not record plots any more
plot <- plot && identical(dev, dev.cur())
out <- watcher(plot, incomplete_plots)
output <<- c(output, out)
out <- list(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this interface so it's likely to change in a future PR.

if (plot) watcher$capture_plot(incomplete_plots),
watcher$capture_output()
)
output <<- c(output, compact(out))
}

local_output_handler(function() handle_output(FALSE))
Expand Down
42 changes: 0 additions & 42 deletions R/graphics.R
Original file line number Diff line number Diff line change
@@ -1,45 +1,3 @@
#" Capture snapshot of current device.
#"
#" There's currently no way to capture when a graphics device changes,
#" except to check its contents after the evaluation of every expression.
#" This means that only the last plot of a series will be captured.
#"
#" @return \code{NULL} if plot is blank or unchanged, otherwise the output of
#" \code{\link{recordPlot}}.
plot_snapshot <- local({
last_plot <- NULL

function(incomplete = FALSE) {
devs <- dev.list()
# No graphics devices
if (is.null(devs)) {
return()
}

# Current graphics device changed since evaluate started
if (!identical(devs, .env$dev_list)) {
return()
}

# current page is incomplete
if (!par("page") && !incomplete) {
return()
}

plot <- recordPlot()
if (!makes_visual_change(plot[[1]])) {
return()
}

if (!looks_different(last_plot[[1]], plot[[1]])) {
return()
}

last_plot <<- plot
plot
}
})

looks_different <- function(old_dl, new_dl) {
if (identical(old_dl, new_dl)) {
return(FALSE)
Expand Down
91 changes: 71 additions & 20 deletions R/watcher.R
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
#' Watch for changes in output, text and graphics
#'
#' @param debug activate debug mode where output will be both printed to
#' screen and captured.
#' @param handler An ouptut handler object.
#' @param frame When this frame terminates, the watcher will automatically close.`
#' @return list containing four functions: `get_new`, `pause`,
#' `unpause`, `close`.
#' @keywords internal
watchout <- function(handler = new_output_handler(),
new_device = TRUE,
debug = FALSE,
frame = parent.frame()) {
last_plot <- NULL

if (new_device) {
# Ensure we have a graphics device available for recording, but choose
# one that's available on all platforms and doesn't write to disk
pdf(file = NULL)
dev.control(displaylist = "enable")
dev <- dev.cur()
defer(dev.off(dev), frame)
}

# record current devices
devs <- dev.list()
devn <- length(devs)
dev <- dev.cur()

con <- file("", "w+b")
defer(frame = frame, {
if (!test_con(con, isOpen)) {
Expand All @@ -24,20 +32,63 @@ watchout <- function(handler = new_output_handler(),
old <- options(try.outFile = con)
defer(options(old), frame = frame)

function(plot = TRUE, incomplete_plots = FALSE) {
out <- list(
if (plot) plot_snapshot(incomplete_plots),
read_con(con)
)
if (!is.null(out[[1]])) {
handler$graphics(out[[1]])
capture_plot <- function(incomplete = FALSE) {
# only record plots for our graphics device
if (!identical(dev.cur(), dev)) {
return()
}
# cur_devs <- dev.list()
# # No graphics devices
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cderv I left this code in but commented out to deliberately draw your attention to it. I'm pretty sure the logic is redundant (because if the device list has changed then that implies the current device has changed) and it doesn't cause any tests to fail. I think it was included because the logic was previously split over two functions and so it was harder to see that it was redundant. But it is a change and I wanted to double check that I didn't miss anything.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this seems redundant.

It seems all this have been introduced over time to fix different bugs. Some have tests that are passing, and other may not have test.

I am seeing that dev <- dev.cur() was previously called evaluate_top_level_expression() so for each parsed expression

evaluate/R/eval.R

Lines 157 to 163 in e8bd6f5

dev <- dev.cur()
handle_output <- function(plot = TRUE, incomplete_plots = FALSE) {
# if dev.cur() has changed, we should not record plots any more
plot <- plot && identical(dev, dev.cur())
out <- watcher(plot, incomplete_plots)
output <<- c(output, out)
}

Now we call it once when creating watcher with watchout() call, and the check in capture_plot() based on initial value.

I tried to see if this could cause problems, but it seems not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm reasonably certain that I got that logic right, and I added a few more tests to capture various situations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW in general if you're trying to find problems it's useful to consider the distinction between evaluate("plot(1); lines(2)") and evaluate("plot(1)\nlines(2)")

# if (is.null(cur_devs)) {
# return()
# }
# # Current graphics device changed since evaluate started
# if (!identical(cur_devs, devs)) {
# return()
# }

# current page is incomplete
if (!par("page") && !incomplete) {
return()
}

plot <- recordPlot()
if (!makes_visual_change(plot[[1]])) {
return()
}
if (!is.null(out[[2]])) {
handler$text(out[[2]])

if (!looks_different(last_plot[[1]], plot[[1]])) {
return()
}

last_plot <<- plot
handler$graphics(plot)
plot
}

capture_output <- function() {
out <- read_con(con)
if (!is.null(out)) {
handler$text(out)
}
out
}

check_devices <- function() {
# if dev.off() was called, make sure to restore device to the one opened
# when watchout() was called
if (length(dev.list()) < devn) {
dev.set(dev)
}

compact(out)
devn <<- length(dev.list())
invisible()
}

list(
capture_plot = capture_plot,
capture_output = capture_output,
check_devices = check_devices
)
}

read_con <- function(con, buffer = 32 * 1024) {
Expand Down
24 changes: 0 additions & 24 deletions man/watchout.Rd

This file was deleted.

Loading