diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 758f8664..5870d208 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main, master, epic-poc] pull_request: - branches: [main, master] + branches: [main, master, epic-poc] name: R-CMD-check diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 087f0b05..3f51c5c7 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main, master, epic-poc] pull_request: - branches: [main, master] + branches: [main, master, epic-poc] release: types: [published] workflow_dispatch: diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 2c5bb502..c4cee67e 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main, master, epic-poc] pull_request: - branches: [main, master] + branches: [main, master, epic-poc] name: test-coverage diff --git a/DESCRIPTION b/DESCRIPTION index 4889fe33..293f837d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -11,15 +11,20 @@ Description: Reimplementation of orderly based on outpack. License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.1.1 +RoxygenNote: 7.2.3 URL: https://github.com/mrc-ide/orderly3 BugReports: https://github.com/mrc-ide/orderly3/issues Imports: fs, + jsonlite, orderly, - outpack, + outpack (>= 0.2.4), + withr, yaml Suggests: + jsonvalidate, + mockery, + pkgload, testthat (>= 3.0.0) Config/testthat/edition: 3 Remotes: mrc-ide/outpack diff --git a/NAMESPACE b/NAMESPACE index e651b944..4262cac8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1 +1,11 @@ # Generated by roxygen2: do not edit by hand + +export(orderly_artefact) +export(orderly_dependency) +export(orderly_global_resource) +export(orderly_parameters) +export(orderly_plugin_add_metadata) +export(orderly_plugin_context) +export(orderly_plugin_register) +export(orderly_resource) +export(orderly_run) diff --git a/R/config.R b/R/config.R index 1c4b65e2..0050206b 100644 --- a/R/config.R +++ b/R/config.R @@ -17,13 +17,12 @@ orderly_config_yml_read <- function(path) { assert_named(raw) } - ## This has a structure that makes using plugins later easier check <- list( + plugins = orderly_config_validate_plugins, global_resources = orderly_config_validate_global_resources) required <- character() - optional <- c(setdiff(names(check), required)) - + optional <- setdiff(names(check), required) check_fields(raw, filename, required, optional) dat <- list() @@ -42,3 +41,19 @@ orderly_config_validate_global_resources <- function(global_resources, global_resources } } + + +orderly_config_validate_plugins <- function(plugins, filename) { + if (is.null(plugins)) { + return(NULL) + } + assert_named(plugins, unique = TRUE, name = sprintf("%s:plugins", filename)) + + ret <- list() + for (nm in names(plugins)) { + dat <- load_orderly_plugin(nm) + dat$config <- dat$config(plugins[[nm]], filename) + ret[[nm]] <- dat + } + ret +} diff --git a/R/context.R b/R/context.R new file mode 100644 index 00000000..be9acd97 --- /dev/null +++ b/R/context.R @@ -0,0 +1,59 @@ +orderly_context <- function() { + p <- get_active_packet() + is_active <- !is.null(p) + if (is_active) { + path <- p$path + root <- p$root$path + config <- p$orderly3$config + env <- p$orderly3$envir + src <- p$orderly3$src + parameters <- p$parameters + } else { + path <- getwd() + root <- detect_orderly_interactive_path(path)$path + config <- orderly_root(root, FALSE)$config + env <- orderly_environment("orderly3") + src <- path + parameters <- current_orderly_parameters(src, env) + } + list(is_active = is_active, path = path, config = config, env = env, + root = root, src = src, parameters = parameters, + packet = p) +} + + +## This is a real trick, and is only used in the case where the report +## is being run interactively, and in that case the correct thing is +## *almost certainly* the global environment, as the user has to do +## some tricks to stop that being the case; for example running +## +## source("orderly.R", local = TRUE) +## +## We want to find the environment that corresponds to the top level +## environment for orderly; that will be the one that called the +## plugin function. So we'll have a stack of frames (corresponding to +## the environments on the call stack) with *parents* that look like +## this, outermost first: +## +## - (anything else) +## - (calling environment) <-- this is what we're looking for +## - (plugin) +## - (orderly3; orderly_plugin_context) +## - (orderly3; from orderly_context) +## - (orderly3; from orderly_environment) +## +## so we loop down the stack looking for the first call to a function +## in the plugin package, then take the frame *above* that. +## +## When we want this for a non-plugin case (i.e., the caller is in +## orderly3) then we just need to pass name = "orderly3" here +orderly_environment <- function(name) { + frames <- sys.frames() + for (i in seq_along(frames)[-1]) { + if (environmentName(parent.env(frames[[i]])) == name) { + return(frames[[i - 1]]) + } + } + ## This error should never surface if the plugin is configured correctly + stop("Could not determine calling environment safely - please report") +} diff --git a/R/interactive.R b/R/interactive.R new file mode 100644 index 00000000..c580e9a9 --- /dev/null +++ b/R/interactive.R @@ -0,0 +1,16 @@ +## This is something that we might improve over time - it will likely +## be useful to have some sort of "register interactive" function +## which we could then just look up. +## +## I am not sure if we also want to allow working interactively from a +## draft directory too. +detect_orderly_interactive_path <- function(path = getwd()) { + path_split <- fs::path_split(path)[[1]] + is_plausible <- length(path_split) > 2 && + path_split[[length(path_split) - 1]] == "src" && + file.exists(file.path(path, "../..", "orderly_config.yml")) + if (!is_plausible) { + stop(sprintf("Failed to detect orderly path at '%s'", path)) + } + orderly_root(file.path(path, "../.."), FALSE) +} diff --git a/R/metadata.R b/R/metadata.R new file mode 100644 index 00000000..e5848be4 --- /dev/null +++ b/R/metadata.R @@ -0,0 +1,306 @@ +##' Declare orderly parameters. You should only have one call to this +##' within your file, though this is not enforced! Typically you'd put +##' it very close to the top, though the order does not really matter. +##' Parameters are scalar atomic values (e.g. a string, number or +##' boolean) and defaults must be present literally (i.e., they may +##' not come from a variable itself). Provide `NULL` if you do not +##' have a default, in which case this parameter will be required. +##' +##' @title Declare orderly parameters +##' +##' @param ... Any number of parameters +##' +##' @return Undefined +##' +##' @export +orderly_parameters <- function(...) { + p <- get_active_packet() + if (is.null(p)) { + pars <- static_orderly_parameters(list(...)) + env <- parent.frame() + check_parameters_interactive(env, pars) + } + + invisible() +} + + +static_orderly_parameters <- function(args) { + if (length(args) == 0L) { + return(NULL) + } + assert_named(args, unique = TRUE, name = "Arguments to 'orderly_parameters'") + check_parameter_values(args, TRUE) + + args +} + + +current_orderly_parameters <- function(src, env) { + dat <- orderly_read(src) + pars <- static_orderly_parameters(dat$parameters) + check_parameters_interactive(env, pars) +} + + +##' Declare that a file, or group of files, are an orderly +##' resource. By explicitly declaring files as resources orderly will +##' mark the files as immutable inputs and validate that your analysis +##' does not modify them when run with [orderly_run()] +##' +##' @title Declare orderly resources +##' +##' @param files Any number of names of files +##' +##' @return Undefined +##' +##' @export +orderly_resource <- function(files) { + ## TODO: an error here needs to throw a condition that we can easily + ## handle and or defer; that's not too hard to do though - convert + ## the error into something with a special class, perhaps make it a + ## warning in normal R and then register a handler for it in the + ## main run. + assert_character(files) + assert_file_exists(files) + + p <- get_active_packet() + if (!is.null(p) && length(files) > 0L) { + outpack::outpack_packet_file_mark(files, "immutable", packet = p) + p$orderly3$resources <- c(p$orderly3$resources, files) + } + + invisible() +} + + +static_orderly_resource <- function(args) { + list(files = static_character_vector(args$files)) +} + + +##' Declare an artefact. By doing this you turn on a number of orderly +##' features; see Details below. You can have multiple calls to this +##' function within your orderly script. +##' +##' (1) files matching this will *not* be copied over from the src +##' directory to the draft directory unless they are also listed as a +##' resource with [orderly_resource()]. This feature is only enabled +##' if you call this function from the top level of the orderly script +##' and if it contains only string literals (no variables). +##' +##' (2) if your script fails to produce these files, then +##' [orderly_run()] will fail, guaranteeing that your task does really +##' produce the things you need it to. +##' +##' (3) within the final metadata, your artefacts will have additional +##' metadata; the description that you provide and a grouping +##' +##' @title Declare orderly artefacts +##' +##' @param description The name of the artefact +##' +##' @param files The files within this artefact +##' +##' @return Undefined +##' +##' @export +orderly_artefact <- function(description, files) { + assert_scalar_character(description) + assert_character(files) # also check length >0 ? + + p <- get_active_packet() + if (!is.null(p)) { + artefact <- list(description = description, files = files) + p$orderly3$artefacts <- c(p$orderly3$artefacts, list(artefact)) + } + + invisible() +} + + +static_orderly_artefact <- function(args) { + list(description = static_character_vector(args$description), + files = static_character_vector(args$files)) +} + + +##' Declare a dependency on another packet +##' +##' @title Declare a dependency +##' +##' @param name The name of the packet to depend on +##' +##' @param query The query to search for; often this will simply be +##' the string `latest`, indicating the most recent version. You may +##' want a more complex query here though. +##' +##' @param use A named character vector of filenames to copy from the +##' upstream packet. The name corresponds to the destination name, +##' so c(here.csv = "there.csv") will take the upstream file +##' `there.csv` and copy it over as `here.csv`. +##' +##' @return Undefined +##' @export +orderly_dependency <- function(name, query, use) { + assert_scalar_character(name) + assert_scalar_character(query) + + assert_character(use) + assert_named(use, unique = TRUE) + + ctx <- orderly_context() + id <- outpack::outpack_query(query, ctx$parameters, name = name, + require_unpacked = TRUE, root = ctx$root) + if (ctx$is_active) { + outpack::outpack_packet_use_dependency(id, use, ctx$packet) + } else { + outpack::outpack_copy_files(id, use, ctx$path, ctx$root) + } + + invisible() +} + + +static_orderly_dependency <- function(args) { + name <- args$name + query <- args$query + use <- args$use + + name <- static_string(name) + use <- static_character_vector(use) + ## TODO: allow passing expressions directly in, that will be much + ## nicer, but possibly needs some care as we do want a consistent + ## approach to NSE here + query <- static_string(query) + if (is.null(name) || is.null(use) || is.null(query)) { + return(NULL) + } + list(name = name, query = query, use = use) +} + + +##' Copy global resources into a packet directory. You can use this to +##' share common resources (data or code) between multiple packets. +##' Additional metadata will be added to keep track of where the files +##' came from. Using this function requires that the orderly +##' repository has global resources enabled, with a +##' `global_resources:` section in the `orderly_config.yml`; an error +##' will be raised if this is not configured. +##' +##' @title Copy global resources into a packet directory +##' +##' @param ... Named arguments corresponding to global resources to +##' copy. The name will be the destination filename, while the value +##' is the filename within the global resource directory. +##' +##' @return Undefined +##' @export +orderly_global_resource <- function(...) { + files <- validate_global_resource(list(...)) + ctx <- orderly_context() + + files <- copy_global(ctx$root, ctx$path, ctx$config, files) + if (ctx$is_active) { + outpack::outpack_packet_file_mark(files$here, "immutable", + packet = ctx$packet) + ctx$packet$orderly3$global_resources <- + rbind(ctx$packet$orderly3$global_resources, files) + } + + invisible() +} + + +validate_global_resource <- function(args) { + if (length(args) == 0) { + stop("orderly_global_resource requires at least one argument") + } + assert_named(args, unique = TRUE) + is_invalid <- !vlapply(args, function(x) is.character(x) && length(x) == 1) + if (any(is_invalid)) { + stop(sprintf("Invalid global resource %s: entries must be strings", + paste(squote(names(args)[is_invalid]), collapse = ", "))) + } + list_to_character(args) +} + + +copy_global <- function(path_root, path_dest, config, files) { + if (is.null(config$global_resources)) { + stop(paste("'global_resources' is not supported;", + "please edit orderly_config.yml to enable"), + call. = FALSE) + } + + here <- names(files) + there <- unname(files) + + global_path <- file.path(path_root, config$global_resources) + assert_file_exists( + there, check_case = TRUE, workdir = global_path, + name = sprintf("Global resources in '%s'", global_path)) + src <- file.path(global_path, there) + dst <- file.path(path_dest, here) + + is_dir <- is_directory(file.path(global_path, there)) + fs::dir_create(file.path(path_dest, dirname(here))) + if (any(is_dir)) { + fs::dir_copy(src[is_dir], dst[is_dir]) + ## Update the names that will be used in the metadata: + files <- lapply(src[is_dir], dir) + here <- replace_ragged(here, is_dir, Map(file.path, here[is_dir], files)) + there <- replace_ragged(there, is_dir, Map(file.path, there[is_dir], files)) + } + if (any(!is_dir)) { + fs::file_copy(src[!is_dir], dst[!is_dir]) + } + + data_frame(here = here, there = there) +} + + +static_orderly_global_resource <- function(args) { + list(files = static_character_vector(args)) +} + + +static_string <- function(x) { + if (is.character(x)) { + x + } else if (is_call(x, "c") && length(x) == 2 && is.character(x[[2]])) { + x[[2]] + } else { + NULL + } +} + + +static_character_vector <- function(x) { + if (is.character(x)) { + x + } else if (is_call(x, "c")) { + x <- lapply(x[-1], static_character_vector) + x <- if (all(vlapply(x, is.character))) unlist(x, FALSE, FALSE) else NULL + } else { + x <- NULL + } + x +} + + + +static_eval <- function(fn, call) { + if (is_call(call[[1]], "::")) { + call[[1]] <- call[[1]][[3]] + } + args <- as.list(match.call(match.fun(call[[1]]), call)[-1]) + fn(args) +} + + +current <- new.env(parent = emptyenv()) + +get_active_packet <- function() { + current[[getwd()]] +} diff --git a/R/plugin.R b/R/plugin.R new file mode 100644 index 00000000..65560733 --- /dev/null +++ b/R/plugin.R @@ -0,0 +1,200 @@ +##' Create an orderly plugin. A plugin is typically defined by a +##' package and is used to extend orderly by enabling new +##' functionality, declared in `orderly_config.yml` and `orderly.R` +##' and affecting the running of reports primarily by creating new +##' objects in the report environment. This system is discussed in +##' more detail in `vignette("plugins")`, but will be expanded (likely +##' in breaking ways) soon. +##' +##' @title Register an orderly plugin +##' +##' @param name The name of the plugin, typically the package name +##' +##' @param config A function to read, check and process the +##' configuration section in `orderly_config.yml`. This function +##' will be passed the deserialised data from the plugin's section +##' of `orderly_config.yml`, and the full path to that file. As the +##' order of loading of plugins is not defined, each plugin must +##' standalone and should not try and interact with other plugins at +##' load. It should return a processed copy of the configuration +##' data, to be passed in as the second argument to `read`. +##' +##' @param serialise A function to serialise any metadata added by the +##' plugin's functions to the outpack metadata. It will be passed a +##' list of all entries pushed in via +##' [`orderly3::orderly_plugin_add_metadata()`]; this is a named +##' list with names corresponding to the `field` argument to +##' `orderly_plugin_add_metadata` and each list element being an +##' unnamed list with values corresponding to `data`. +##' +##' @param cleanup Optionally, a function to clean up any state that +##' your plugin uses. You can call `orderly_plugin_context` from +##' within this function and access anything you need from that. If +##' not given, then no cleanup is done. +##' +##' @param schema Optionally a path to a schema for the metadata +##' created by this plugin. See `vignette("plugins")` for details. +##' +##' @return Nothing, this function is called for its side effect of +##' registering a plugin. +##' +##' @export +orderly_plugin_register <- function(name, config, serialise, cleanup = NULL, + schema = NULL) { + assert_scalar_character(name) + .plugins[[name]] <- orderly_plugin(config, serialise, cleanup, schema) +} + + +load_orderly_plugin <- function(name) { + assert_scalar_character(name) + if (!(name %in% names(.plugins))) { + loadNamespace(name) + } + plugin <- .plugins[[name]] + if (is.null(plugin)) { + stop(sprintf("Plugin '%s' not found", name)) + } + + plugin +} + + +.plugins <- new.env(parent = emptyenv()) + + +orderly_plugin <- function(config, serialise, cleanup, schema) { + assert_is(config, "function") + assert_is(serialise, "function") + if (is.null(cleanup)) { + cleanup <- plugin_no_cleanup + } + assert_is(cleanup, "function") + if (!is.null(schema)) { + assert_file_exists(schema, name = "Schema file") + schema <- paste(readLines(schema), collapse = "\n") + class(schema) <- "json" + } + ret <- list(config = config, + serialise = serialise, + cleanup = cleanup, + schema = schema) + class(ret) <- "orderly_plugin" + ret +} + +##' Fetch the running context, for use within a plugin. The intention +##' here is that within free functions that your plugin makes +##' available, you will call this function to get information about +##' the state of a packet. You will then typically call +##' [`orderly3::orderly_plugin_add_metadata()`] afterwards. +##' +##' When a plugin function is called, orderly3 will be running in one +##' of two modes; (1) from within [`orderly3::orderly_run()`], in +##' which case we're part way through creating a packet in a brand new +##' directory, and possibly using a special environment for +##' evaluation, or (2) interactively, with a user developing their +##' report. The plugin needs to be able to support both modes, and +##' this function will return information about the state to help you +##' cope with either case. +##' +##' @title Fetch plugin context +##' +##' @param name Name of the plugin +##' +##' @return A list with elements: +##' +##' * `is_active`: a logical, indicating if we're running under +##' [`orderly3::orderly_run()`]; you may need to change behaviour +##' depending on this value. +##' +##' * `path`: the path of the running packet. This is almost always the +##' working directory, unless the packet contains calls to [`setwd()`] +##' or similar. You may create files here. +##' +##' * `config`: the configuration for this plugin, after processing +##' with the plugin's `read` function (see +##' [`orderly3::orderly_plugin_register`]) +##' +##' * `env`: the environment that the packet is running in. Often this +##' will be the global environment, but do not assume this! You may +##' read and write from this environment. +##' +##' * `src`: the path to the packet source directory. This is +##' different to the current directory when the packet is running, +##' but the same when the user is interactively working with a +##' report. You may *read* from this directory but *must not write +##' to it* +##' +##' * `parameters`: the parameters as passed through to the run the +##' report. +##' +##' @seealso [orderly3::orderly_plugin_register], +##' [orderly3::orderly_plugin_add_metadata] +##' @export +orderly_plugin_context <- function(name) { + assert_scalar_character(name) + ctx <- orderly_context() + check_plugin_enabled(name, ctx$config) + ## Narrower view on configuration - can only see the config for the + ## plugin itself: + ctx$config <- ctx$config$plugins[[name]]$config + ## No direct access to the full packet + ctx$packet <- NULL + ## Correct environment in the interactive case: + if (!ctx$is_active) { + ctx$env <- orderly_environment(name) + } + ctx +} + + +##' Add plugin-specific metadata to a running packet. This will take +##' some describing. You accumulate any number of bits of metadata +##' into arbitrary fields, and then later on serialise these to json. +##' +##' @title Add metadata from plugin +##' +##' @param name The name of the plugin; must be the same as used in +##' [orderly3::orderly_plugin_register] and +##' [orderly3::orderly_plugin_context] +##' +##' @param field The name of a field to add the data to. This is +##' required even if your plugin only produces one sort of data, in +##' which case you can remove it later on within your serialisation +##' function. +##' +##' @param data Arbitrary data to be added to the currently running +##' packet +##' +##' @return Nothing, called only for its side effects +##' +##' @export +orderly_plugin_add_metadata <- function(name, field, data) { + assert_scalar_character(name) + assert_scalar_character(field) + p <- get_active_packet() + if (!is.null(p)) { + check_plugin_enabled(name, p$orderly3$config) + p$orderly3$plugins[[name]][[field]] <- + c(p$orderly3$plugins[[name]][[field]], list(data)) + } +} + + +check_plugin_enabled <- function(name, config) { + if (is.null(config$plugins[[name]])) { + stop(sprintf("Plugin '%s' not enabled in 'orderly_config.yml'", name)) + } +} + + +plugin_run_cleanup <- function(path, plugins) { + for (p in plugins) { + withr::with_dir(path, p$cleanup()) + } +} + + +plugin_no_cleanup <- function() { +} diff --git a/R/read.R b/R/read.R new file mode 100644 index 00000000..1866087d --- /dev/null +++ b/R/read.R @@ -0,0 +1,63 @@ +orderly_read <- function(path) { + assert_file_exists("orderly.R", workdir = path) + orderly_read_r(file.path(path, "orderly.R")) +} + + +orderly_read_r <- function(path) { + exprs <- parse(file = path) + + inputs <- list() + artefacts <- list() + + check <- list(orderly_parameters = static_orderly_parameters, + orderly_resource = static_orderly_resource, + orderly_global_resource = static_orderly_global_resource, + orderly_artefact = static_orderly_artefact, + orderly_dependency = static_orderly_dependency) + dat <- set_names(rep(list(NULL), length(check)), names(check)) + + for (e in exprs) { + for (f in names(check)) { + if (is_orderly_call(e, f)) { + ## This will error a bit early really, so later we'll + ## intercept this and throw a careful error with line numbers + ## etc, or proceed without erroring in some situations. + dat[[f]] <- c(dat[[f]], list(static_eval(check[[f]], e))) + } + } + } + + names(dat) <- sub("^orderly_", "", names(dat)) + + ret <- list() + if (length(dat$parameters) > 0) { + ## TODO: once things are working, check for no duplicate parameter + ## names across multiple calls; leaving this assertion in for + ## now. Reporting on that well suggests that we should be able to + ## record the line numbers when we pass off through the file; + ## that's not too hard to do really, I think we can do that with + ## getSrcref or similar. + ## + ## TODO: we should disallow use of the parameter function in any + ## deeper expression (at least in strict mode), worth searching + ## for that and setting up that check here + stopifnot(!anyDuplicated(unlist(lapply(dat$parameters, names)))) + ret$parameters <- unlist(dat$parameters, FALSE, TRUE) + } + if (length(dat$resource) > 0) { + ret$resources <- unique(unlist(dat$resource, TRUE, FALSE)) + } + if (length(dat$artefact) > 0) { + ret$artefacts <- dat$artefact + } + if (length(dat$dependency) > 0) { + ret$dependency <- drop_null(dat$dependency, empty = NULL) + } + + ret +} + + +orderly_validate <- function(dat, path) { +} diff --git a/R/run.R b/R/run.R new file mode 100644 index 00000000..79419c38 --- /dev/null +++ b/R/run.R @@ -0,0 +1,236 @@ +##' Run a report. This will create a new directory in +##' `drafts/`, copy your declared resources there, run +##' your script and check that all expected artefacts were created. +##' +##' @title Run a report +##' +##' @param name Name of the report to run +##' +##' @param parameters Parameters passed to the report. A named list of +##' parameters declared in the `orderly.yml`. Each parameter +##' must be a scalar character, numeric, integer or logical. +##' +##' @param envir The environment that will be used to evaluate the +##' report script; by default we use the global environment, which +##' may not always be what is wanted. +##' +##' @param root The path to an orderly root directory, or `NULL` +##' (the default) to search for one from the current working +##' directory if `locate` is `TRUE`. +##' +##' @param locate Logical, indicating if the configuration should be +##' searched for. If `TRUE` and `config` is not given, +##' then orderly looks in the working directory and up through its +##' parents until it finds an `.outpack` directory +##' +##' @return The id of the created report (a string) +##' +##' @export +orderly_run <- function(name, parameters = NULL, envir = NULL, + root = NULL, locate = TRUE) { + root <- orderly_root(root, locate) + src <- file.path(root$path, "src", name) + + envir <- envir %||% .GlobalEnv + assert_is(envir, "environment") + + dat <- orderly_read(src) + + parameters <- check_parameters(parameters, dat$parameters) + + orderly_validate(dat, src) + + id <- outpack::outpack_id() + + stopifnot(fs::is_absolute_path(root$path)) + path <- file.path(root$path, "draft", name, id) + fs::dir_create(path) + ## Slightly peculiar formulation here; we're going to use 'path' as + ## the key for storing the active packet and look it up later with + ## getwd(); this means we definitely will match. Quite possibly path + ## normalisation would do the same fix. This will likely change in + ## future if we need to support working within subdirectories of + ## path too, in which case we use something from fs. + path <- withr::with_dir(path, getwd()) + + ## For now, let's just copy *everything* over. Later we'll get more + ## clever about this and avoid copying over artefacts and + ## dependencies. + all_files <- dir_ls_local(src, all = TRUE) + if (length(dat) == 0) { + fs::file_copy(file.path(src, all_files), path) + } else { + ## This will require some care in the case where we declare + ## directory artefacts, not totally sure what we'll want to do + ## there, but it probably means that we need to write a dir walker + ## - let's ignore that detail for now and come up with some + ## adverserial cases later. + exclude <- unlist(lapply(dat$artefacts, "[[", "files"), TRUE, FALSE) + to_copy <- union(dat$resources, setdiff(all_files, exclude)) + fs::file_copy(file.path(src, to_copy), path) + } + + p <- outpack::outpack_packet_start(path, name, parameters = parameters, + id = id, root = root$outpack, + local = TRUE) + withCallingHandlers({ + p$orderly3 <- list(config = root$config, envir = envir, src = src) + current[[path]] <- p + + if (!is.null(parameters)) { + list2env(parameters, envir) + } + + if (length(dat$resources) > 0) { # outpack should cope with this... + outpack::outpack_packet_file_mark(dat$resources, "immutable", packet = p) + } + outpack::outpack_packet_run("orderly.R", envir, packet = p) + check_produced_artefacts(path, p$orderly3$artefacts) + custom_metadata_json <- to_json(custom_metadata(p$orderly3)) + + schema <- custom_metadata_schema(root$config) + outpack::outpack_packet_add_custom("orderly", custom_metadata_json, + schema, packet = p) + plugin_run_cleanup(path, p$orderly3$config$plugins) + outpack::outpack_packet_end(p) + unlink(path, recursive = TRUE) + current[[path]] <- NULL + }, error = function(e) { + ## Eventually fail nicely here with mrc-3379 + outpack::outpack_packet_cancel(p) + current[[path]] <- NULL + }) + + id +} + + +custom_metadata <- function(dat) { + global <- dat$global_resources %||% list() + role <- data_frame( + path = c(dat$resources, global$here), + role = c(rep_along("resource", dat$resources), + rep_along("global", global$here))) + artefacts <- lapply(dat$artefacts, function(x) { + list(description = scalar(x$description), + paths = x$files) + }) + + if (is.null(dat$plugins)) { + plugins <- NULL + } else { + plugins <- list() + for (nm in names(dat$plugins)) { + plugins[[nm]] <- dat$config$plugins[[nm]]$serialise(dat$plugins[[nm]]) + } + } + + list(artefacts = artefacts, + role = role, + displayname = NULL, + description = NULL, + custom = NULL, + global = global, + packages = character(0), + plugins = plugins) +} + + +check_produced_artefacts <- function(path, artefacts) { + if (is.null(artefacts)) { + return() + } + expected <- unlist(lapply(artefacts, "[[", "files"), FALSE, FALSE) + found <- file_exists(expected, workdir = path) + if (any(!found)) { + stop("Script did not produce expected artefacts: ", + paste(squote(expected[!found]), collapse = ", ")) + } +} + + +## Same logic as orderly1; has worked well in practice. We might want +## to relax additional parameters here later, but that requires some +## thinking about what to do with them (do they get passed into the +## environment etc or not? do they get validated?) +check_parameters <- function(given, spec) { + if (length(given) > 0) { + assert_named(given, unique = TRUE) + } + + is_required <- vlapply(spec, is.null) + + msg <- setdiff(names(spec)[is_required], names(given)) + if (length(msg) > 0L) { + stop("Missing parameters: ", paste(squote(msg), collapse = ", ")) + } + extra <- setdiff(names(given), names(spec)) + if (length(extra) > 0L) { + stop("Extra parameters: ", paste(squote(extra), collapse = ", ")) + } + if (length(spec) == 0) { + return(NULL) + } + + check_parameter_values(given, FALSE) + + use_default <- setdiff(names(spec), names(given)) + if (length(use_default) > 0) { + given[use_default] <- spec[use_default] + } + given[names(spec)] +} + + +check_parameter_values <- function(given, defaults) { + name <- if (defaults) "parameter defaults" else "parameters" + if (defaults) { + given <- given[!vlapply(given, is.null)] + } + + nonscalar <- lengths(given) != 1 + if (any(nonscalar)) { + stop(sprintf( + "Invalid %s: %s - must be scalar", + name, paste(squote(names(nonscalar[nonscalar])), collapse = ", "))) + } + + err <- !vlapply(given, function(x) { + is.character(x) || is.numeric(x) || is.logical(x) + }) + if (any(err)) { + stop(sprintf( + "Invalid %s: %s - must be character, numeric or logical", + name, paste(squote((names(err[err]))), collapse = ", "))) + } +} + + +check_parameters_interactive <- function(env, spec) { + if (length(spec) == 0) { + return() + } + + is_required <- vlapply(spec, is.null) + + msg <- setdiff(names(spec)[is_required], names(env)) + if (length(msg) > 0L) { + ## This will change, but we'll need some interactive prompting + ## better done in another ticket. See + ## https://github.com/r-lib/cli/issues/228 and + ## https://github.com/r-lib/cli/issues/488 for context here. + ## + ## There will be other "interactive mode" functions too that we'll + ## try and get a unified interface on. + stop("Missing parameters: ", paste(squote(msg), collapse = ", ")) + } + + ## Set any missing values into the environment: + list2env(spec[setdiff(names(spec), names(env))], env) + + ## We might need a slightly better error message here that indicates + ## that we're running in a pecular mode so the value might just have + ## been overwritten + found <- lapply(names(spec), function(v) env[[v]]) + check_parameter_values(found[!vlapply(found, is.null)], FALSE) +} diff --git a/R/schema.R b/R/schema.R index 48397302..432a4257 100644 --- a/R/schema.R +++ b/R/schema.R @@ -1,10 +1,31 @@ cache <- new.env(parent = emptyenv()) -custom_metadata_schema <- function() { +custom_metadata_schema <- function(config) { if (is.null(cache$custom_metadata_schema)) { path <- system.file("outpack-custom.json", package = "orderly3", mustWork = TRUE) cache$custom_metadata_schema <- paste(readLines(path), collapse = "\n") } - cache$custom_metadata_schema + schema <- cache$custom_metadata_schema + + ## This is pretty ugly, but we rewrite the orderly3 schema to inject + ## the new bits from plugins. This would be much easier to do if we + ## could easily manipulate the json directly, but that will require + ## at least a V8 dependency (which is not terrible as we'll need + ## that for jsonvalidate anyway if we were doing schema + ## validation). The other thing to try would be to update + ## jsonvalidate to allow adding in custom references and don't + ## interpolate the entire contents of the schema but just the + ## reference to it, then provide a list of definitions. + plugins <- Filter(Negate(is.null), lapply(config$plugins, "[[", "schema")) + if (length(plugins) > 0) { + re <- '"plugins": \\{\\s+\\}' + stopifnot(grepl(re, schema)) + str <- c('"plugins": {', + ' "type": "object",', + paste0(' "properties": ', to_json(plugins, pretty = TRUE)), + "}") + schema <- sub(re, paste(str, collapse = "\n"), schema) + } + schema } diff --git a/R/util.R b/R/util.R index 8feb06e5..8ff24910 100644 --- a/R/util.R +++ b/R/util.R @@ -6,3 +6,72 @@ is_directory <- function(x) { file.info(x, extra_cols = FALSE)$isdir } + + +is_call <- function(x, name) { + is.recursive(x) && is.name(x[[1]]) && as.character(x[[1]]) == name +} + + +is_orderly_call <- function(x, name) { + is_call(x, name) || ( + is.recursive(x) && + is_call(x[[1]], "::") && + as.character(x[[1]][[2]]) == "orderly3" && + as.character(x[[1]][[3]]) == name) +} + + +vlapply <- function(X, FUN, ...) { # nolint + vapply(X, FUN, logical(1), ...) +} + + +set_names <- function(x, nms) { + names(x) <- nms + x +} + + +dir_ls_local <- function(path, ...) { + withr::with_dir(path, fs::dir_ls(path = ".", ...)) +} + + +scalar <- function(x) { + jsonlite::unbox(x) +} + + +to_json <- function(obj, pretty = FALSE) { + jsonlite::toJSON(obj, pretty = pretty, auto_unbox = FALSE, na = "null", + null = "null", json_verbatim = TRUE, digits = NA) +} + + +rep_along <- function(x, v) { + rep_len(x, length(v)) +} + + +data_frame <- function(...) { + data.frame(..., stringsAsFactors = FALSE, check.names = FALSE) +} + + +squote <- function(x) { + sprintf("'%s'", x) +} + + +drop_null <- function(x, empty) { + i <- vlapply(x, is.null) + if (all(i)) empty else x[!i] +} + + +replace_ragged <- function(x, i, values) { + ret <- as.list(x) + ret[i] <- values + unlist(ret, FALSE, FALSE) +} diff --git a/inst/outpack-custom.json b/inst/outpack-custom.json index 102547ee..5abc8bdd 100644 --- a/inst/outpack-custom.json +++ b/inst/outpack-custom.json @@ -66,6 +66,8 @@ "type": ["null", "string"] }, "custom": { + }, + "plugins": { } }, "additionalProperties": false, diff --git a/man/orderly_artefact.Rd b/man/orderly_artefact.Rd new file mode 100644 index 00000000..1ed918e0 --- /dev/null +++ b/man/orderly_artefact.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/metadata.R +\name{orderly_artefact} +\alias{orderly_artefact} +\title{Declare orderly artefacts} +\usage{ +orderly_artefact(description, files) +} +\arguments{ +\item{description}{The name of the artefact} + +\item{files}{The files within this artefact} +} +\value{ +Undefined +} +\description{ +Declare an artefact. By doing this you turn on a number of orderly +features; see Details below. You can have multiple calls to this +function within your orderly script. +} +\details{ +(1) files matching this will \emph{not} be copied over from the src +directory to the draft directory unless they are also listed as a +resource with \code{\link[=orderly_resource]{orderly_resource()}}. This feature is only enabled +if you call this function from the top level of the orderly script +and if it contains only string literals (no variables). + +(2) if your script fails to produce these files, then +\code{\link[=orderly_run]{orderly_run()}} will fail, guaranteeing that your task does really +produce the things you need it to. + +(3) within the final metadata, your artefacts will have additional +metadata; the description that you provide and a grouping +} diff --git a/man/orderly_dependency.Rd b/man/orderly_dependency.Rd new file mode 100644 index 00000000..b51d27f6 --- /dev/null +++ b/man/orderly_dependency.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/metadata.R +\name{orderly_dependency} +\alias{orderly_dependency} +\title{Declare a dependency} +\usage{ +orderly_dependency(name, query, use) +} +\arguments{ +\item{name}{The name of the packet to depend on} + +\item{query}{The query to search for; often this will simply be +the string \code{latest}, indicating the most recent version. You may +want a more complex query here though.} + +\item{use}{A named character vector of filenames to copy from the +upstream packet. The name corresponds to the destination name, +so c(here.csv = "there.csv") will take the upstream file +\code{there.csv} and copy it over as \code{here.csv}.} +} +\value{ +Undefined +} +\description{ +Declare a dependency on another packet +} diff --git a/man/orderly_global_resource.Rd b/man/orderly_global_resource.Rd new file mode 100644 index 00000000..49b4c073 --- /dev/null +++ b/man/orderly_global_resource.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/metadata.R +\name{orderly_global_resource} +\alias{orderly_global_resource} +\title{Copy global resources into a packet directory} +\usage{ +orderly_global_resource(...) +} +\arguments{ +\item{...}{Named arguments corresponding to global resources to +copy. The name will be the destination filename, while the value +is the filename within the global resource directory.} +} +\value{ +Undefined +} +\description{ +Copy global resources into a packet directory. You can use this to +share common resources (data or code) between multiple packets. +Additional metadata will be added to keep track of where the files +came from. Using this function requires that the orderly +repository has global resources enabled, with a +\verb{global_resources:} section in the \code{orderly_config.yml}; an error +will be raised if this is not configured. +} diff --git a/man/orderly_parameters.Rd b/man/orderly_parameters.Rd new file mode 100644 index 00000000..42c2d597 --- /dev/null +++ b/man/orderly_parameters.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/metadata.R +\name{orderly_parameters} +\alias{orderly_parameters} +\title{Declare orderly parameters} +\usage{ +orderly_parameters(...) +} +\arguments{ +\item{...}{Any number of parameters} +} +\value{ +Undefined +} +\description{ +Declare orderly parameters. You should only have one call to this +within your file, though this is not enforced! Typically you'd put +it very close to the top, though the order does not really matter. +Parameters are scalar atomic values (e.g. a string, number or +boolean) and defaults must be present literally (i.e., they may +not come from a variable itself). Provide \code{NULL} if you do not +have a default, in which case this parameter will be required. +} diff --git a/man/orderly_plugin_add_metadata.Rd b/man/orderly_plugin_add_metadata.Rd new file mode 100644 index 00000000..438ef52a --- /dev/null +++ b/man/orderly_plugin_add_metadata.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plugin.R +\name{orderly_plugin_add_metadata} +\alias{orderly_plugin_add_metadata} +\title{Add metadata from plugin} +\usage{ +orderly_plugin_add_metadata(name, field, data) +} +\arguments{ +\item{name}{The name of the plugin; must be the same as used in +\link{orderly_plugin_register} and +\link{orderly_plugin_context}} + +\item{field}{The name of a field to add the data to. This is +required even if your plugin only produces one sort of data, in +which case you can remove it later on within your serialisation +function.} + +\item{data}{Arbitrary data to be added to the currently running +packet} +} +\value{ +Nothing, called only for its side effects +} +\description{ +Add plugin-specific metadata to a running packet. This will take +some describing. You accumulate any number of bits of metadata +into arbitrary fields, and then later on serialise these to json. +} diff --git a/man/orderly_plugin_context.Rd b/man/orderly_plugin_context.Rd new file mode 100644 index 00000000..a06b9f3e --- /dev/null +++ b/man/orderly_plugin_context.Rd @@ -0,0 +1,56 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plugin.R +\name{orderly_plugin_context} +\alias{orderly_plugin_context} +\title{Fetch plugin context} +\usage{ +orderly_plugin_context(name) +} +\arguments{ +\item{name}{Name of the plugin} +} +\value{ +A list with elements: +\itemize{ +\item \code{is_active}: a logical, indicating if we're running under +\code{\link[=orderly_run]{orderly_run()}}; you may need to change behaviour +depending on this value. +\item \code{path}: the path of the running packet. This is almost always the +working directory, unless the packet contains calls to \code{\link[=setwd]{setwd()}} +or similar. You may create files here. +\item \code{config}: the configuration for this plugin, after processing +with the plugin's \code{read} function (see +\code{\link{orderly_plugin_register}}) +\item \code{env}: the environment that the packet is running in. Often this +will be the global environment, but do not assume this! You may +read and write from this environment. +\item \code{src}: the path to the packet source directory. This is +different to the current directory when the packet is running, +but the same when the user is interactively working with a +report. You may \emph{read} from this directory but \emph{must not write +to it} +\item \code{parameters}: the parameters as passed through to the run the +report. +} +} +\description{ +Fetch the running context, for use within a plugin. The intention +here is that within free functions that your plugin makes +available, you will call this function to get information about +the state of a packet. You will then typically call +\code{\link[=orderly_plugin_add_metadata]{orderly_plugin_add_metadata()}} afterwards. +} +\details{ +When a plugin function is called, orderly3 will be running in one +of two modes; (1) from within \code{\link[=orderly_run]{orderly_run()}}, in +which case we're part way through creating a packet in a brand new +directory, and possibly using a special environment for +evaluation, or (2) interactively, with a user developing their +report. The plugin needs to be able to support both modes, and +this function will return information about the state to help you +cope with either case. +} +\seealso{ +\link{orderly_plugin_register}, +\link{orderly_plugin_add_metadata} +} diff --git a/man/orderly_plugin_register.Rd b/man/orderly_plugin_register.Rd new file mode 100644 index 00000000..92976d1f --- /dev/null +++ b/man/orderly_plugin_register.Rd @@ -0,0 +1,49 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plugin.R +\name{orderly_plugin_register} +\alias{orderly_plugin_register} +\title{Register an orderly plugin} +\usage{ +orderly_plugin_register(name, config, serialise, cleanup = NULL, schema = NULL) +} +\arguments{ +\item{name}{The name of the plugin, typically the package name} + +\item{config}{A function to read, check and process the +configuration section in \code{orderly_config.yml}. This function +will be passed the deserialised data from the plugin's section +of \code{orderly_config.yml}, and the full path to that file. As the +order of loading of plugins is not defined, each plugin must +standalone and should not try and interact with other plugins at +load. It should return a processed copy of the configuration +data, to be passed in as the second argument to \code{read}.} + +\item{serialise}{A function to serialise any metadata added by the +plugin's functions to the outpack metadata. It will be passed a +list of all entries pushed in via +\code{\link[=orderly_plugin_add_metadata]{orderly_plugin_add_metadata()}}; this is a named +list with names corresponding to the \code{field} argument to +\code{orderly_plugin_add_metadata} and each list element being an +unnamed list with values corresponding to \code{data}.} + +\item{cleanup}{Optionally, a function to clean up any state that +your plugin uses. You can call \code{orderly_plugin_context} from +within this function and access anything you need from that. If +not given, then no cleanup is done.} + +\item{schema}{Optionally a path to a schema for the metadata +created by this plugin. See \code{vignette("plugins")} for details.} +} +\value{ +Nothing, this function is called for its side effect of +registering a plugin. +} +\description{ +Create an orderly plugin. A plugin is typically defined by a +package and is used to extend orderly by enabling new +functionality, declared in \code{orderly_config.yml} and \code{orderly.R} +and affecting the running of reports primarily by creating new +objects in the report environment. This system is discussed in +more detail in \code{vignette("plugins")}, but will be expanded (likely +in breaking ways) soon. +} diff --git a/man/orderly_resource.Rd b/man/orderly_resource.Rd new file mode 100644 index 00000000..a1906911 --- /dev/null +++ b/man/orderly_resource.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/metadata.R +\name{orderly_resource} +\alias{orderly_resource} +\title{Declare orderly resources} +\usage{ +orderly_resource(files) +} +\arguments{ +\item{files}{Any number of names of files} +} +\value{ +Undefined +} +\description{ +Declare that a file, or group of files, are an orderly +resource. By explicitly declaring files as resources orderly will +mark the files as immutable inputs and validate that your analysis +does not modify them when run with \code{\link[=orderly_run]{orderly_run()}} +} diff --git a/man/orderly_run.Rd b/man/orderly_run.Rd new file mode 100644 index 00000000..4de48b78 --- /dev/null +++ b/man/orderly_run.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run.R +\name{orderly_run} +\alias{orderly_run} +\title{Run a report} +\usage{ +orderly_run(name, parameters = NULL, envir = NULL, root = NULL, locate = TRUE) +} +\arguments{ +\item{name}{Name of the report to run} + +\item{parameters}{Parameters passed to the report. A named list of +parameters declared in the \code{orderly.yml}. Each parameter +must be a scalar character, numeric, integer or logical.} + +\item{envir}{The environment that will be used to evaluate the +report script; by default we use the global environment, which +may not always be what is wanted.} + +\item{root}{The path to an orderly root directory, or \code{NULL} +(the default) to search for one from the current working +directory if \code{locate} is \code{TRUE}.} + +\item{locate}{Logical, indicating if the configuration should be +searched for. If \code{TRUE} and \code{config} is not given, +then orderly looks in the working directory and up through its +parents until it finds an \code{.outpack} directory} +} +\value{ +The id of the created report (a string) +} +\description{ +Run a report. This will create a new directory in +\verb{drafts/}, copy your declared resources there, run +your script and check that all expected artefacts were created. +} diff --git a/tests/testthat/examples/computed-resource/data.csv b/tests/testthat/examples/computed-resource/data.csv new file mode 100644 index 00000000..31d0c9da --- /dev/null +++ b/tests/testthat/examples/computed-resource/data.csv @@ -0,0 +1,11 @@ +x,y +1,2 +2,4 +3,6 +4,8 +5,10 +6,12 +7,14 +8,16 +9,18 +10,20 diff --git a/tests/testthat/examples/computed-resource/orderly.R b/tests/testthat/examples/computed-resource/orderly.R new file mode 100644 index 00000000..e5dc062c --- /dev/null +++ b/tests/testthat/examples/computed-resource/orderly.R @@ -0,0 +1,8 @@ +files <- dir(pattern = "*.csv") +orderly3::orderly_resource(files) +orderly3::orderly_artefact("A graph of things", "mygraph.png") + +data <- read.csv("data.csv", stringsAsFactors = FALSE) +png("mygraph.png") +plot(data) +dev.off() diff --git a/tests/testthat/examples/depends/orderly.R b/tests/testthat/examples/depends/orderly.R new file mode 100644 index 00000000..0c91ba6d --- /dev/null +++ b/tests/testthat/examples/depends/orderly.R @@ -0,0 +1,2 @@ +orderly3::orderly_dependency("explicit", "latest", c(graph.png = "mygraph.png")) +orderly3::orderly_artefact("Final plot", "graph.png") diff --git a/tests/testthat/examples/explicit/data.csv b/tests/testthat/examples/explicit/data.csv new file mode 100644 index 00000000..31d0c9da --- /dev/null +++ b/tests/testthat/examples/explicit/data.csv @@ -0,0 +1,11 @@ +x,y +1,2 +2,4 +3,6 +4,8 +5,10 +6,12 +7,14 +8,16 +9,18 +10,20 diff --git a/tests/testthat/examples/explicit/orderly.R b/tests/testthat/examples/explicit/orderly.R new file mode 100644 index 00000000..c04f6e21 --- /dev/null +++ b/tests/testthat/examples/explicit/orderly.R @@ -0,0 +1,7 @@ +orderly3::orderly_resource("data.csv") +orderly3::orderly_artefact("A graph of things", "mygraph.png") + +data <- read.csv("data.csv", stringsAsFactors = FALSE) +png("mygraph.png") +plot(data) +dev.off() diff --git a/tests/testthat/examples/global-dir/orderly.R b/tests/testthat/examples/global-dir/orderly.R new file mode 100644 index 00000000..4bf5ec6f --- /dev/null +++ b/tests/testthat/examples/global-dir/orderly.R @@ -0,0 +1,7 @@ +orderly3::orderly_global_resource(global_data = "data") +orderly3::orderly_artefact("combined data", "output.rds") + +files <- dir("global_data") +dat <- lapply(file.path("global_data", files), read.csv) +names(dat) <- sub("\\..+$", "", files) +saveRDS(dat, "output.rds") diff --git a/tests/testthat/examples/global/orderly.R b/tests/testthat/examples/global/orderly.R new file mode 100644 index 00000000..ee221b6c --- /dev/null +++ b/tests/testthat/examples/global/orderly.R @@ -0,0 +1,7 @@ +orderly3::orderly_global_resource(global_data.csv = "data.csv") +orderly3::orderly_artefact("A graph of things", "mygraph.png") + +data <- read.csv("global_data.csv", stringsAsFactors = FALSE) +png("mygraph.png") +plot(data) +dev.off() diff --git a/tests/testthat/examples/implicit/data.csv b/tests/testthat/examples/implicit/data.csv new file mode 100644 index 00000000..31d0c9da --- /dev/null +++ b/tests/testthat/examples/implicit/data.csv @@ -0,0 +1,11 @@ +x,y +1,2 +2,4 +3,6 +4,8 +5,10 +6,12 +7,14 +8,16 +9,18 +10,20 diff --git a/tests/testthat/examples/implicit/orderly.R b/tests/testthat/examples/implicit/orderly.R new file mode 100644 index 00000000..d9544076 --- /dev/null +++ b/tests/testthat/examples/implicit/orderly.R @@ -0,0 +1,4 @@ +data <- read.csv("data.csv", stringsAsFactors = FALSE) +png("mygraph.png") +plot(data) +dev.off() diff --git a/tests/testthat/examples/parameters/orderly.R b/tests/testthat/examples/parameters/orderly.R new file mode 100644 index 00000000..4ea8f157 --- /dev/null +++ b/tests/testthat/examples/parameters/orderly.R @@ -0,0 +1,2 @@ +orderly3::orderly_parameters(a = NULL, b = 2, c = NULL) +saveRDS(list(a = a, b = b, c = c), "data.rds") diff --git a/tests/testthat/examples/plugin/orderly.R b/tests/testthat/examples/plugin/orderly.R new file mode 100644 index 00000000..6a03013d --- /dev/null +++ b/tests/testthat/examples/plugin/orderly.R @@ -0,0 +1,3 @@ +orderly3::orderly_artefact("Generated data", "data.rds") +example.random::numbers("dat", 10) +saveRDS(dat, "data.rds") diff --git a/tests/testthat/helper-orderly.R b/tests/testthat/helper-orderly.R new file mode 100644 index 00000000..ba5caeb7 --- /dev/null +++ b/tests/testthat/helper-orderly.R @@ -0,0 +1,62 @@ +options(outpack.schema_validate = + requireNamespace("jsonvalidate", quietly = TRUE) && + packageVersion("jsonvalidate") >= "1.4.0") + + +test_prepare_orderly_example <- function(examples, ...) { + tmp <- tempfile() + withr::defer_parent(unlink(tmp, recursive = TRUE)) + orderly_init(tmp) + + config <- character() + + if (any(c("global", "global-dir") %in% examples)) { + config <- c(config, + "global_resources: global") + fs::dir_create(file.path(tmp, "global")) + if ("global" %in% examples) { + fs::file_copy(test_path("examples/explicit/data.csv"), + file.path(tmp, "global")) + } + if ("global-dir" %in% examples) { + fs::dir_create(file.path(tmp, "global", "data")) + } + } + + if ("plugin" %in% examples) { + register_example_plugin() + config <- c(config, + "plugins:", + " example.random:", + " distribution:", + " normal") + } + + writeLines(config, file.path(tmp, "orderly_config.yml")) + fs::dir_create(file.path(tmp, "src")) + for (i in examples) { + fs::dir_copy(test_path("examples", i), file.path(tmp, "src")) + } + tmp +} + + +test_path <- function(...) { + if (basename(getwd()) == "testthat") { + file.path(...) + } else { + testthat::test_path(...) + } +} + + +clear_plugins <- function() { + rm(list = ls(envir = .plugins), envir = .plugins) +} + + +register_example_plugin <- function() { + testthat::skip_if_not_installed("pkgload") + pkgload::load_all(test_path("plugins/example.random"), + quiet = TRUE, export_all = FALSE) +} diff --git a/tests/testthat/plugins/example.random/DESCRIPTION b/tests/testthat/plugins/example.random/DESCRIPTION new file mode 100644 index 00000000..40a74cdf --- /dev/null +++ b/tests/testthat/plugins/example.random/DESCRIPTION @@ -0,0 +1,12 @@ +Package: example.random +Title: Example Random Numbers +Version: 0.0.1 +Authors@R: c(person("Rich", "FitzJohn", role = c("aut", "cre"), + email = "rich.fitzjohn@gmail.com"), + person("Robert", "Ashton", role = "aut"), + person("Alex", "Hill", role = "aut"), + person("Imperial College of Science, Technology and Medicine", + role = "cph")) +Description: Example random numbers. +License: CC0 +Imports: orderly3 diff --git a/tests/testthat/plugins/example.random/NAMESPACE b/tests/testthat/plugins/example.random/NAMESPACE new file mode 100644 index 00000000..ee4425b0 --- /dev/null +++ b/tests/testthat/plugins/example.random/NAMESPACE @@ -0,0 +1 @@ +export(numbers) diff --git a/tests/testthat/plugins/example.random/R/random.R b/tests/testthat/plugins/example.random/R/random.R new file mode 100644 index 00000000..30f2ead7 --- /dev/null +++ b/tests/testthat/plugins/example.random/R/random.R @@ -0,0 +1,36 @@ +numbers <- function(as, n) { + ctx <- orderly3::orderly_plugin_context("example.random") + x <- ctx$config$generator(n) + ctx$env[[as]] <- x + info <- list(as = as, mean = mean(x), variance = var(x)) + orderly3::orderly_plugin_add_metadata("example.random", "numbers", info) + invisible() +} + +config <- function(data, filename) { + orderly3:::assert_named( + data, name = paste0(filename, ":example.random")) + orderly3:::assert_scalar_character( + data$distribution, + paste0(filename, ":example.random:distribution")) + data$generator <- switch( + data$distribution, + normal = rnorm, + uniform = runif, + stop(sprintf("Unknown value '%s' for '%s:example.random:distribution'", + data$distribution, filename))) + data +} + +serialise <- function(data) { + jsonlite::toJSON(data$numbers, auto_unbox = TRUE, digits = NA) +} + +.onLoad <- function(...) { + schema <- system.file("schema.json", package = "example.random", + mustWork = TRUE) + orderly3::orderly_plugin_register("example.random", + config = config, + serialise = serialise, + schema = schema) +} diff --git a/tests/testthat/plugins/example.random/inst/schema.json b/tests/testthat/plugins/example.random/inst/schema.json new file mode 100644 index 00000000..3e4d62ae --- /dev/null +++ b/tests/testthat/plugins/example.random/inst/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + + "type": "array", + "items": { + "type": "object", + "properties": { + "as": { + "type": "string", + }, + "mean": { + "type": "number" + }, + "variance": { + "type": "number" + } + }, + "required": ["as", "mean", "variance"], + "additionalProperties": false + } +} diff --git a/tests/testthat/test-interactive.R b/tests/testthat/test-interactive.R new file mode 100644 index 00000000..5aaa5c69 --- /dev/null +++ b/tests/testthat/test-interactive.R @@ -0,0 +1,14 @@ +test_that("can detect orderly directory", { + path <- test_prepare_orderly_example("explicit") + env <- new.env() + id <- orderly_run("explicit", root = path, envir = env) + + expect_error( + detect_orderly_interactive_path(path), + "Failed to detect orderly path at") + expect_error( + detect_orderly_interactive_path(file.path(path, "src")), + "Failed to detect orderly path at") + root <- detect_orderly_interactive_path(file.path(path, "src", "explicit")) + expect_s3_class(root, "orderly_root") +}) diff --git a/tests/testthat/test-parameters.R b/tests/testthat/test-parameters.R new file mode 100644 index 00000000..2b148674 --- /dev/null +++ b/tests/testthat/test-parameters.R @@ -0,0 +1,105 @@ +test_that("prevent missing parameters", { + expect_error( + check_parameters(list(), list(a = NULL)), + "Missing parameters: 'a'") + expect_error( + check_parameters(list(), list(a = NULL, b = NULL)), + "Missing parameters: 'a', 'b'") + expect_error( + check_parameters(list(a = 1), list(a = NULL, b = NULL)), + "Missing parameters: 'b'") + expect_error( + check_parameters(list(a = 1), list(a = NULL, b = NULL, c = NULL)), + "Missing parameters: 'b', 'c'") + expect_error( + check_parameters(list(a = 1), list(a = NULL, b = 2, c = NULL)), + "Missing parameters: 'c'") +}) + + +test_that("prevent extra parameters", { + expect_error( + check_parameters(list(a = 1), NULL), + "Extra parameters: 'a'") + expect_error( + check_parameters(list(a = 1, b = 2), NULL), + "Extra parameters: 'a', 'b'") + expect_error( + check_parameters(list(a = 1, b = 2), list(a = NULL)), + "Extra parameters: 'b'") +}) + + +test_that("combine default and given parameters", { + expect_equal( + check_parameters(list(a = 1, b = 2), list(a = NULL, b = NULL)), + list(a = 1, b = 2)) + expect_equal( + check_parameters(list(b = 2, a = 1), list(a = 10, b = 20)), + list(a = 1, b = 2)) + expect_equal( + check_parameters(list(a = 1), list(a = NULL, b = 20)), + list(a = 1, b = 20)) + expect_equal( + check_parameters(NULL, list(a = 10, b = 20)), + list(a = 10, b = 20)) +}) + + +test_that("do nothing when no spec given", { + env <- new.env() + expect_null(check_parameters_interactive(env, NULL)) + expect_equal(ls(env), character()) +}) + + +test_that("set defaults into environment if missing", { + env <- new.env() + check_parameters_interactive(env, list(a = 1, b = 2)) + expect_setequal(names(env), c("a", "b")) + expect_equal(env$a, 1) + expect_equal(env$b, 2) +}) + + +test_that("require non-default parameters are present in environment", { + env <- list2env(list(b = 3, c = 4), parent = new.env()) + expect_error( + check_parameters_interactive(env, list(a = NULL, b = NULL, c = NULL)), + "Missing parameters: 'a'") +}) + + +test_that("parameters must be atomic scalars", { + expect_error( + check_parameters(list(a = NULL, b = 2), list(a = NULL, b = NULL)), + "Invalid parameters: 'a' - must be scalar") + expect_error( + check_parameters(list(a = NULL, b = 2:10), list(a = NULL, b = NULL)), + "Invalid parameters: 'a', 'b' - must be scalar") + expect_error( + check_parameters(list(a = data, b = 2), list(a = NULL, b = NULL)), + "Invalid parameters: 'a' - must be character, numeric or logical") + expect_error( + check_parameters(list(a = data, b = 2 + 1i), list(a = NULL, b = NULL)), + "Invalid parameters: 'a', 'b' - must be character, numeric or logical") +}) + + +test_that("parse parameter metadata", { + expect_null(static_orderly_parameters(list())) + expect_equal(static_orderly_parameters(list(a = NULL)), + list(a = NULL)) + expect_equal(static_orderly_parameters(list(a = 1)), + list(a = 1)) +}) + + +test_that("defaults must be valid", { + expect_error( + static_orderly_parameters(list(a = 1:2)), + "Invalid parameter defaults: 'a' - must be scalar") + expect_error( + static_orderly_parameters(list(a = data)), + "Invalid parameter defaults: 'a' - must be character, numeric or logical") +}) diff --git a/tests/testthat/test-plugin.R b/tests/testthat/test-plugin.R new file mode 100644 index 00000000..fa8484b6 --- /dev/null +++ b/tests/testthat/test-plugin.R @@ -0,0 +1,126 @@ +test_that("Can run simple example with plugin", { + path <- test_prepare_orderly_example("plugin") + + env <- new.env() + set.seed(1) + id <- orderly_run("plugin", root = path, envir = env) + + set.seed(1) + cmp <- rnorm(10) + + expect_identical(env$dat, cmp) + + root <- orderly_root(path, locate = FALSE) + meta <- root$outpack$metadata(id, full = TRUE) + + ## Our nice vectors have become lists here, due to the general pain + ## of deserialising json, into R but at least it's all there. + ## Probably the most general solution involves plugins being able to + ## provide deserialisers that can apply any required simplification? + expect_equal( + meta$custom$orderly$plugins$example.random, + list(list(as = "dat", mean = mean(cmp), variance = var(cmp)))) + expect_equal(readRDS(file.path(path, "archive", "plugin", id, "data.rds")), + cmp) +}) + + +test_that("can run interactive example with plugin", { + path <- test_prepare_orderly_example("plugin") + + env <- new.env() + set.seed(1) + path_src <- file.path(path, "src", "plugin") + withr::with_dir(path_src, + sys.source("orderly.R", env)) + + set.seed(1) + cmp <- rnorm(10) + + expect_identical(env$dat, cmp) + expect_setequal(dir(path_src), c("data.rds", "orderly.R")) + expect_equal(readRDS(file.path(path_src, "data.rds")), cmp) +}) + + +test_that("loading plugin triggers package load", { + skip_if_not_installed("mockery") + clear_plugins() + on.exit(clear_plugins()) + + mock_load_namespace <- mockery::mock(register_example_plugin()) + mockery::stub(load_orderly_plugin, "loadNamespace", mock_load_namespace) + + plugin <- load_orderly_plugin("example.random") + mockery::expect_called(mock_load_namespace, 1) + expect_equal(mockery::mock_args(mock_load_namespace)[[1]], + list("example.random")) + expect_s3_class(plugin, "orderly_plugin") + expect_identical(plugin, .plugins$example.random) +}) + + +test_that("error if load fails to register plugin", { + skip_if_not_installed("mockery") + clear_plugins() + on.exit(clear_plugins()) + + mock_load_namespace <- mockery::mock() + mockery::stub(load_orderly_plugin, "loadNamespace", mock_load_namespace) + + expect_error(load_orderly_plugin("example.random"), + "Plugin 'example.random' not found") + mockery::expect_called(mock_load_namespace, 1) + expect_equal(mockery::mock_args(mock_load_namespace)[[1]], + list("example.random")) +}) + + +test_that("don't load package if plugin already loaded", { + skip_if_not_installed("mockery") + register_example_plugin() + mock_load_namespace <- mockery::mock() + mockery::stub(load_orderly_plugin, "loadNamespace", mock_load_namespace) + plugin <- load_orderly_plugin("example.random") + mockery::expect_called(mock_load_namespace, 0) + expect_s3_class(plugin, "orderly_plugin") + expect_identical(plugin, .plugins$example.random) +}) + + +test_that("error if packet uses non-configured plugin", { + path <- test_prepare_orderly_example("plugin") + file.create(file.path(path, "orderly_config.yml")) + + env <- new.env() + expect_error( + orderly_run("plugin", root = path, envir = env), + "Plugin 'example.random' not enabled in 'orderly_config.yml'", + fixed = TRUE) +}) + + +test_that("error if environment not found", { + expect_error( + orderly_environment("unknown.package"), + "Could not determine calling environment safely - please report") +}) + + +test_that("run cleanup on exit", { + skip_if_not_installed("mockery") + clear_plugins() + on.exit(clear_plugins()) + + path <- test_prepare_orderly_example("plugin") + mock_cleanup <- mockery::mock() + .plugins$example.random$cleanup <- mock_cleanup + + env <- new.env() + set.seed(1) + id <- orderly_run("plugin", root = path, envir = env) + + mockery::expect_called(mock_cleanup, 1) + expect_equal( + mockery::mock_args(.plugins$example.random$cleanup)[[1]], list()) +}) diff --git a/tests/testthat/test-read.R b/tests/testthat/test-read.R new file mode 100644 index 00000000..0d5e0d0a --- /dev/null +++ b/tests/testthat/test-read.R @@ -0,0 +1,64 @@ +test_that("can read file with no helpers", { + expect_equal(orderly_read_r("examples/implicit/orderly.R"), list()) +}) + + +test_that("can read file with helpers", { + dat <- orderly_read_r("examples/explicit/orderly.R") + expect_setequal(names(dat), c("resources", "artefacts")) + expect_equal(dat$resources, "data.csv") + expect_equal(dat$artefacts, + list(list(description = "A graph of things", + files = "mygraph.png"))) +}) + + +test_that("Skip over computed resources", { + dat <- orderly_read_r("examples/computed-resource/orderly.R") + expect_null(dat$resources) +}) + + +test_that("Can read string vector literals from expressions", { + expect_equal(static_character_vector(quote("x")), "x") + expect_equal(static_character_vector(quote(c("x"))), "x") + expect_equal(static_character_vector(quote(c("x", "y"))), c("x", "y")) + expect_equal(static_character_vector(quote(c("x", c("y", "z")))), + c("x", "y", "z")) + + expect_null(static_character_vector(quote(a))) + expect_null(static_character_vector(quote(c(a)))) + expect_null(static_character_vector(quote(c(a, "x")))) + expect_null(static_character_vector(quote(c(a, b)))) + expect_null(static_character_vector(quote(c("x", c(a, b))))) + expect_null(static_character_vector(quote(c("x", c("y", b))))) + expect_null(static_character_vector(quote(c(a, c("x", "y"))))) +}) + + +test_that("Can read string from expressions", { + expect_equal(static_string(quote("x")), "x") + expect_equal(static_string(quote(c("x"))), "x") + + expect_null(static_string(quote(a))) + expect_null(static_string(quote(c(a)))) +}) + + +test_that("read dependency", { + args <- list(name = "a", query = "latest", use = c(x = "y")) + expect_equal(static_orderly_dependency(args), args) + + expect_null( + static_orderly_dependency(list(name = quote(a), + query = "latest", + use = c(x = "y")))) + expect_null( + static_orderly_dependency(list(name = "a", + query = quote(latest), + use = c(x = "y")))) + expect_null( + static_orderly_dependency(list(name = "a", + query = "latest", + use = quote(use)))) +}) diff --git a/tests/testthat/test-run.R b/tests/testthat/test-run.R new file mode 100644 index 00000000..ff232861 --- /dev/null +++ b/tests/testthat/test-run.R @@ -0,0 +1,268 @@ +test_that("can run simple task with explicit inputs and outputs", { + path <- test_prepare_orderly_example("explicit") + env <- new.env() + id <- orderly_run("explicit", root = path, envir = env) + expect_type(id, "character") + expect_length(id, 1) + expect_match(id, "^[0-9]{8}-[0-9]{6}-[[:xdigit:]]{8}$") + + ## All outputs as expected + path_res <- file.path(path, "archive", "explicit", id) + expect_true(is_directory(path_res)) + expect_setequal(dir(path_res), c("orderly.R", "mygraph.png", "data.csv")) + + ## Nothing left in drafts + expect_true(is_directory(file.path(path, "draft", "explicit"))) + expect_false(file.exists(file.path(path, "draft", "explicit", id))) + + ## Nothing extra in src + expect_setequal(dir(file.path(path, "src", "explicit")), + c("orderly.R", "data.csv")) + + root <- orderly_root(path, FALSE) + idx <- root$outpack$index() + expect_equal(names(idx$metadata), id) + meta <- root$outpack$metadata(id, full = TRUE) + + expect_equal( + meta$custom$orderly$role, + list(list(path = "data.csv", role = "resource"))) + expect_equal( + meta$custom$orderly$artefacts, + list(list(description = "A graph of things", + paths = list("mygraph.png")))) +}) + + +test_that("can run simple task with implicit inputs and outputs", { + path <- test_prepare_orderly_example("implicit") + env <- new.env() + id <- orderly_run("implicit", root = path, envir = env) + expect_type(id, "character") + expect_length(id, 1) + expect_match(id, "^[0-9]{8}-[0-9]{6}-[[:xdigit:]]{8}$") + + ## All outputs as expected + path_res <- file.path(path, "archive", "implicit", id) + expect_true(is_directory(path_res)) + expect_setequal(dir(path_res), c("orderly.R", "mygraph.png", "data.csv")) + + ## Nothing left in drafts + expect_true(is_directory(file.path(path, "draft", "implicit"))) + expect_false(file.exists(file.path(path, "draft", "implicit", id))) + + ## Nothing extra in src + expect_setequal(dir(file.path(path, "src", "implicit")), + c("orderly.R", "data.csv")) + + root <- orderly_root(path, FALSE) + idx <- root$outpack$index() + expect_equal(names(idx$metadata), id) + meta <- root$outpack$metadata(id, full = TRUE) + + expect_equal(meta$custom$orderly$role, list()) + expect_equal(meta$custom$orderly$artefacts, list()) +}) + + +test_that("error if declared artefacts are not produced", { + path <- test_prepare_orderly_example("explicit") + env <- new.env() + path_src <- file.path(path, "src", "explicit", "orderly.R") + code <- readLines(path_src) + writeLines(c( + 'orderly3::orderly_artefact("some data", "output.csv")', + code), + path_src) + expect_error( + orderly_run("explicit", root = path, envir = env), + "Script did not produce expected artefacts: 'output.csv'") +}) + + +test_that("Can run explicit case without orderly", { + path <- test_prepare_orderly_example("explicit") + env <- new.env() + path_src <- file.path(path, "src", "explicit") + withr::with_dir(path_src, + sys.source("orderly.R", env)) + expect_setequal(dir(path_src), + c("data.csv", "orderly.R", "mygraph.png")) +}) + + +test_that("cope with computed values in static functions", { + path <- test_prepare_orderly_example("computed-resource") + env <- new.env() + id <- orderly_run("computed-resource", root = path, envir = env) + expect_setequal( + dir(file.path(path, "archive", "computed-resource", id)), + c("data.csv", "mygraph.png", "orderly.R")) +}) + + +test_that("run a packet with parameters", { + path <- test_prepare_orderly_example("parameters") + env <- new.env() + id <- orderly_run("parameters", parameters = list(a = 10, b = 20, c = 30), + envir = env, root = path) + path_rds <- file.path(path, "archive", "parameters", id, "data.rds") + expect_true(file.exists(path_rds)) + expect_equal(readRDS(path_rds), list(a = 10, b = 20, c = 30)) +}) + + +test_that("fall back on parameter defaults", { + path <- test_prepare_orderly_example("parameters") + env <- new.env() + id <- orderly_run("parameters", parameters = list(a = 10, c = 30), + envir = env, root = path) + + path_rds <- file.path(path, "archive", "parameters", id, "data.rds") + expect_true(file.exists(path_rds)) + expect_equal(readRDS(path_rds), list(a = 10, b = 2, c = 30)) +}) + + +test_that("can run orderly with parameters, without orderly", { + path <- test_prepare_orderly_example("parameters") + env <- list2env(list(a = 10, c = 30), parent = new.env()) + path_src <- file.path(path, "src", "parameters") + withr::with_dir(path_src, + sys.source("orderly.R", env)) + + path_rds <- file.path(path_src, "data.rds") + expect_true(file.exists(path_rds)) + expect_equal(readRDS(path_rds), list(a = 10, b = 2, c = 30)) +}) + + +test_that("Can run simple case with dependency", { + path <- test_prepare_orderly_example(c("explicit", "depends")) + env1 <- new.env() + id1 <- orderly_run("explicit", root = path, envir = env1) + env2 <- new.env() + id2 <- orderly_run("depends", root = path, envir = env2) + + path1 <- file.path(path, "archive", "explicit", id1) + path2 <- file.path(path, "archive", "depends", id2) + + expect_true(file.exists(file.path(path2, "graph.png"))) + expect_equal( + unname(tools::md5sum(file.path(path2, "graph.png"))), + unname(tools::md5sum(file.path(path1, "mygraph.png")))) +}) + + +test_that("Can run dependencies case without orderly", { + path <- test_prepare_orderly_example(c("explicit", "depends")) + env1 <- new.env() + id1 <- orderly_run("explicit", root = path, envir = env1) + + env2 <- new.env() + path_src <- file.path(path, "src", "depends") + withr::with_dir(path_src, + sys.source("orderly.R", env2)) + expect_setequal(dir(path_src), c("orderly.R", "graph.png")) + expect_equal( + unname(tools::md5sum(file.path(path_src, "graph.png"))), + unname(tools::md5sum(file.path(path, "archive", "explicit", id1, + "mygraph.png")))) +}) + + +test_that("can run with global resources", { + path <- test_prepare_orderly_example("global") + env <- new.env() + id <- orderly_run("global", root = path, envir = env) + expect_setequal( + dir(file.path(path, "archive", "global", id)), + c("global_data.csv", "mygraph.png", "orderly.R")) + root <- orderly_root(path, FALSE) + meta <- root$outpack$metadata(id, full = TRUE) + expect_length(meta$custom$orderly$global, 1) + expect_mapequal(meta$custom$orderly$global[[1]], + list(here = "global_data.csv", there = "data.csv")) + expect_equal( + meta$custom$orderly$role, + list(list(path = "global_data.csv", role = "global"))) +}) + + +test_that("can run manually with global resources", { + path <- test_prepare_orderly_example("global") + env <- new.env() + path_src <- file.path(path, "src", "global") + withr::with_dir(path_src, + sys.source("orderly.R", env)) + expect_setequal( + dir(path_src), + c("global_data.csv", "mygraph.png", "orderly.R")) +}) + + +test_that("can validate global resource arguments", { + expect_error( + validate_global_resource(list()), + "orderly_global_resource requires at least one argument") + expect_error( + validate_global_resource(list(input = c("a", "b"))), + "Invalid global resource 'input': entries must be strings") + expect_error( + validate_global_resource(list(a = 1, b = TRUE, c = "str")), + "Invalid global resource 'a', 'b': entries must be strings") + expect_equal( + validate_global_resource(list(a = "A", b = "B")), + c(a = "A", b = "B")) +}) + + +test_that("can't use global resources if not enabled", { + path <- test_prepare_orderly_example("global") + file.create(file.path(path, "orderly_config.yml")) # truncates file + env <- new.env() + path_src <- file.path(path, "src", "global") + err <- expect_error( + orderly_run("global", root = path, envir = env), + "'global_resources' is not supported; please edit orderly_config.yml") + expect_error( + withr::with_dir(path_src, sys.source("orderly.R", env)), + err$message, fixed = TRUE) +}) + + +test_that("global resources can be directories", { + path <- test_prepare_orderly_example("global-dir") + write.csv(mtcars, file.path(path, "global/data/mtcars.csv"), + row.names = FALSE) + write.csv(iris, file.path(path, "global/data/iris.csv"), + row.names = FALSE) + + env <- new.env() + id <- orderly_run("global-dir", root = path, envir = env) + + expect_setequal( + dir(file.path(path, "archive", "global-dir", id)), + c("global_data", "output.rds", "orderly.R")) + expect_setequal( + dir(file.path(path, "archive", "global-dir", id, "global_data")), + c("iris.csv", "mtcars.csv")) + root <- orderly_root(path, FALSE) + meta <- root$outpack$metadata(id, full = TRUE) + expect_length(meta$custom$orderly$global, 2) + expect_mapequal( + meta$custom$orderly$global[[1]], + list(here = "global_data/iris.csv", there = "data/iris.csv")) + expect_mapequal( + meta$custom$orderly$global[[2]], + list(here = "global_data/mtcars.csv", there = "data/mtcars.csv")) + expect_equal( + meta$custom$orderly$role, + list(list(path = "global_data/iris.csv", role = "global"), + list(path = "global_data/mtcars.csv", role = "global"))) + d <- readRDS(file.path(path, "archive", "global-dir", id, "output.rds")) + expect_equal( + d, + list(iris = read.csv(file.path(path, "global/data/iris.csv")), + mtcars = read.csv(file.path(path, "global/data/mtcars.csv")))) +}) diff --git a/tests/testthat/test-schema.R b/tests/testthat/test-schema.R index c57257f5..66a7554b 100644 --- a/tests/testthat/test-schema.R +++ b/tests/testthat/test-schema.R @@ -1,7 +1,26 @@ test_that("can load custom metadata schema", { cache$custom_metadata_schema <- NULL - s <- custom_metadata_schema() + s <- custom_metadata_schema(list()) expect_type(s, "character") expect_length(s, 1) - expect_identical(custom_metadata_schema(), s) + expect_identical(custom_metadata_schema(list()), s) +}) + + +test_that("can add plugin schemas to metadata", { + schema <- structure('{"type": "object"}', class = "json") + config <- list(plugins = list(a = list(schema = schema))) + s <- custom_metadata_schema(config) + d <- jsonlite::fromJSON(s) + expect_equal( + d$properties$plugins, + list(type = "object", + properties = list(a = list(type = "object")))) +}) + + +test_that("no plugin schema by default", { + s <- custom_metadata_schema(list()) + d <- jsonlite::fromJSON(s) + expect_length(d$properties$plugins, 0) }) diff --git a/tests/testthat/test-util.R b/tests/testthat/test-util.R index 8f4ffdd1..f9503a3e 100644 --- a/tests/testthat/test-util.R +++ b/tests/testthat/test-util.R @@ -4,3 +4,18 @@ test_that("null-or-value works", { expect_equal(NULL %||% NULL, NULL) expect_equal(NULL %||% 2, 2) }) + + +test_that("drop null can return corner case", { + expect_null(drop_null(list(), NULL)) + expect_null(drop_null(list(NULL), NULL)) + expect_null(drop_null(list(NULL, NULL), NULL)) + expect_equal(drop_null(list(NULL, NULL), list()), list()) +}) + + +test_that("drop_null can filter list", { + expect_equal(drop_null(list("x", NULL)), list("x")) + expect_equal(drop_null(list("x", "y")), list("x", "y")) + expect_equal(drop_null(list("x", NULL, "y")), list("x", "y")) +})