diff --git a/.Rbuildignore b/.Rbuildignore index 584e793..2f24dd1 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -19,4 +19,4 @@ ^revdep ^.DS_Store ^help - +^.claude$ diff --git a/DESCRIPTION b/DESCRIPTION index 2df7b78..75dd796 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: APCalign Title: Resolving Plant Taxon Names Using the Australian Plant Census -Version: 1.1.6 +Version: 1.2.0 Authors@R: c( person(given = "Elizabeth", family = "Wenk", role = c("aut", "cre", "cph"), email = "e.wenk@unsw.edu.au", comment = c(ORCID = "0000-0001-5640-5910")), person(given = "Daniel", family = "Falster", role = c("aut", "ctb"), email = "daniel.falster@unsw.edu.au", comment = c(ORCID = "0000-0002-9814-092X")), @@ -8,7 +8,13 @@ Authors@R: c( person(given = "Fonti", family = "Kar", role = c("aut", "ctb"), email = "f.kar@unsw.edu.au", comment = c(ORCID = "0000-0002-2760-3974")), person(given = "Carl", family= "Boettiger", role = c("ctb"), email = "cboettig@gmail.com", comment = c(ORCID = "0000-0002-1642-628X")) ) -Description: The process of resolving and updating taxon names is necessary when working with biodiversity data. 'APCalign' uses the Australian Plant Census (APC) and the Australian Plant Name Index (APNI) to align and update plant taxon names to current, accepted standards. 'APCalign' also supplies information about the establishment status (i.e. native or introduced) of plant taxa across different states/territories. https://doi.org/10.1071/BT24014 +Description: The process of resolving and updating taxon names is necessary when + working with biodiversity data. 'APCalign' uses the Australian Plant Census + (APC) and the Australian Plant Name Index (APNI) to align and update plant + taxon names to current, accepted standards. 'APCalign' also supplies + information about the establishment status (i.e. native or introduced) of + plant taxa across different states/territories. See Falster et al. (2024) + . License: MIT + file LICENSE Encoding: UTF-8 Language: en @@ -38,8 +44,8 @@ Suggests: here, testthat (>= 3.0.0) Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 Config/testthat/edition: 3 VignetteBuilder: knitr -URL: https://traitecoevo.github.io/APCalign/, https://github.com/traitecoevo/APCalign, https://www.publish.csiro.au/bt/pdf/BT24014, https://doi.org/10.1071/BT24014 +URL: https://traitecoevo.github.io/APCalign/, https://github.com/traitecoevo/APCalign, https://doi.org/10.1071/BT24014 BugReports: https://github.com/traitecoevo/APCalign/issues diff --git a/NAMESPACE b/NAMESPACE index 61445c9..3435416 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand export(align_taxa) +export(clear_cached_resources) export(create_species_state_origin_matrix) export(create_taxonomic_update_lookup) export(default_version) diff --git a/NEWS.md b/NEWS.md index 6203de3..58c285e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# APCalign 1.2.0 + +- `load_taxonomic_resources()` now caches results in memory for the duration of the R session, so repeated calls with the same version return immediately without re-downloading or re-processing data. +- New function `clear_cached_resources()` to remove the session cache and force a reload. +- `load_taxonomic_resources()` now works offline when parquet files have been previously downloaded; `default_version()` falls back to the most recently cached local version when no internet connection is available. # APCalign 1.1.6 - Fix issue #262: filter to accepted species only in genus-family lookup diff --git a/R/load_taxonomic_resources.R b/R/load_taxonomic_resources.R index 7323e73..c8828c4 100644 --- a/R/load_taxonomic_resources.R +++ b/R/load_taxonomic_resources.R @@ -1,27 +1,38 @@ +# Package-internal cache environment. +# This is NOT a global variable — it lives in the package namespace only. +# CRAN policy prohibits modifying .GlobalEnv; using a package-private +# environment is the standard CRAN-compliant pattern for session-level caching. +.pkg_cache <- new.env(parent = emptyenv()) + #' @title Load taxonomic reference lists, APC & APNI #' #' @description -#' This function loads two taxonomic datasets for Australia's vascular plants, -#' the APC and APNI, into the global environment. It creates several data frames -#' by filtering and selecting data from the loaded lists. +#' This function loads two taxonomic datasets for Australia's vascular plants, +#' the APC and APNI. It creates several data frames by filtering and selecting +#' data from the loaded lists. #' #' @details -#' - It accesses taxonomic data from a dataset using the provided version number -#' or the default version. +#' - It accesses taxonomic data from a dataset using the provided version number +#' or the default version. #' - The output is several dataframes that include subsets of the APC/APNI based #' on taxon rank and taxonomic status. +#' - Results are cached in memory for the R session so that repeated calls with +#' the same `version` and `stable_or_current_data` arguments return immediately +#' without re-downloading or re-processing the data. Use +#' [clear_cached_resources()] to force a reload. +#' - `"current"` data is not cached because it may change between calls. #' -#' @param stable_or_current_data Type of dataset to access. -#' The default is "stable", which loads the dataset from a github archived file. -#' If set to "current", the dataset will be loaded from a URL which is the +#' @param stable_or_current_data Type of dataset to access. +#' The default is "stable", which loads the dataset from a github archived file. +#' If set to "current", the dataset will be loaded from a URL which is the #' cutting edge version, but this may change at any time without notice. -#' @param version The version number of the dataset to use. +#' @param version The version number of the dataset to use. #' Defaults to the default version. -#' +#' #' @param quiet A logical indicating whether to print status of loading to screen. #' Defaults to FALSE. #' -#' @return The taxonomic resources data loaded into the global environment. +#' @return A list of taxonomic resource data frames. #' @export #' #' @examples @@ -34,7 +45,19 @@ load_taxonomic_resources <- function(stable_or_current_data = "stable", version = default_version(), quiet = FALSE) { - + + # Session-level cache for stable data only. + # "current" data is explicitly cutting-edge and may change between calls. + # .pkg_cache is a package-private environment — NOT .GlobalEnv — so this + # is CRAN-compliant. + use_cache <- stable_or_current_data == "stable" && !is.null(version) + cache_key <- if (use_cache) paste0("stable_", version) else NULL + + if (use_cache && !is.null(.pkg_cache[[cache_key]])) { + if (!quiet) message("Using cached taxonomic resources.") + return(.pkg_cache[[cache_key]]) + } + if(is.null(version)){ message("No internet connection, please retry with stable connection or specify a local version of the data") return(invisible(NULL)) @@ -276,9 +299,34 @@ load_taxonomic_resources <- close(pb) if(!quiet) message("...done") + + # Store in session cache for future calls + if (use_cache) { + .pkg_cache[[cache_key]] <- taxonomic_resources + } + return(taxonomic_resources) } +#' Clear cached taxonomic resources +#' +#' Removes any taxonomic resources that have been cached in memory during the +#' current R session. After calling this function, the next call to +#' [load_taxonomic_resources()] will re-download and re-process the data. +#' +#' This is useful if you want to force a reload of the resources, for example +#' after updating the package or switching to a different version. +#' +#' @return Invisibly returns `NULL`. +#' @export +#' +#' @examples +#' clear_cached_resources() +clear_cached_resources <- function() { + rm(list = ls(.pkg_cache), envir = .pkg_cache) + invisible(NULL) +} + ##' Access Australian Plant Census Dataset ##' ##' This function provides access to the Australian Plant Census dataset @@ -312,15 +360,21 @@ dataset_access_function <- # Check if there is internet connection ## Dummy variable to allow testing of network - network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) - - - if (!curl::has_internet() | !network| is.null(version)) { # Simulate if network is down + network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) + offline <- !curl::has_internet() | !network + + # "current" always requires a live internet connection + if (offline && type == "current") { message("No internet connection, please retry with stable connection (dataset_access_function)") return(invisible(NULL)) - } - - # Download from Github Release + } + + if (is.null(version)) { + message("No internet connection, please retry with stable connection (dataset_access_function)") + return(invisible(NULL)) + } + + # Download from Github Release (dataset_get handles offline fallback) if (type == "stable") { return(dataset_get(version, path)) } @@ -383,9 +437,16 @@ dataset_access_function <- default_version <- function() { # Check if there is internet connection ## Dummy variable to allow testing of network - network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) - + network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) + if (!curl::has_internet() | !network) { # Simulate if network is down + # Fall back to the most recently downloaded local version, if any + local_versions <- local_cached_versions() + if (length(local_versions) > 0) { + version <- sort(local_versions, decreasing = TRUE)[1] + message("No internet connection; using most recent locally cached version: ", version) + return(version) + } message("No internet connection, please retry with stable connection (default_version)") return(invisible(NULL)) } else { @@ -424,19 +485,62 @@ default_version <- function() { } } +# Returns a character vector of version strings (e.g. "2024-10-11") for which +# both APC and APNI parquet files exist in the local cache directory. +#' @noRd +local_cached_versions <- function(path = tools::R_user_dir("APCalign")) { + if (!dir.exists(path)) return(character(0)) + apc_files <- list.files(path, pattern = "^apc\\d{4}-\\d{2}-\\d{2}\\.parquet$") + versions <- gsub("^apc|\\.parquet$", "", apc_files) + # Only return versions where the matching APNI file also exists + has_apni <- file.exists(file.path(path, paste0("apni", versions, ".parquet"))) + versions[has_apni] +} + #' @noRd dataset_get <- function(version = default_version(), path = tools::R_user_dir("APCalign")) { - + # Check if there is internet connection ## Dummy variable to allow testing of network - network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) - - if (!curl::has_internet() | !network | is.null(version)) { # Simulate if network is down - message("No internet connection, please retry with stable connection (dataset_get)") + network <- as.logical(Sys.getenv("NETWORK_UP", unset = TRUE)) + offline <- !curl::has_internet() | !network + + if (is.null(version)) { + message( + "No internet connection and no locally cached version found (dataset_get)" + ) return(invisible(NULL)) - } else{ - + } + + if (!dir.exists(path)) { + dir.create(path, recursive = TRUE) + } + + path_to_apc <- file.path(path, paste0("apc", version, ".parquet")) + path_to_apni <- file.path(path, paste0("apni", version, ".parquet")) + + # If offline but both files are cached locally, read and return them + if (offline) { + if (file.exists(path_to_apc) && file.exists(path_to_apni)) { + message( + "No internet connection; loading locally cached version: ", version + ) + APC <- arrow::read_parquet(path_to_apc) + APNI <- arrow::read_parquet(path_to_apni) + current_list <- list(APC, APNI) + names(current_list) <- c("APC", "APNI") + return(current_list) + } else { + message( + "No internet connection and no local data for version ", version, + "; please connect and retry (dataset_get)" + ) + return(invisible(NULL)) + } + } + + # Online path — download if not already cached #APC apc.url <- paste0( @@ -467,33 +571,23 @@ dataset_get <- function(version = default_version(), return(NULL) }) } - - if (!dir.exists(path)) { - dir.create(path, recursive = TRUE) - } - - path_to_apc <- file.path(path, paste0("apc", version, ".parquet")) - path_to_apni <- file.path(path, paste0("apni", version, ".parquet")) - + APC <- if (!file.exists(path_to_apc)) { message("Downloading...") download_and_read_parquet(apc.url, path_to_apc) } else { arrow::read_parquet(path_to_apc) } - + APNI <- if (!file.exists(path_to_apni)) { download_and_read_parquet(apni.url, path_to_apni) } else { arrow::read_parquet(path_to_apni) } - #combine current_list <- list(APC, APNI) names(current_list) <- c("APC", "APNI") return(current_list) - - } } diff --git a/_pkgdown.yml b/_pkgdown.yml index 977e37c..51c1176 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -45,6 +45,7 @@ reference: - subtitle: Data - contents: - load_taxonomic_resources + - clear_cached_resources - get_versions - default_version - gbif_lite diff --git a/man/APCalign.Rd b/man/APCalign.Rd index 4a031a1..4a6d4b6 100644 --- a/man/APCalign.Rd +++ b/man/APCalign.Rd @@ -40,7 +40,6 @@ Useful links: \itemize{ \item \url{https://traitecoevo.github.io/APCalign/} \item \url{https://github.com/traitecoevo/APCalign} - \item \url{https://www.publish.csiro.au/bt/pdf/BT24014} \item \doi{10.1071/BT24014} \item Report bugs at \url{https://github.com/traitecoevo/APCalign/issues} } diff --git a/man/clear_cached_resources.Rd b/man/clear_cached_resources.Rd new file mode 100644 index 0000000..741bf9f --- /dev/null +++ b/man/clear_cached_resources.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/load_taxonomic_resources.R +\name{clear_cached_resources} +\alias{clear_cached_resources} +\title{Clear cached taxonomic resources} +\usage{ +clear_cached_resources() +} +\value{ +Invisibly returns \code{NULL}. +} +\description{ +Removes any taxonomic resources that have been cached in memory during the +current R session. After calling this function, the next call to +\code{\link[=load_taxonomic_resources]{load_taxonomic_resources()}} will re-download and re-process the data. +} +\details{ +This is useful if you want to force a reload of the resources, for example +after updating the package or switching to a different version. +} +\examples{ +clear_cached_resources() +} diff --git a/man/load_taxonomic_resources.Rd b/man/load_taxonomic_resources.Rd index f7022d7..8693278 100644 --- a/man/load_taxonomic_resources.Rd +++ b/man/load_taxonomic_resources.Rd @@ -23,12 +23,12 @@ Defaults to the default version.} Defaults to FALSE.} } \value{ -The taxonomic resources data loaded into the global environment. +A list of taxonomic resource data frames. } \description{ This function loads two taxonomic datasets for Australia's vascular plants, -the APC and APNI, into the global environment. It creates several data frames -by filtering and selecting data from the loaded lists. +the APC and APNI. It creates several data frames by filtering and selecting +data from the loaded lists. } \details{ \itemize{ @@ -36,6 +36,11 @@ by filtering and selecting data from the loaded lists. or the default version. \item The output is several dataframes that include subsets of the APC/APNI based on taxon rank and taxonomic status. +\item Results are cached in memory for the R session so that repeated calls with +the same \code{version} and \code{stable_or_current_data} arguments return immediately +without re-downloading or re-processing the data. Use +\code{\link[=clear_cached_resources]{clear_cached_resources()}} to force a reload. +\item \code{"current"} data is not cached because it may change between calls. } } \examples{ diff --git a/tests/testthat/teardown.R b/tests/testthat/teardown.R new file mode 100644 index 0000000..25ed6f0 --- /dev/null +++ b/tests/testthat/teardown.R @@ -0,0 +1,4 @@ +# Free cached taxonomic resources after the test suite completes. +# This runs before covr saves its coverage traces, preventing the +# memory pressure that causes saveRDS() to segfault on large datasets. +clear_cached_resources() diff --git a/tests/testthat/test-cache.R b/tests/testthat/test-cache.R new file mode 100644 index 0000000..4359e6e --- /dev/null +++ b/tests/testthat/test-cache.R @@ -0,0 +1,125 @@ +test_that("resources are cached after first load", { + skip_on_cran() + + # Ensure cache has the version loaded by helper.R + cache_key <- "stable_2024-10-11" + expect_false(is.null(.pkg_cache[[cache_key]])) +}) + +test_that("second call returns cached resources with message", { + skip_on_cran() + + expect_message( + load_taxonomic_resources(stable_or_current_data = "stable", version = "2024-10-11", quiet = FALSE), + "Using cached taxonomic resources" + ) +}) + +test_that("cached result is identical to original", { + skip_on_cran() + + r1 <- load_taxonomic_resources(stable_or_current_data = "stable", version = "2024-10-11", quiet = TRUE) + r2 <- load_taxonomic_resources(stable_or_current_data = "stable", version = "2024-10-11", quiet = TRUE) + expect_identical(r1, r2) +}) + +test_that("clear_cached_resources() empties the cache", { + skip_on_cran() + + # Confirm something is cached first + expect_gt(length(ls(.pkg_cache)), 0) + + clear_cached_resources() + expect_equal(length(ls(.pkg_cache)), 0) + + # Reload for subsequent tests + load_taxonomic_resources(stable_or_current_data = "stable", version = "2024-10-11", quiet = TRUE) +}) + +test_that("after clearing, resources are reloaded without cache message", { + skip_on_cran() + + clear_cached_resources() + + expect_no_message( + load_taxonomic_resources(stable_or_current_data = "stable", version = "2024-10-11", quiet = TRUE), + message = "Using cached taxonomic resources" + ) +}) + +test_that("local_cached_versions() finds previously downloaded versions", { + skip_on_cran() + + versions <- local_cached_versions() + expect_type(versions, "character") + # At least one version should be present (downloaded by helper.R) + expect_gt(length(versions), 0) + # All returned values should look like dates + expect_true(all(grepl("^\\d{4}-\\d{2}-\\d{2}$", versions))) +}) + +test_that("local_cached_versions() returns empty vector for empty directory", { + tmp <- tempfile() + dir.create(tmp) + on.exit(unlink(tmp, recursive = TRUE)) + + expect_equal(local_cached_versions(path = tmp), character(0)) +}) + +test_that("local_cached_versions() ignores versions missing the APNI file", { + tmp <- tempfile() + dir.create(tmp) + on.exit(unlink(tmp, recursive = TRUE)) + + # Create only the APC file, not the APNI + file.create(file.path(tmp, "apc2024-01-01.parquet")) + expect_equal(local_cached_versions(path = tmp), character(0)) +}) + +test_that("default_version() falls back to local cache when offline", { + skip_on_cran() + + Sys.setenv("NETWORK_UP" = FALSE) + on.exit(Sys.setenv("NETWORK_UP" = TRUE)) + + version <- suppressMessages(default_version()) + # Should return a date-string from the local cache, not NULL + expect_type(version, "character") + expect_true(grepl("^\\d{4}-\\d{2}-\\d{2}$", version)) +}) + +test_that("load_taxonomic_resources() works offline when files are cached locally", { + skip_on_cran() + + clear_cached_resources() + on.exit(Sys.setenv("NETWORK_UP" = TRUE)) + + Sys.setenv("NETWORK_UP" = FALSE) + result <- suppressMessages( + load_taxonomic_resources(quiet = TRUE) + ) + + expect_type(result, "list") + expect_true("APC" %in% names(result)) + expect_true("APNI" %in% names(result)) +}) + +test_that("'current' type data is not cached", { + skip_on_cran() + skip_on_ci() # avoid hitting live URL in CI + + Sys.setenv("NETWORK_UP" = TRUE) + n_before <- length(ls(.pkg_cache)) + + # Suppress output; we only care about cache side-effect + suppressMessages( + tryCatch( + load_taxonomic_resources(stable_or_current_data = "current", quiet = TRUE), + error = function(e) NULL # tolerate network/server errors + ) + ) + + # Cache should not have grown with a "current" key + current_keys <- grep("^current", ls(.pkg_cache), value = TRUE) + expect_equal(length(current_keys), 0) +}) diff --git a/tests/testthat/test-connection.R b/tests/testthat/test-connection.R index 69267ba..38fced2 100644 --- a/tests/testthat/test-connection.R +++ b/tests/testthat/test-connection.R @@ -1,17 +1,91 @@ -test_that("Complains when network is down", { +# Tests for graceful degradation when network is unavailable. +# These tests simulate offline conditions using the NETWORK_UP environment +# variable and do NOT require an internet connection, so they run on CRAN. + +# Helper: simulate offline, restore on exit +with_network_down <- function(code) { + Sys.setenv("NETWORK_UP" = FALSE) + on.exit(Sys.setenv("NETWORK_UP" = TRUE), add = TRUE) + force(code) +} + +test_that("default_version() messages and does not error when offline", { + with_network_down({ + expect_message(result <- default_version()) + # Must be either NULL or a valid date-string from local cache — never an error + expect_true(is.null(result) || grepl("^\\d{4}-\\d{2}-\\d{2}$", result)) + }) +}) + +test_that("dataset_get() messages and returns NULL when offline with no local files", { + tmp <- tempfile() + dir.create(tmp) + on.exit(unlink(tmp, recursive = TRUE), add = TRUE) + + with_network_down({ + expect_message(result <- dataset_get(version = "2099-01-01", path = tmp)) + expect_null(result) + }) +}) + +test_that("dataset_get() messages and returns NULL when version is NULL", { + with_network_down({ + expect_message(result <- dataset_get(version = NULL)) + expect_null(result) + }) +}) + +test_that("dataset_access_function() messages and returns NULL for 'current' when offline", { + with_network_down({ + expect_message( + result <- dataset_access_function( + version = "2024-10-11", type = "current" + ) + ) + expect_null(result) + }) +}) + +test_that("dataset_access_function() messages and returns NULL when version is NULL", { + with_network_down({ + expect_message( + result <- dataset_access_function(version = NULL, type = "stable") + ) + expect_null(result) + }) +}) + +test_that("load_taxonomic_resources() messages and returns NULL when offline with no local files", { + tmp <- tempfile() + dir.create(tmp) + on.exit(unlink(tmp, recursive = TRUE), add = TRUE) + + with_network_down({ + # Provide a bogus version so it cannot fall back to local cache + expect_message( + result <- load_taxonomic_resources( + stable_or_current_data = "stable", + version = "2099-01-01", + quiet = FALSE + ) + ) + expect_null(result) + }) +}) + +test_that("get_versions() messages and returns NULL when offline", { + with_network_down({ + expect_message(result <- get_versions()) + expect_null(result) + }) +}) + +test_that("functions return visibly when online", { skip_on_ci() skip_on_cran() - Sys.setenv("NETWORK_UP" = FALSE) - expect_message(default_version()) - expect_message(dataset_access_function()) - expect_message(dataset_get()) - - #commenting out for now to test in CI, see issue #235 Sys.setenv("NETWORK_UP" = TRUE) expect_visible(default_version()) expect_visible(dataset_access_function()) expect_visible(dataset_get()) }) - -