From c2bc9a97e738bb52ad392c84ef90e7ca96b6ccc0 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Fri, 17 Sep 2021 12:55:36 +0100 Subject: [PATCH 01/14] First draft of auto-reload function --- R/autoreload.r | 137 +++++++++++++++++++++++++++++++++++++++++++++++++ R/loaded.r | 7 ++- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 R/autoreload.r diff --git a/R/autoreload.r b/R/autoreload.r new file mode 100644 index 00000000..4756560f --- /dev/null +++ b/R/autoreload.r @@ -0,0 +1,137 @@ +#' Auto-reloading of modules on change +#' +#' @usage \special{box::enable_autoreload(..., include, exclude, on_access = FALSE)} +#' @param ... ignored; present to force naming arguments +#' @param include vector of unevaluated, qualified module names to auto-reload +#' (optional) +#' @param exclude vector of unevaluates, qualified module names to auto-reload +#' (optional) +#' @param on_access logical value specifying whether to reload modules every +#' time they are used, or only when they are being loaded via \code{box::use}. +#' @return \code{enable_autoreload} is called for its side effect and does not +#' return a value. +#' @details +#' \code{include} and \code{exclude}, when given, are either single, +#' unevaluated, qualified module names (e.g. \code{./a}; \code{my/mod}) or +#' vectors of such module names (e.g. \code{c(./a, my/mod)}). +#' @name auto-reload +#' @export +enable_autoreload = function (..., include, exclude, on_access = FALSE) { + autoreload$init(on_access) + includes = spec_list(substitute(include)) + excludes = spec_list(substitute(exclude)) + caller = parent.frame() + map(autoreload$add_include, includes, list(caller)) + map(autoreload$add_exclude, excludes, list(caller)) + invisible() +} + +#' @rdname auto-reload +#' @export +disable_autoreload = function () { + autoreload$reset() + invisible() +} + +#' @rdname auto-reload +#' @export +autoreload_include = function (...) { + caller = parent.frame() + includes = match.call(expand.dots = FALSE)$... + map(autoreload$add_include, includes, list(caller)) + invisible() +} + +#' @rdname auto-reload +#' @export +autoreload_exclude = function (...) { + caller = parent.frame() + excludes = match.call(expand.dots = FALSE)$... + map(autoreload$add_exclude, excludes, list(caller)) + invisible() +} + +spec_list = function (specs) { + if (identical(specs, quote(expr =))) { + list() + } else if (is.call(specs) && identical(specs[[1L]], quote(c))) { + specs[-1L] + } else { + list(specs) + } +} + +autoreload = local({ + self = environment() + + init = function (on_access) { + reset() + if (on_access) { + throw('Not yet implemented') + } else { + self$is_mod_loaded = is_mod_loaded_reload + } + } + + reset = function () { + self$includes = character() + self$excludes = character() + self$is_mod_loaded = is_mod_loaded_basic + } + + add_include = function (spec, caller) { + spec = parse_spec(spec, '') + info = find_mod(spec, caller) + self$excludes = setdiff(self$excludes, info$source_path) + self$includes = c(self$includes, info$source_path) + } + + add_exclude = function (spec, caller) { + spec = parse_spec(spec, '') + info = find_mod(spec, caller) + self$includes = setdiff(self$includes, info$source_path) + self$excludes = c(self$excludes, info$source_path) + } + + included = function (info) { + path = info$source_path + + if (length(includes) == 0L) { + ! path %in% excludes + } else { + path %in% includes + } + } + + is_mod_loaded_basic = function (info) { + info$source_path %in% names(loaded_mods) + } + + is_mod_loaded_reload = function (info) { + is_mod_loaded_basic(info) && ! needs_reloading(info) + } + + needs_reloading = function (info) { + included(info) && is_file_modified(info) + } + + reset() + + self +}) + +add_timestamp = function (info) { + timestamp = file.mtime(info$source_path) + mod_timestamps[[info$source_path]] = timestamp +} + +remove_timestamp = function (info) { + rm(list = info$source_path, envir = mod_timestamps) +} + +is_file_modified = function (info) { + prev = mod_timestamps[[info$source_path]] + is.null(prev) || file.mtime(info$source_path) > prev +} + +mod_timestamps = new.env(parent = emptyenv()) diff --git a/R/loaded.r b/R/loaded.r index 39d99e67..508274ef 100644 --- a/R/loaded.r +++ b/R/loaded.r @@ -29,13 +29,18 @@ loaded_mods = new.env(parent = emptyenv()) #' @param info the mod info of a module #' @rdname loaded is_mod_loaded = function (info) { - info$source_path %in% names(loaded_mods) + autoreload$is_mod_loaded(info) } #' @param mod_ns module namespace environment #' @rdname loaded register_mod = function (info, mod_ns) { loaded_mods[[info$source_path]] = mod_ns + # The timestamp is saved *before* the source file is loaded to prevent race + # conditions in the presence of concurrent file modifications. + # At worst, this means loading the module redundantly in auto-reload mode. + # Doing it the other way round might cause file changes not to be noticed. + add_timestamp(info) attr(loaded_mods[[info$source_path]], 'loading') = TRUE } From 1d3a8d4e6f5907169aee677b5f38a398ce395cfa Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Fri, 17 Sep 2021 23:28:45 +0100 Subject: [PATCH 02/14] Remove obsolete code --- tests/testthat/test-reload.r | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/testthat/test-reload.r b/tests/testthat/test-reload.r index d04b3e8f..0b93388b 100644 --- a/tests/testthat/test-reload.r +++ b/tests/testthat/test-reload.r @@ -4,11 +4,6 @@ is_module_loaded = function (path) { path %in% names(box:::loaded_mods) } -unload_all = function () { - modenv = box:::loaded_mods - rm(list = names(modenv), envir = modenv) -} - tempfile_dir = function (...) { file = tempfile() dir.create(file) @@ -28,10 +23,6 @@ edit_nested_test_module = function (dir) { } test_that('module can be reloaded', { - # Required since other tests have side-effects. - # Tear-down would be helpful here, but not supported by testthat. - unload_all() - box::use(mod/a) expect_equal(length(box:::loaded_mods), 1L) counter = a$get_counter() From 060da851c75fac05118b23b020eeef88db3c533b Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Fri, 17 Sep 2021 23:30:02 +0100 Subject: [PATCH 03/14] Fix module inclusion logic --- R/autoreload.r | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/R/autoreload.r b/R/autoreload.r index 4756560f..b1101c7f 100644 --- a/R/autoreload.r +++ b/R/autoreload.r @@ -82,15 +82,25 @@ autoreload = local({ add_include = function (spec, caller) { spec = parse_spec(spec, '') info = find_mod(spec, caller) - self$excludes = setdiff(self$excludes, info$source_path) - self$includes = c(self$includes, info$source_path) + + if (length(self$includes) > 0L) { + self$includes = c(self$includes, info$source_path) + } else if (length(self$excludes) > 0L) { + self$excludes = setdiff(self$excludes, info$source_path) + } else { + self$includes = info$source_path + } } add_exclude = function (spec, caller) { spec = parse_spec(spec, '') info = find_mod(spec, caller) - self$includes = setdiff(self$includes, info$source_path) - self$excludes = c(self$excludes, info$source_path) + + if (length(self$includes) > 0L) { + self$includes = setdiff(self$includes, info$source_path) + } else { + self$excludes = c(self$excludes, info$source_path) + } } included = function (info) { From 08761a42e86317155ea137d9aec13e421863dd05 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Fri, 17 Sep 2021 23:57:37 +0100 Subject: [PATCH 04/14] Add unit tests --- tests/testthat/test-autoreload.r | 220 +++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/testthat/test-autoreload.r diff --git a/tests/testthat/test-autoreload.r b/tests/testthat/test-autoreload.r new file mode 100644 index 00000000..22e79fab --- /dev/null +++ b/tests/testthat/test-autoreload.r @@ -0,0 +1,220 @@ +context('autoreload') + +tempfile_dir = function (...) { + file = tempfile() + dir.create(file) + file +} + +create_simple_test_module = function (dir) { + mod = file.path(dir, 'mod') + dir.create(mod) + a = file.path(mod, 'a.r') + writeLines("#' @export\nf = function () 1L", a) +} + +edit_simple_test_module = function (dir) { + a = file.path(dir, 'mod', 'a.r') + writeLines("#' @export\nf = function () 2L", a) +} + +create_dependent_test_module = function (dir) { + mod = file.path(dir, 'mod') + dir.create(mod) + writeLines("#' @export\nbox::use(./b[f])", file.path(mod, 'a.r')) + writeLines("#' @export\nf = function () 1L", file.path(mod, 'b.r')) +} + +edit_dependent_test_module = function (dir) { + mod = file.path(dir, 'mod') + writeLines("#' @export\nf = function () 2L", file.path(mod, 'b.r')) +} + +test_teardown(box:::autoreload$reset()) + +included = function (declaration) { + caller = parent.frame() + spec = parse_spec(substitute(declaration), '') + info = find_mod(spec, caller) + box:::autoreload$included(info) +} + +test_that('no name needs to be specified', { + expect_error(box::enable_autoreload(), NA) +}) + +test_that('a single name can be specified', { + expect_error(box::enable_autoreload(include = mod/a), NA) + expect_error(box::enable_autoreload(exclude = mod/b), NA) + + expect_error(box::autoreload_include(mod/a), NA) + expect_error(box::autoreload_exclude(mod/a), NA) +}) + +test_that('multiple names can be specified', { + expect_error(box::enable_autoreload(include = c(mod/a, mod/b)), NA) + expect_error(box::enable_autoreload(exclude = c(mod/a, mod/b)), NA) + + expect_error(box::autoreload_include(mod/a, mod/b), NA) + expect_error(box::autoreload_exclude(mod/a, mod/b), NA) +}) + +test_that('all names are included by default', { + box::enable_autoreload() + expect_true(included(mod/a)) + expect_true(included(mod/b)) + expect_true(included(mod/b/a)) + expect_true(included(mod/b/b)) +}) + +test_that('included names are excluded', { + box::enable_autoreload(include = c(mod/a, mod/b, mod/b/a)) + expect_true(included(mod/a)) + expect_true(included(mod/b)) + expect_true(included(mod/b/a)) + expect_false(included(mod/b/b)) +}) + +test_that('excluded names are not included', { + box::enable_autoreload(exclude = c(mod/a, mod/b, mod/b/a)) + expect_false(included(mod/a)) + expect_false(included(mod/b)) + expect_false(included(mod/b/a)) + expect_true(included(mod/b/b)) +}) + +test_that('names can be included after being excluded', { + box::enable_autoreload(exclude = c(mod/a, mod/b, mod/b/a)) + box::autoreload_include(mod/a, mod/b/a) + expect_true(included(mod/a)) + expect_false(included(mod/b)) + expect_true(included(mod/b/a)) + expect_true(included(mod/b/b)) +}) + +test_that('names can be excluded after being included', { + box::enable_autoreload(include = c(mod/a, mod/b, mod/b/a)) + box::autoreload_exclude(mod/a, mod/b/a) + expect_false(included(mod/a)) + expect_true(included(mod/b)) + expect_false(included(mod/b/a)) + expect_false(included(mod/b/b)) +}) + +test_that('auto-reloading simple modules works with `box::use`', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_simple_test_module(dir) + + box::enable_autoreload() + box::use(mod/a) + + expect_equal(a$f(), 1L) + + edit_simple_test_module(dir) + + box::use(mod/a) + + expect_equal(a$f(), 2L) +}) + +test_that('auto-reloading dependent modules works with `box::use`', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_dependent_test_module(dir) + + box::enable_autoreload() + box::use(mod/a) + + expect_equal(a$f(), 1L) + + edit_dependent_test_module(dir) + + box::use(mod/a) + + expect_equal(a$f(), 2L) +}) + +test_that('auto-reloading simple modules works with module name', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_simple_test_module(dir) + + box::enable_autoreload(on_access = TRUE) + box::use(mod/a) + + expect_equal(a$f(), 1L) + + edit_simple_test_module(dir) + + expect_equal(a$f(), 2L) +}) + +test_that('auto-reloading dependent modules works with module name', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_dependent_test_module(dir) + + box::enable_autoreload(on_access = TRUE) + box::use(mod/a) + + expect_equal(a$f(), 1L) + + edit_dependent_test_module(dir) + + expect_equal(a$f(), 2L) +}) + +test_that('auto-reloading simple modules works with attached name', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_simple_test_module(dir) + + box::enable_autoreload(on_access = TRUE) + box::use(mod/a[f]) + + expect_equal(f(), 1L) + + edit_simple_test_module(dir) + + expect_equal(f(), 2L) +}) + +test_that('auto-reloading dependent modules works with attached name', { + dir = tempfile_dir() + on.exit(unlink(dir, recursive = TRUE)) + + old_path = options(box.path = dir) + on.exit(options(old_path), add = TRUE) + + create_dependent_test_module(dir) + + box::enable_autoreload(on_access = TRUE) + box::use(mod/a[f]) + + expect_equal(f(), 1L) + + edit_dependent_test_module(dir) + + expect_equal(f(), 2L) +}) From 85983afcd30dcdcb5f033eb94d27debb939a7aed Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sat, 18 Sep 2021 14:16:17 +0100 Subject: [PATCH 05/14] Move module namespace attribute into namespace info --- R/loaded.r | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/R/loaded.r b/R/loaded.r index 508274ef..018c7fda 100644 --- a/R/loaded.r +++ b/R/loaded.r @@ -41,7 +41,7 @@ register_mod = function (info, mod_ns) { # At worst, this means loading the module redundantly in auto-reload mode. # Doing it the other way round might cause file changes not to be noticed. add_timestamp(info) - attr(loaded_mods[[info$source_path]], 'loading') = TRUE + namespace_info(loaded_mods[[info$source_path]], 'loading') = TRUE } #' @rdname loaded @@ -59,10 +59,10 @@ loaded_mod = function (info) { #' @rdname loaded is_mod_still_loading = function (info) { # pkg_info has no `source_path` but already finished loading anyway. - ! is.null(info$source_path) && attr(loaded_mods[[info$source_path]], 'loading') + ! is.null(info$source_path) && namespace_info(loaded_mods[[info$source_path]], 'loading') } #' @rdname loaded mod_loading_finished = function (info, mod_ns) { - attr(loaded_mods[[info$source_path]], 'loading') = FALSE + namespace_info(loaded_mods[[info$source_path]], 'loading') = FALSE } From 73f77065c1023475b835795aa96ab639af071cda Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sat, 18 Sep 2021 14:30:53 +0100 Subject: [PATCH 06/14] Implement auto-reloading on dependency change --- R/autoreload.r | 27 +++++++++++++-------------- R/loaded.r | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/R/autoreload.r b/R/autoreload.r index b1101c7f..0d4c12c5 100644 --- a/R/autoreload.r +++ b/R/autoreload.r @@ -118,11 +118,16 @@ autoreload = local({ } is_mod_loaded_reload = function (info) { - is_mod_loaded_basic(info) && ! needs_reloading(info) + is_mod_loaded_basic(info) && ! needs_reloading(info, loaded_mod(info)) } - needs_reloading = function (info) { - included(info) && is_file_modified(info) + needs_reloading = function (info, ns) { + included(info) && ( + is_file_modified(info, ns) || { + imports = namespace_info(ns, 'imports') + any(map_lgl(function (x) needs_reloading(x$info, x$ns), imports)) + } + ) } reset() @@ -130,18 +135,12 @@ autoreload = local({ self }) -add_timestamp = function (info) { +add_timestamp = function (info, ns) { timestamp = file.mtime(info$source_path) - mod_timestamps[[info$source_path]] = timestamp + namespace_info(ns, 'timestamp') = timestamp } -remove_timestamp = function (info) { - rm(list = info$source_path, envir = mod_timestamps) +is_file_modified = function (info, ns) { + timestamp = namespace_info(ns, 'timestamp') + file.mtime(info$source_path) > timestamp } - -is_file_modified = function (info) { - prev = mod_timestamps[[info$source_path]] - is.null(prev) || file.mtime(info$source_path) > prev -} - -mod_timestamps = new.env(parent = emptyenv()) diff --git a/R/loaded.r b/R/loaded.r index 018c7fda..8a360753 100644 --- a/R/loaded.r +++ b/R/loaded.r @@ -40,7 +40,7 @@ register_mod = function (info, mod_ns) { # conditions in the presence of concurrent file modifications. # At worst, this means loading the module redundantly in auto-reload mode. # Doing it the other way round might cause file changes not to be noticed. - add_timestamp(info) + add_timestamp(info, mod_ns) namespace_info(loaded_mods[[info$source_path]], 'loading') = TRUE } From 2b515fd95f41ab3c234791549d775fb0197641c3 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sat, 18 Sep 2021 15:16:06 +0100 Subject: [PATCH 07/14] Implement auto-reloading for each module access --- R/autoreload.r | 29 ++++++++++++++++++++++++++--- R/env.r | 5 ++++- R/use.r | 1 + 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/R/autoreload.r b/R/autoreload.r index 0d4c12c5..b08e1c61 100644 --- a/R/autoreload.r +++ b/R/autoreload.r @@ -67,16 +67,16 @@ autoreload = local({ init = function (on_access) { reset() if (on_access) { - throw('Not yet implemented') - } else { - self$is_mod_loaded = is_mod_loaded_reload + self$export_env_class = export_env_class_reload } + self$is_mod_loaded = is_mod_loaded_reload } reset = function () { self$includes = character() self$excludes = character() self$is_mod_loaded = is_mod_loaded_basic + self$export_env_class = export_env_class_basic } add_include = function (spec, caller) { @@ -113,6 +113,29 @@ autoreload = local({ } } + extract = function (e1, e2) { + ns = attr(e1, 'namespace') + info = namespace_info(ns, 'info') + new_mod = if (needs_reloading(info, ns)) { + spec = attr(e1, 'spec') + parent = attr(e1, 'parent') + load_and_register(spec, info, parent) + get(spec$alias, envir = parent) + } else { + e1 + } + + strict_extract(new_mod, e2) + } + + export_env_class_basic = function (info, ns) { + 'box$mod' + } + + export_env_class_reload = function (info) { + c(if (included(info)) 'box$autoreload', 'box$mod') + } + is_mod_loaded_basic = function (info) { info$source_path %in% names(loaded_mods) } diff --git a/R/env.r b/R/env.r index 42fef4f0..2c2c1235 100644 --- a/R/env.r +++ b/R/env.r @@ -153,7 +153,7 @@ make_export_env = function (info, spec, ns) { structure( new.env(parent = emptyenv()), name = paste0('mod:', spec_name(spec)), - class = 'box$mod', + class = autoreload$export_env_class(info), spec = spec, info = info, namespace = ns @@ -176,6 +176,9 @@ strict_extract = function (e1, e2) { #' @export `$.box$ns` = strict_extract +#' @export +`$.box$autoreload` = autoreload$extract + #' @export `print.box$mod` = function (x, ...) { spec = attr(x, 'spec') diff --git a/R/use.r b/R/use.r index 4bb0dce9..cb9ade47 100644 --- a/R/use.r +++ b/R/use.r @@ -499,6 +499,7 @@ assign_alias = function (spec, mod_exports, caller) { if (exists(spec$alias, caller, inherits = FALSE) && bindingIsLocked(spec$alias, caller)) { box_unlock_binding(spec$alias, caller) } + attr(mod_exports, 'parent') = caller assign(spec$alias, mod_exports, envir = caller) } From 4d07f78eafd896936d815ecbec32776f8e675ac4 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sun, 19 Sep 2021 20:00:31 +0100 Subject: [PATCH 08/14] Make test error traces more useful --- tests/testthat/helper-debug.r | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/testthat/helper-debug.r b/tests/testthat/helper-debug.r index 2c8c63f6..b8315133 100644 --- a/tests/testthat/helper-debug.r +++ b/tests/testthat/helper-debug.r @@ -2,6 +2,14 @@ clear_mods = function () { rm(list = names(box:::loaded_mods), envir = box:::loaded_mods) } +# Undo “user-friendly” stack traces to make them more useful. +utils::assignInNamespace( + 'rethrow_on_error', + function (expr, call) expr, + ns = getNamespace('box'), + envir = getNamespace('box') +) + .setup_fun = NULL .teardown_fun = NULL From 267a7789a1e389b2df30717157ce02cd2400cb31 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sun, 19 Sep 2021 20:53:24 +0100 Subject: [PATCH 09/14] Add different name lookup scenario to tests --- tests/testthat/test-autoreload.r | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/testthat/test-autoreload.r b/tests/testthat/test-autoreload.r index 22e79fab..320ff4c5 100644 --- a/tests/testthat/test-autoreload.r +++ b/tests/testthat/test-autoreload.r @@ -10,24 +10,29 @@ create_simple_test_module = function (dir) { mod = file.path(dir, 'mod') dir.create(mod) a = file.path(mod, 'a.r') - writeLines("#' @export\nf = function () 1L", a) + writeLines(c("#' @export", 'f = function () 1L'), a) } edit_simple_test_module = function (dir) { a = file.path(dir, 'mod', 'a.r') - writeLines("#' @export\nf = function () 2L", a) + writeLines(c("#' @export", 'f = function () 2L'), a) } create_dependent_test_module = function (dir) { mod = file.path(dir, 'mod') dir.create(mod) - writeLines("#' @export\nbox::use(./b[f])", file.path(mod, 'a.r')) - writeLines("#' @export\nf = function () 1L", file.path(mod, 'b.r')) + writeLines(c( + "#' @export", + 'box::use(b = ./b[f])', + "#' @export", + 'g = function () b$f()' + ), file.path(mod, 'a.r')) + writeLines(c("#' @export", 'f = function () 1L'), file.path(mod, 'b.r')) } edit_dependent_test_module = function (dir) { mod = file.path(dir, 'mod') - writeLines("#' @export\nf = function () 2L", file.path(mod, 'b.r')) + writeLines(c("#' @export", 'f = function () 2L'), file.path(mod, 'b.r')) } test_teardown(box:::autoreload$reset()) @@ -135,12 +140,16 @@ test_that('auto-reloading dependent modules works with `box::use`', { box::use(mod/a) expect_equal(a$f(), 1L) + expect_equal(a$g(), 1L) + edit_dependent_test_module(dir) box::use(mod/a) expect_equal(a$f(), 2L) + expect_equal(a$g(), 2L) + }) test_that('auto-reloading simple modules works with module name', { @@ -175,10 +184,12 @@ test_that('auto-reloading dependent modules works with module name', { box::use(mod/a) expect_equal(a$f(), 1L) + expect_equal(a$g(), 1L) edit_dependent_test_module(dir) expect_equal(a$f(), 2L) + expect_equal(a$g(), 2L) }) test_that('auto-reloading simple modules works with attached name', { @@ -210,11 +221,13 @@ test_that('auto-reloading dependent modules works with attached name', { create_dependent_test_module(dir) box::enable_autoreload(on_access = TRUE) - box::use(mod/a[f]) + box::use(mod/a[f, g]) expect_equal(f(), 1L) + expect_equal(g(), 1L) edit_dependent_test_module(dir) expect_equal(f(), 2L) + expect_equal(g(), 2L) }) From ee67e28467283c30cbdc6cb9af21a1154dcb287b Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sun, 19 Sep 2021 21:24:32 +0100 Subject: [PATCH 10/14] Add helper for walking over lists --- R/env.r | 14 +++++++------- R/map.r | 11 +++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/R/env.r b/R/env.r index 2c2c1235..9916041a 100644 --- a/R/env.r +++ b/R/env.r @@ -215,17 +215,17 @@ find_import_env.environment = function (x, spec, info, mod_ns) { } import_into_env = function (to_env, to_names, from_env, from_names) { - for (i in seq_along(to_names)) { + foreach(function (from, to) { if ( - exists(from_names[i], from_env, inherits = FALSE) - && bindingIsActive(from_names[i], from_env) - && ! inherits((fun = activeBindingFunction(from_names[i], from_env)), 'box$placeholder') + exists(from, from_env, inherits = FALSE) + && bindingIsActive(from, from_env) + && ! inherits((fun = activeBindingFunction(from, from_env)), 'box$placeholder') ) { - makeActiveBinding(to_names[i], fun, to_env) + makeActiveBinding(to, fun, to_env) } else { - assign(to_names[i], env_get(from_env, from_names[i]), envir = to_env) + assign(to, env_get(from_env, from), envir = to_env) } - } + }, from_names, to_names) } env_get = function (env, name) { diff --git a/R/map.r b/R/map.r index f68b9ee3..12e444ab 100644 --- a/R/map.r +++ b/R/map.r @@ -8,6 +8,8 @@ #' \sQuote{Examples}). #' \code{transpose} is a special \code{map} application that concatenates its #' inputs to compute a transposed list. +#' \code{foreach} is a special \code{map} application that does not return a +#' value; it is therefore expected that \code{.f} causes a side-effect. #' @param .f an n-ary function where n is the number of further arguments given #' @param \dots lists of arguments to map over in parallel #' @param .default the default value returned by \code{flatmap} for an empty @@ -77,3 +79,12 @@ map_chr = function (.f, ...) { transpose = function (...) { map(c, ...) } + +#' @return \code{foreach} does not return any value. +#' @rdname map +foreach = function (.f, ...) { + args = list(...) + for (i in seq_along(..1)) { + do.call(.f, lapply(args, `[[`, i)) + } +} From 8b13d236cdf81dc39e11d9ca932e7c28e14ae808 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sun, 19 Sep 2021 21:32:28 +0100 Subject: [PATCH 11/14] Implement auto-reloading for attached names --- R/autoreload.r | 42 +++++++++++++++++++++++++++++++++++++++++- R/use.r | 6 +++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/R/autoreload.r b/R/autoreload.r index b08e1c61..d4c0a34e 100644 --- a/R/autoreload.r +++ b/R/autoreload.r @@ -63,11 +63,13 @@ spec_list = function (specs) { autoreload = local({ self = environment() + top = topenv() init = function (on_access) { reset() if (on_access) { self$export_env_class = export_env_class_reload + self$import_into_env = import_into_env_reload } self$is_mod_loaded = is_mod_loaded_reload } @@ -77,6 +79,7 @@ autoreload = local({ self$excludes = character() self$is_mod_loaded = is_mod_loaded_basic self$export_env_class = export_env_class_basic + self$import_into_env = import_into_env_basic } add_include = function (spec, caller) { @@ -120,7 +123,7 @@ autoreload = local({ spec = attr(e1, 'spec') parent = attr(e1, 'parent') load_and_register(spec, info, parent) - get(spec$alias, envir = parent) + get(spec$alias, envir = parent, inherits = FALSE) } else { e1 } @@ -144,6 +147,43 @@ autoreload = local({ is_mod_loaded_basic(info) && ! needs_reloading(info, loaded_mod(info)) } + import_into_env_basic = function (spec, info, to_env, to_names, from_env, from_names) { + top$import_into_env(to_env, to_names, from_env, from_names) + } + + import_into_env_reload = function (spec, info, to_env, to_names, from_env, from_names) { + foreach(function (from, to) { + fun = if ( + exists(from, from_env, inherits = FALSE) && + bindingIsActive(from, from_env) && + ! inherits(active_binding_function(from, from_env), 'box$placeholder') + ) { + function (value) { + new_env = if (needs_reloading(info, from_env)) { + load_and_register(spec, info, to_env) + loaded_mod(info) + } else { + from_env + } + + fun = active_binding_function(from, new_env) + fun(value) + } + } else { + function () { + new_env = if (needs_reloading(info, from_env)) { + load_and_register(spec, info, to_env) + loaded_mod(info) + } else { + from_env + } + get(from, envir = new_env) + } + } + makeActiveBinding(to, fun, to_env) + }, from_names, to_names) + } + needs_reloading = function (info, ns) { included(info) && ( is_file_modified(info, ns) || { diff --git a/R/use.r b/R/use.r index cb9ade47..7863831c 100644 --- a/R/use.r +++ b/R/use.r @@ -455,7 +455,11 @@ attach_to_caller = function (spec, info, mod_exports, mod_ns, caller) { import_env = find_import_env(caller, spec, info, mod_ns) attr(mod_exports, 'attached') = environmentName(import_env) - import_into_env(import_env, names(attach_list), mod_exports, attach_list) + autoreload$import_into_env( + spec, info, + import_env, names(attach_list), + mod_ns, attach_list + ) } #' @return \code{attach_list} returns a named character vector of the names in From 60d759a132dca9fb6849fb1a8c200db257ed01fd Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Sun, 19 Sep 2021 21:43:55 +0100 Subject: [PATCH 12/14] Add delay to ensure test files are seen as modified --- tests/testthat/test-autoreload.r | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/testthat/test-autoreload.r b/tests/testthat/test-autoreload.r index 320ff4c5..fc85224c 100644 --- a/tests/testthat/test-autoreload.r +++ b/tests/testthat/test-autoreload.r @@ -14,6 +14,8 @@ create_simple_test_module = function (dir) { } edit_simple_test_module = function (dir) { + # Ensure file modification timestamp is different. + Sys.sleep(0.001) a = file.path(dir, 'mod', 'a.r') writeLines(c("#' @export", 'f = function () 2L'), a) } @@ -31,6 +33,8 @@ create_dependent_test_module = function (dir) { } edit_dependent_test_module = function (dir) { + # Ensure file modification timestamp is different. + Sys.sleep(0.001) mod = file.path(dir, 'mod') writeLines(c("#' @export", 'f = function () 2L'), file.path(mod, 'b.r')) } From cafb6072840557f6a2ac873fc718fc7523f52bf6 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Tue, 21 Sep 2021 17:31:23 +0100 Subject: [PATCH 13/14] Handle package dependencies during auto-reloading --- R/autoreload.r | 8 ++++++++ R/env.r | 14 +++++++++++++- tests/testthat/test-autoreload.r | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/R/autoreload.r b/R/autoreload.r index d4c0a34e..c4ec01bc 100644 --- a/R/autoreload.r +++ b/R/autoreload.r @@ -185,6 +185,10 @@ autoreload = local({ } needs_reloading = function (info, ns) { + UseMethod('needs_reloading') + } + + `needs_reloading.box$mod_info` = function (info, ns) { included(info) && ( is_file_modified(info, ns) || { imports = namespace_info(ns, 'imports') @@ -193,6 +197,10 @@ autoreload = local({ ) } + `needs_reloading.box$pkg_info` = function (info, ns) { + FALSE + } + reset() self diff --git a/R/env.r b/R/env.r index 9916041a..b9054e09 100644 --- a/R/env.r +++ b/R/env.r @@ -153,13 +153,25 @@ make_export_env = function (info, spec, ns) { structure( new.env(parent = emptyenv()), name = paste0('mod:', spec_name(spec)), - class = autoreload$export_env_class(info), + class = export_env_class(info), spec = spec, info = info, namespace = ns ) } +export_env_class = function (info) { + UseMethod('export_env_class') +} + +`export_env_class.box$mod_info` = function (info) { + autoreload$export_env_class(info) +} + +`export_env_class.box$pkg_info` = function (info) { + 'box$mod' +} + strict_extract = function (e1, e2) { # Implemented in C since this function is called very frequently and needs # to be fast, and the C implementation is about 270% faster than an R diff --git a/tests/testthat/test-autoreload.r b/tests/testthat/test-autoreload.r index fc85224c..35cb9696 100644 --- a/tests/testthat/test-autoreload.r +++ b/tests/testthat/test-autoreload.r @@ -10,7 +10,11 @@ create_simple_test_module = function (dir) { mod = file.path(dir, 'mod') dir.create(mod) a = file.path(mod, 'a.r') - writeLines(c("#' @export", 'f = function () 1L'), a) + writeLines(c( + 'box::use(stats)', + "#' @export", + 'f = function () 1L' + ), a) } edit_simple_test_module = function (dir) { From 216f3cadee3c58fb1340b58a152c658747847332 Mon Sep 17 00:00:00 2001 From: Konrad Rudolph Date: Thu, 22 Jun 2023 21:43:30 +0200 Subject: [PATCH 14/14] Set dev version number --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index abde76ca..50c1644e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: box Title: Write Reusable, Composable and Modular R Code -Version: 1.1.3 +Version: 1.1.3.9000 Authors@R: c( person( 'Konrad', 'Rudolph',