From 62d7ce8b4ebb68bee727ae5a510d2767caa3ab7a Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 01:25:03 -0400 Subject: [PATCH 1/7] Make NSR_metadata() fail gracefully when the API is unreachable This is the error that got NSR archived from CRAN: on the Windows check machines the API could not be reached, NSR_metadata() assigned NULL results into a list and then named three elements, breaking the examples and the vignette. It now gives a message and returns NULL, stopping after the first failed request. nsr_core()'s exists() checks no longer look outside the function, so a same-named object in the user's workspace can't be mistaken for a reply. New tests put every exported API function through an unreachable API, an HTTP error, an empty reply and no internet access, with the request mocked. Co-Authored-By: Claude Opus 5.5 --- NEWS | 6 +++ R/NSR_core.R | 4 +- R/NSR_metadata.R | 26 +++++++--- man/NSR_metadata.Rd | 2 +- tests/testthat/test-NSR_api_failure.R | 72 +++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 tests/testthat/test-NSR_api_failure.R diff --git a/NEWS b/NEWS index 692bad0..c2227a8 100644 --- a/NEWS +++ b/NEWS @@ -30,6 +30,12 @@ bases are answered separately in native_status_coordinates and native_status_names. ## Bug fixes +* NSR_metadata() stopped with an error ("'names' attribute [3] must be the same length + as the vector [0]") when the NSR API could not be reached, which broke the package's + examples and vignette on CRAN's Windows check machines and led to its archival. It + now gives a message and returns NULL, like the package's other API functions. Tests + now put every exported API function through an unreachable API, an HTTP error and an + empty reply, with the request mocked. * NSR_metadata() had its sources and version elements named the wrong way round: metadata$version held the sources and metadata$sources the version. * NSR() sent any input in a single request, although the API accepts at most 5,000 diff --git a/R/NSR_core.R b/R/NSR_core.R index bd25b37..76699e8 100644 --- a/R/NSR_core.R +++ b/R/NSR_core.R @@ -68,7 +68,7 @@ nsr_core <- function(url = "https://nsrapi.xyz/nsr_wsb.php", #Return NULL if API isn't working - if(!exists("results_json")){ + if(!exists("results_json", inherits = FALSE)){ return(invisible(NULL)) } @@ -92,7 +92,7 @@ nsr_core <- function(url = "https://nsrapi.xyz/nsr_wsb.php", #Convert to data.frame if things worked - if(!exists("results_raw")){ + if(!exists("results_raw", inherits = FALSE)){ return(invisible(NULL)) }else{ results <- as.data.frame(results_raw) diff --git a/R/NSR_metadata.R b/R/NSR_metadata.R index 20ba2f7..ad79949 100644 --- a/R/NSR_metadata.R +++ b/R/NSR_metadata.R @@ -3,7 +3,7 @@ #'Returns metadata on NSR including version and citation information #' @param bibtex_file Optional output file for writing bibtex citations. #' @param ... Additional arguments passed to internal functions. -#' @return List containing: (1) bibtex-formatted citation information, (2) information about NSR data sources, and (3) NSR version information. +#' @return List containing: (1) bibtex-formatted citation information, (2) information about NSR data sources, and (3) NSR version information. If any of these can't be retrieved (e.g. the API is unreachable), a message is given and NULL is returned. #' @note This function provides citation information in bibtex format that can be used with reference manager software (e.g. Paperpile, Zotero). Please remember to cite both the sources and the NSR, as the NSR couldn't exist without these sources! #' @note This function is a wrapper that returns the output of the functions NSR_citations, NSR_sources, and NSR_version. #' @export @@ -13,14 +13,24 @@ #' NSR_metadata <- function(bibtex_file=NULL, ...){ + getters <- list(citations = NSR_citations, + sources = NSR_sources, + version = NSR_version) + output <- list() - - output[[1]] <- NSR_citations(...) - output[[2]] <- NSR_sources(...) - output[[3]] <- NSR_version(...) - - names(output) <- c("citations", "sources", "version") - + + for(element in names(getters)){ + + output[[element]] <- getters[[element]](...) + + #If the API didn't work, return a null (stopping here avoids repeating the failed request) + if(is.null(output[[element]])){ + message("NSR metadata could not be retrieved.") + return(invisible(NULL)) + } + + } + #Write the bibtex information if a file is specified if(!is.null(bibtex_file)){writeLines(text = output$citations$citation, con = bibtex_file)} diff --git a/man/NSR_metadata.Rd b/man/NSR_metadata.Rd index 1ba7205..086b8f8 100644 --- a/man/NSR_metadata.Rd +++ b/man/NSR_metadata.Rd @@ -12,7 +12,7 @@ NSR_metadata(bibtex_file = NULL, ...) \item{...}{Additional arguments passed to internal functions.} } \value{ -List containing: (1) bibtex-formatted citation information, (2) information about NSR data sources, and (3) NSR version information. +List containing: (1) bibtex-formatted citation information, (2) information about NSR data sources, and (3) NSR version information. If any of these can't be retrieved (e.g. the API is unreachable), a message is given and NULL is returned. } \description{ Returns metadata on NSR including version and citation information diff --git a/tests/testthat/test-NSR_api_failure.R b/tests/testthat/test-NSR_api_failure.R new file mode 100644 index 0000000..46227bf --- /dev/null +++ b/tests/testthat/test-NSR_api_failure.R @@ -0,0 +1,72 @@ +# CRAN archived NSR 0.1.0 because NSR_metadata() stopped with an error on the Windows check +# machines, which had internet access but could not reach the NSR API. CRAN policy is that +# a package using an internet resource fails gracefully - a message, no error or warning - +# when that resource is unavailable. These tests put every exported API function through +# that situation, and two related ones, with the request mocked: no network is used. + +fake_response <- function(status, body) { + structure(list(status_code = status, content = charToRaw(body)), class = "response") +} + +failures <- list( + "the API can't be reached" = function(...) stop("Could not resolve host: nsrapi.xyz"), + "the API returns an HTTP error" = function(...) fake_response(500L, "Internal Server Error"), + # what the live API sends for some records (https://github.com/ojalaquellueva/nsr/issues/7) + "the API returns an empty body" = function(...) fake_response(200L, "") +) + +api_calls <- list( + NSR = function() NSR(nsr_testfile), + NSR_simple = function() NSR_simple(species = "Acer rubrum", country = "Canada", + state_province = "Ontario"), + NSR_citations = function() NSR_citations(), + NSR_sources = function() NSR_sources(), + NSR_version = function() NSR_version(), + NSR_metadata = function() NSR_metadata(), + NSR_political_divisions = function() NSR_political_divisions(), + NSR_political_divisions_by_checklist = function() NSR_political_divisions(by_country = FALSE), + NSR_data_dictionary = function() NSR_data_dictionary(), + NSR_data_dictionary_status = function() NSR_data_dictionary(native_status = TRUE) +) + +for (failure in names(failures)) { + for (fn in names(api_calls)) { + test_that(paste0(fn, "() gives a message and NULL, not an error, when ", failure), { + local_mocked_bindings(check_internet = function() TRUE, POST = failures[[failure]]) + expect_no_warning(suppressMessages(expect_message(r <- api_calls[[fn]]()))) + expect_null(r) + }) + } +} + +for (fn in names(api_calls)) { + test_that(paste0(fn, "() gives a message and NULL without internet access"), { + local_mocked_bindings(check_internet = function() FALSE, + POST = function(...) stop("no request should be sent")) + suppressMessages(expect_message(r <- api_calls[[fn]](), "requires internet access")) + expect_null(r) + }) +} + +test_that("NSR_metadata() stops at the first part it can't retrieve", { + requests <- 0 + local_mocked_bindings(check_internet = function() TRUE, + POST = function(...) { + requests <<- requests + 1 + stop("Could not resolve host: nsrapi.xyz") + }) + suppressMessages(expect_message(r <- NSR_metadata(), "NSR metadata could not be retrieved")) + expect_null(r) + expect_equal(requests, 1) +}) + +test_that("nsr_core() doesn't mistake a same-named object in the user's workspace for a reply", { + skip_on_cran() # it puts an object in the global environment, removed again on exit + local_mocked_bindings(check_internet = function() TRUE, + POST = function(...) stop("Could not resolve host: nsrapi.xyz")) + assign("results_json", fake_response(200L, '{"meta":{"db_version":"stale"}}'), + envir = globalenv()) + withr::defer(rm("results_json", envir = globalenv())) + expect_message(r <- NSR:::nsr_core(mode = "meta")) + expect_null(r) +}) From 71b30799398a564c569235883da98c9cc4dbfd6d Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 01:25:03 -0400 Subject: [PATCH 2/7] Drop rWCVP from Suggests rWCVP was archived on CRAN on 2025-11-28, so CRAN can't install it. It was only a fallback source for the WGSRPD polygons that rWCVPdata provides, and rWCVPdata is available through Additional_repositories. Co-Authored-By: Claude Opus 5.5 --- DESCRIPTION | 1 - R/local_build.R | 2 +- R/local_regions.R | 9 +++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 67f9b4c..0e5ddea 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -23,7 +23,6 @@ Suggests: data.table, nanoparquet, rmarkdown, - rWCVP, rWCVPdata, sf, terra, diff --git a/R/local_build.R b/R/local_build.R index c05b189..a13a210 100644 --- a/R/local_build.R +++ b/R/local_build.R @@ -183,7 +183,7 @@ NSR_local_build <- function(sources = "wcvp", "The checklist tables are written, but until this raster exists no query ", "can reach a WCVP opinion: coordinates cannot be placed in a WCVP region, ", "and division names resolve to GADM, which needs the link table below. ", - "Install 'rWCVPdata' (or 'rWCVP'), then re-run NSR_local_build(overwrite = TRUE).", + "Install 'rWCVPdata', then re-run NSR_local_build(overwrite = TRUE).", call. = FALSE) } } diff --git a/R/local_regions.R b/R/local_regions.R index 501ba51..ff72206 100644 --- a/R/local_regions.R +++ b/R/local_regions.R @@ -53,12 +53,9 @@ nsr_wgsrpd_polygons <- function() { p <- try(rWCVPdata::wgsrpd3, silent = TRUE) if (!inherits(p, "try-error")) return(sf::st_as_sf(p)) } - if (requireNamespace("rWCVP", quietly = TRUE)) { - e <- new.env() - utils::data("wgsrpd3", package = "rWCVP", envir = e) - return(sf::st_as_sf(get("wgsrpd3", envir = e))) - } - stop("The WGSRPD level-3 polygons need the 'rWCVPdata' (or 'rWCVP') package.", call. = FALSE) + stop("The WGSRPD level-3 polygons need the 'rWCVPdata' package, installed with ", + "install.packages(\"rWCVPdata\", repos = c(\"https://matildabrown.github.io/drat\", ", + "getOption(\"repos\")))", call. = FALSE) } #' Spatial links between region systems From 56690a4244688bf4db1291f9a58164cb4a9ae00f Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 01:25:03 -0400 Subject: [PATCH 3/7] Run R CMD check on GitHub Actions for every push and pull request Same workflow as RTNRS and RGNRS, plus the drat repository for rWCVPdata. Adds the status badge to the README. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/R-CMD-check.yaml | 60 ++++++++++++++++++++++++++++++ README.md | 3 ++ 2 files changed, 63 insertions(+) create mode 100644 .github/workflows/R-CMD-check.yaml diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..62023a7 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,60 @@ +# Runs R CMD check on every push and pull request. Based on the canonical +# workflow at https://github.com/r-lib/actions/tree/v2/examples +# +# The R-hub workflow alongside this one stays manual: it covers the unusual +# platforms (valgrind, gcc-san, older toolchains) that are worth a look before +# a CRAN submission but not on every commit. + +on: + push: + branches: [master, main] + pull_request: + workflow_dispatch: + +name: R-CMD-check + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true + # rWCVPdata (Suggests) isn't on CRAN; this is the repository named in + # DESCRIPTION's Additional_repositories + extra-repositories: 'https://matildabrown.github.io/drat' + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual", "--compact-vignettes=gs+qpdf")' diff --git a/README.md b/README.md index 4662d3b..0486452 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # RNSR + + [![R-CMD-check](https://github.com/EnquistLab/RNSR/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/EnquistLab/RNSR/actions/workflows/R-CMD-check.yaml) + R package containing tools to access the Native Status Resolver API and associated functionality ## Description From bef71e2bc2bf15cb47aa0cc25e2dbcbaa4d479ba Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 01:25:03 -0400 Subject: [PATCH 4/7] Explain the archival and its fix in cran-comments Co-Authored-By: Claude Opus 5.5 --- cran-comments.md | 52 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index 38029a3..eba0a8d 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -1,18 +1,46 @@ +## Resubmission of an archived package + +NSR was archived on 2026-07-28 because check errors were not corrected in time. This +submission fixes their cause. + +The errors were on the Windows flavors (r-devel, r-release and r-oldrel), in the examples +and in re-building the vignette. Those machines could not reach the NSR API, and +NSR_metadata() then stopped with "'names' attribute [3] must be the same length as the +vector [0]" instead of failing gracefully: it assigned NULL results into a list and then +named three elements. + +What we changed: + +* NSR_metadata() now gives an informative message and returns NULL when any part of the + metadata can't be retrieved, as the package's other API functions already did. +* New tests (tests/testthat/test-NSR_api_failure.R) run every exported function that uses + the API under an unreachable API, an HTTP error, an empty reply and no internet access, + with the request mocked so no network is needed. Each must give a message and return + NULL, with no error or warning. These tests fail on the archived version with the + error CRAN reported. +* We rendered the vignette with the API made unreachable, and it builds, showing the + messages in place of results. +* Tests that need the live API are skipped on CRAN or replay recorded responses (vcr). + +This version also adds an offline resolver, NSR_local(), whose data are built on the +user's machine by NSR_local_build(). Nothing derived from the checklists ships with the +package, and the packages it needs are in Suggests. One of them, rWCVPdata, is not on +CRAN; it is available from the repository given in Additional_repositories, and it is +used only conditionally, through requireNamespace(). We dropped rWCVP from Suggests, +since it has been archived on CRAN. + ## Test environments -* local "Windows" "10 x64" "build 17763", R 4.4.1 -* win-builder -* macOS builder -* ubuntu-latest on GitHub -* macos-13 on GitHub -* macos-latest on GitHub -* windows-latest on GitHub -## R CMD check results +* local Windows 11 x64, R 4.4.1 -0 errors √ | 0 warnings √ | 0 notes √ +## R CMD check results +0 errors | 0 warnings | 1 note -## Notes -* win-builder returns two notes: (1) "CRAN-pack does not know about .github", which is not returned otherwise (.github is needed for rhub and is in the .Rbuildignore); and (2) "This is a new release."" +* checking CRAN incoming feasibility ... NOTE + - New submission. Package was archived on CRAN: addressed above. + - Suggests or Enhances not in mainstream repositories: rWCVPdata. It is available + using the Additional_repositories specification, as above. -* Updated the Description per CRAN feedback. +(Locally there is a second NOTE, "unable to verify current time", which comes from the +local network rather than the package.) From b45221fe2d52827bf9381da0bfc3189ddd495d52 Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 01:30:21 -0400 Subject: [PATCH 5/7] Add a pre-release checklist in dev/ dev/release_checks.R follows glyospalettes': docs and figures, the --as-cran incoming checks, URL and spelling checks, tarball size, and the remote builders, run section by section before a CRAN submission. It adds the API-down checks that got 0.1.0 archived and all three Windows builders. dev/ is in .Rbuildignore. Co-Authored-By: Claude Opus 5.5 --- .Rbuildignore | 1 + dev/release_checks.R | 84 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 dev/release_checks.R diff --git a/.Rbuildignore b/.Rbuildignore index ace9176..50bacf6 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -10,3 +10,4 @@ ^CRAN-SUBMISSION$ ^\.claude$ ^dev_notes$ +^dev$ diff --git a/dev/release_checks.R b/dev/release_checks.R new file mode 100644 index 0000000..8d595a0 --- /dev/null +++ b/dev/release_checks.R @@ -0,0 +1,84 @@ +# Pre-release checklist for NSR. Source the sections one at a time from the +# package root. This folder is in .Rbuildignore and never ships. +# +# The GitHub Actions workflow (.github/workflows/R-CMD-check.yaml) already runs +# R CMD check on Linux/macOS/Windows for every push, so the steps here are the +# ones CRAN asks for beyond that: the --as-cran incoming checks, the API-down +# checks that got 0.1.0 archived, URL and spelling checks, tarball size, and +# the remote CRAN-like builders. + +# 1. Bookkeeping ------------------------------------------------------------ +# Bump Version in DESCRIPTION, add an entry to NEWS, update cran-comments.md +# (its Test environments should list the runs below once they pass). +# usethis::use_version("patch") # or "minor" / "major" +# usethis::use_release_issue() # tickable checklist as a GitHub issue + +# 2. Regenerate docs and figures -------------------------------------------- +devtools::document() + +# The README figures are drawn as SVG; the PNGs are rendered from them. Only +# needed after editing an SVG. +for (svg in list.files("man/figures", "\\.svg$", full.names = TRUE)) { + img <- magick::image_read(rsvg::rsvg_png(svg, width = 2000)) + img <- magick::image_quantize(img, max = 256, dither = FALSE) + magick::image_write(img, sub("\\.svg$", ".png", svg), format = "png") +} + +# 3. Local checks ----------------------------------------------------------- +devtools::test() +chk <- devtools::check(cran = TRUE, incoming = TRUE, remote = TRUE) +stopifnot(length(chk$errors) == 0, length(chk$warnings) == 0) +# Expected NOTE: "New submission / Package was archived on CRAN", and +# rWCVPdata (Suggests) not in mainstream repositories but available through +# Additional_repositories. Both are explained in cran-comments.md. +# "unable to verify current time" is the local network, not the package. + +# URLs in Rd files, README and DESCRIPTION. +urlchecker::url_check() + +# Spelling. There is no inst/WORDLIST yet: fix real typos, then run +# update_wordlist() to accept the remaining terms (taxon names, NSR, WCVP...). +spelling::spell_check_package() +# spelling::update_wordlist() # accept the current list + +# Tarball must stay comfortably under CRAN's 5 MB limit. man/figures ships. +tb <- devtools::build(path = tempdir(), quiet = TRUE) +cat(basename(tb), round(file.size(tb) / 1024), "KB\n") +stopifnot(file.size(tb) < 4 * 1024^2) + +# 4. With the API down ------------------------------------------------------ +# 0.1.0 was archived because CRAN's Windows machines couldn't reach the NSR +# API and NSR_metadata() stopped with an error. CRAN policy: functions that +# use the internet must fail gracefully (a message, no error or warning). +# These tests mock every exported API function through an unreachable API, +# an HTTP error, an empty reply and no internet access. +devtools::test(filter = "api_failure") + +# The vignette calls the API too, and is rebuilt during CRAN's checks. Render +# it with the API unreachable: it must build, showing messages, not stop. +devtools::load_all() +testthat::with_mocked_bindings( + rmarkdown::render("vignettes/NSR.Rmd", output_dir = tempdir(), quiet = TRUE), + check_internet = function() TRUE, + POST = function(...) stop("Could not resolve host: nsrapi.xyz"), + .package = "NSR") + +# 5. Remote builders (results arrive by email / GitHub) --------------------- +# All three Windows flavors failed for 0.1.0, so run all three. +devtools::check_win_devel() # CRAN's Windows R-devel builder +devtools::check_win_release() +devtools::check_win_oldrelease() +# R-hub v2 runs in this repo's own GitHub Actions (.github/workflows/rhub.yaml); +# rhub::rhub_doctor() passed on 2026-10-19. The platforms below are the +# CRAN-flavoured set for a pure-R package: the three OS runners, current and +# patched R, the alternative-BLAS Fedora build, no-Suggests, and \donttest +# examples. The call dispatches the workflow and prints the run URL; the +# package must be committed and pushed first, since the run checks what is on +# GitHub. +rhub::rhub_check(platforms = c("linux", "windows", "macos", + "ubuntu-release", "ubuntu-next", "atlas", + "nosuggests", "donttest")) + +# 6. Submit ----------------------------------------------------------------- +# devtools::release() # interactive checklist, then uploads +# After acceptance: usethis::use_github_release(); usethis::use_dev_version() From be5cedf742f52cd353878a3468b22e4b61a524fc Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 09:21:16 -0400 Subject: [PATCH 6/7] Don't reference TNRS functions missing from its CRAN release R CMD check on GitHub Actions warned "Missing or unexported object: 'TNRS::TNRS_local'" and "Missing object imported by a ':::' call: 'TNRS:::tnrs_cache_dir'". Both exist only in TNRS 0.4.0; CRAN has 0.3.6, so CRAN's checks would warn the same way. The local check passed only because TNRS 0.4.0 is installed here. TNRS_local() is now looked up at run time, with a clear error naming the version needed when the installed TNRS lacks it. TNRS's cache directory is computed from TNRS's own rule instead of calling its unexported tnrs_cache_dir(). R CMD check now passes against TNRS 0.3.6. Co-Authored-By: Claude Opus 5.5 --- R/local_build.R | 30 +++++++++++++++++++++++++--- R/local_import.R | 8 ++++++-- cran-comments.md | 6 ++++++ tests/testthat/test-local-tnrs.R | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 tests/testthat/test-local-tnrs.R diff --git a/R/local_build.R b/R/local_build.R index a13a210..26d6e44 100644 --- a/R/local_build.R +++ b/R/local_build.R @@ -276,6 +276,30 @@ nsr_match_poldiv <- function(country, state = NULL, bb) { out } +#' TNRS_local(), which TNRS added in 0.4.0 +#' +#' Internal. Looked up when it's needed rather than called as TNRS::TNRS_local(), so the +#' package installs and passes R CMD check with an older TNRS: only the offline build +#' needs it. +#' @keywords internal +#' @noRd +nsr_tnrs_local <- function() { + nsr_need("TNRS", what = "Resolving names against WCVP") + if (!nsr_has_export("TNRS", "TNRS_local")) { + stop("Resolving names against WCVP needs TNRS 0.4.0 or later, for TNRS_local(); the ", + "installed version is ", utils::packageVersion("TNRS"), ". Install the newer ", + "version with remotes::install_github(\"EnquistLab/RTNRS\").", call. = FALSE) + } + getExportedValue("TNRS", "TNRS_local") +} + +#' Whether a package exports a function +#' @keywords internal +#' @noRd +nsr_has_export <- function(pkg, name) { + name %in% getNamespaceExports(pkg) +} + #' Resolve checklist names to WCVP accepted species #' #' Internal. The id returned is the SPECIES' id, not an infraspecific one, so every @@ -283,9 +307,9 @@ nsr_match_poldiv <- function(country, state = NULL, bb) { #' @keywords internal #' @noRd nsr_resolve_names <- function(names_in, quiet = FALSE, species_index = NULL) { - nsr_need("TNRS", what = "Resolving names against WCVP") - r <- TNRS::TNRS_local(taxonomic_names = names_in, sources = "wcvp", matches = "best", - build_missing = FALSE, quiet = quiet) + tnrs_local <- nsr_tnrs_local() + r <- tnrs_local(taxonomic_names = names_in, sources = "wcvp", matches = "best", + build_missing = FALSE, quiet = quiet) acc <- r$Accepted_species acc[is.na(acc) | !nzchar(acc)] <- NA_character_ # prefer the species' own id where we know it, so subspecies land on their species diff --git a/R/local_import.R b/R/local_import.R index 0baacb0..030ce86 100644 --- a/R/local_import.R +++ b/R/local_import.R @@ -137,8 +137,12 @@ nsr_species_of <- function(ids, nm, max_steps = 3L) { #' @noRd nsr_find_wcvp_zip <- function() { if (!requireNamespace("TNRS", quietly = TRUE)) return(NULL) - d <- try(TNRS:::tnrs_cache_dir(), silent = TRUE) - if (inherits(d, "try-error") || !dir.exists(d)) return(NULL) + # TNRS's own rule for its cache directory (its tnrs_cache_dir(), which isn't exported) + d <- getOption("TNRS.cache_dir") + if (is.null(d) && !is.null(getNamespace("tools")[["R_user_dir"]])) { + d <- tools::R_user_dir("TNRS", which = "cache") + } + if (is.null(d) || !dir.exists(d)) return(NULL) f <- list.files(d, pattern = "^wcvp.*\\.zip$", full.names = TRUE) if (!length(f)) NULL else f[order(file.mtime(f), decreasing = TRUE)][1] } diff --git a/cran-comments.md b/cran-comments.md index eba0a8d..10f5b55 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -29,6 +29,12 @@ CRAN; it is available from the repository given in Additional_repositories, and used only conditionally, through requireNamespace(). We dropped rWCVP from Suggests, since it has been archived on CRAN. +NSR_local_build() resolves names with TNRS_local(), which TNRS (Suggests) adds in +version 0.4.0; CRAN currently has 0.3.6. The function is looked up at run time, after +checking that the installed TNRS exports it, and an informative error says which +version is needed if it doesn't. The package installs and passes R CMD check with +TNRS 0.3.6, and nothing outside the offline build uses TNRS. + ## Test environments * local Windows 11 x64, R 4.4.1 diff --git a/tests/testthat/test-local-tnrs.R b/tests/testthat/test-local-tnrs.R new file mode 100644 index 0000000..bfd22d1 --- /dev/null +++ b/tests/testthat/test-local-tnrs.R @@ -0,0 +1,34 @@ +# The offline build resolves names with TNRS_local(), which TNRS added in 0.4.0, and reads +# WCVP from TNRS's cache. Neither may be referenced in a way R CMD check resolves against +# the installed TNRS: with an older TNRS (CRAN's 0.3.6) the check warns that +# TNRS::TNRS_local is missing and that TNRS:::tnrs_cache_dir is unexported. + +test_that("the package code doesn't call TNRS_local() or TNRS internals directly", { + skip_on_cran() + src <- test_path("..", "..", "R") + skip_if_not(dir.exists(src), "package sources not available") + code <- unlist(lapply(list.files(src, "\\.R$", full.names = TRUE), readLines, warn = FALSE)) + code <- code[!grepl("^\\s*#", code)] + expect_false(any(grepl("TNRS::", code, fixed = TRUE))) +}) + +test_that("an older TNRS without TNRS_local() gives a clear error", { + skip_if_not_installed("TNRS") + local_mocked_bindings(nsr_has_export = function(pkg, name) FALSE) + expect_error(nsr_tnrs_local(), "needs TNRS 0.4.0 or later.*install_github") +}) + +test_that("TNRS_local() is found when TNRS exports it", { + skip_if_not_installed("TNRS") + skip_if_not("TNRS_local" %in% getNamespaceExports("TNRS"), "TNRS predates TNRS_local()") + expect_identical(nsr_tnrs_local(), getExportedValue("TNRS", "TNRS_local")) +}) + +test_that("WCVP is found in TNRS's cache directory, as TNRS sets it", { + skip_if_not_installed("TNRS") + d <- withr::local_tempdir() + withr::local_options(TNRS.cache_dir = d) + expect_null(nsr_find_wcvp_zip()) + file.create(file.path(d, "wcvp_dwca.zip")) + expect_equal(normalizePath(nsr_find_wcvp_zip()), normalizePath(file.path(d, "wcvp_dwca.zip"))) +}) From 853642ace203f2fc22171efacfb336085788d5f2 Mon Sep 17 00:00:00 2001 From: Brian Maitner Date: Mon, 19 Oct 2026 09:38:11 -0400 Subject: [PATCH 7/7] Restore any existing global results_json after the workspace test Under devtools::test(), the test overwrote a developer's own results_json and then removed it. It now puts back what was there, and removes the binding only if there was none. Co-Authored-By: Claude Opus 5.5 --- tests/testthat/test-NSR_api_failure.R | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-NSR_api_failure.R b/tests/testthat/test-NSR_api_failure.R index 46227bf..49aa3f1 100644 --- a/tests/testthat/test-NSR_api_failure.R +++ b/tests/testthat/test-NSR_api_failure.R @@ -61,12 +61,19 @@ test_that("NSR_metadata() stops at the first part it can't retrieve", { }) test_that("nsr_core() doesn't mistake a same-named object in the user's workspace for a reply", { - skip_on_cran() # it puts an object in the global environment, removed again on exit + skip_on_cran() # it puts an object in the global environment, put back as it was on exit local_mocked_bindings(check_internet = function() TRUE, POST = function(...) stop("Could not resolve host: nsrapi.xyz")) + # a developer running devtools::test() may have a results_json of their own + had <- exists("results_json", envir = globalenv(), inherits = FALSE) + if (had) previous <- get("results_json", envir = globalenv()) + withr::defer(if (had) { + assign("results_json", previous, envir = globalenv()) + } else { + rm("results_json", envir = globalenv()) + }) assign("results_json", fake_response(200L, '{"meta":{"db_version":"stale"}}'), envir = globalenv()) - withr::defer(rm("results_json", envir = globalenv())) expect_message(r <- NSR:::nsr_core(mode = "meta")) expect_null(r) })