diff --git a/NEWS.md b/NEWS.md index e5e5f400a..eef24aa79 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,33 @@ whitespace, so tools like pre-commit's `trailing-whitespace` hook no longer modify it. (#2411) +* Fixed an issue where `renv::restore()` and `renv::install()` would build a + package from its archived sources when the requested version was no longer + available from the configured repositories, even when P3M provided a binary + for that version. The dependency graph now consults P3M before falling back + to the archive, as it did before the graph-based installer (renv 1.2.0). + +* `renv::restore()` and `renv::install()` now report, before installing + anything, which of the requested packages are not available as binaries and + so will be compiled from source. On Windows, when no Rtools installation is + found, the report also names the version of Rtools required. Previously, + such installs failed only after reaching the first package needing + compilation. + +* On Windows, `renv::equip()` now downloads and runs the installer for the + version of Rtools matching the running version of R, when no Rtools + installation is found. The external libraries previously installed by + `equip()` are now only installed for R (< 4.0.0), as newer versions of + Rtools bundle them. + +* `renv::diagnostics()` now reports a description of the platform R is running + on: the R version and platform, the operating system (and Linux + distribution), the BLAS and LAPACK libraries in use, the versions of the + external software R was built against, the collation locale, the Rtools + installation (on Windows), the versions of pandoc, Quarto, Java and TeX + found on the system, and the installed versions of the system packages + required by the packages in the lockfile (on Linux). + * renv now recognizes the custom binary package types introduced with R 4.6.0 (of the form `.binary.`), along with binary packages distributed as `.tar.bz2`, `.tar.xz`, `.tar.zst` and `.tar.zstd` archives diff --git a/R/diagnostics.R b/R/diagnostics.R index 29aed10c8..b02dab891 100644 --- a/R/diagnostics.R +++ b/R/diagnostics.R @@ -30,6 +30,7 @@ diagnostics <- function(project = NULL) { renv_diagnostics_status, renv_diagnostics_packages, renv_diagnostics_sysreqs, + renv_diagnostics_platform, renv_diagnostics_r, renv_diagnostics_abi, renv_diagnostics_profile, @@ -193,6 +194,12 @@ renv_diagnostics_sysreqs <- function(project) { } +renv_diagnostics_platform <- function(project) { + writef(header("Platform")) + record <- renv_platform_record(project = project) + renv_json_write(record, file = stdout()) +} + renv_diagnostics_packages_version <- function(lockfile, all) { data <- rep.int(NA_character_, length(all)) diff --git a/R/equip.R b/R/equip.R index 81de6215b..db2449311 100644 --- a/R/equip.R +++ b/R/equip.R @@ -1,17 +1,25 @@ -#' Install required system libraries +#' Install a build toolchain #' -#' Equip your system with libraries commonly-used during compilation of -#' base and recommended \R packages. This was previously useful with older -#' versions of R on windows, but is no longer terribly helpful. +#' Equip your system with the tools required to build \R packages from +#' source. This is useful when a package recorded in the lockfile is not +#' available as a binary for your version of \R, as can happen when +#' restoring an older project. +#' +#' On Windows, `equip()` downloads and runs the installer for the version of +#' [Rtools](https://cran.r-project.org/bin/windows/Rtools/) matching the +#' running version of \R. The installer may request administrator +#' privileges. (For \R versions older than 4.0.0, `equip()` instead installs +#' the external libraries once used when building packages on Windows.) +#' +#' On macOS, `equip()` ensures the Xcode command line tools are installed. #' #' @return This function is normally called for its side effects. #' @export -#' @keywords internal #' @examples #' \dontrun{ #' -#' # download useful build tools +#' # install a build toolchain, if one is not already available #' renv::equip() #' #' } @@ -30,7 +38,97 @@ equip <- function() { } renv_equip_windows <- function() { - invisible(renv_extsoft_install() && renv_extsoft_use()) + + # Rtools 4.0 and newer bundle the libraries once provided by extsoft + if (getRversion() < "4.0.0") + return(invisible(renv_extsoft_install() && renv_extsoft_use())) + + invisible(renv_equip_windows_rtools()) + +} + +renv_equip_windows_rtools <- function() { + + # an explicitly-requested Rtools installation needn't be discoverable + root <- renv_paths_override("rtools") + if (!is.null(root) && file.exists(root)) { + writef("- Rtools is already installed at %s.", renv_path_pretty(root)) + return(TRUE) + } + + spec <- renv_rtools_find() + if (!is.null(spec)) { + fmt <- "- Rtools %s is already installed at %s." + writef(fmt, spec$version, renv_path_pretty(spec$root)) + return(TRUE) + } + + if (renv_rtools_path_available()) { + writef("- A build toolchain is already available on the PATH.") + return(TRUE) + } + + series <- renv_rtools_required(getRversion(), renv_rtools_series_available()) + url <- renv_rtools_installer_url(series) + + if (interactive()) { + + bulletin( + sprintf("Rtools %s will be downloaded and installed from:", series), + url, + "The installer may request administrator privileges." + ) + + cancel_if(!proceed()) + + } + + destfile <- file.path(tempdir(), basename(url)) + download(url, destfile = destfile) + renv_equip_windows_rtools_run(destfile) + + # the installer is run via 'start', so check for the installation itself + # rather than relying on the exit status + spec <- renv_rtools_find() + if (is.null(spec)) { + + bulletin( + "renv was unable to verify that Rtools was installed. You can run the installer manually:", + renv_path_pretty(destfile), + "Use the default installation location so that R can find it." + ) + + return(FALSE) + + } + + fmt <- "- Rtools %s was installed at %s." + writef(fmt, spec$version, renv_path_pretty(spec$root)) + TRUE + +} + +renv_equip_windows_rtools_run <- function(installer) { + + # the installer requests elevation, which CreateProcess() (and so system2()) + # refuses; launch it through the shell instead so that Windows can prompt. + # note that 'start' treats its first quoted argument as a window title, so + # an empty title ("") is required for the installer path to be run + args <- c( + "/c", "start", "\"\"", "/wait", + shQuote(installer), + "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" + ) + + printf("- Running the Rtools installer ... ") + status <- suppressWarnings(system2("cmd", args, stdout = FALSE, stderr = FALSE)) + if (identical(status, 0L)) + writef("Done!") + else + writef("FAILED [exit code %s]", status) + + invisible(status) + } renv_equip_linux <- function() { diff --git a/R/graph.R b/R/graph.R index 5e468c085..f51e11623 100644 --- a/R/graph.R +++ b/R/graph.R @@ -1204,8 +1204,15 @@ renv_graph_url_repository <- function(desc) { } - # the requested version is not in current available packages; - # try the CRAN-style archive (source only) + # the requested version is not in current available packages. P3M may + # still have a binary for it, from the snapshot taken when it was current; + # prefer that over building the archived sources, as sequential retrieval + # does (renv_retrieve_repos()) + info <- renv_graph_url_repository_p3m(desc) + if (!is.null(info)) + return(info) + + # otherwise, try the CRAN-style archive (source only) baseurl <- renv_repos_baseurl(attr(record, "url", exact = TRUE)) root <- renv_retrieve_repos_archive_root(baseurl, as.list(desc)) @@ -1230,6 +1237,46 @@ renv_graph_url_repository <- function(desc) { } +# the P3M binary for this record on the current platform, if P3M is in use +# and its database knows of one +renv_graph_url_repository_p3m <- function(desc) { + + if (!renv_p3m_enabled(the$install_pkg_type)) + return(NULL) + + # in strict mode, records naming a repository are only retrieved from it + if (renv_graph_url_repository_strict(desc)) + return(NULL) + + # a failure to refresh or read the P3M database shouldn't abort + # resolution; the archive remains available + record <- as.list(desc) + date <- catch(renv_retrieve_repos_p3m_date(record)) + if (inherits(date, "error") || is.null(date)) + return(NULL) + + suffix <- contrib.url("", type = "binary") + base <- renv_p3m_url(date, suffix) + name <- renv_retrieve_name(record, type = "binary") + + list( + url = file.path(base, name), + destfile = renv_retrieve_path(record, type = "binary"), + type = "repository", + pkgtype = "binary", + repourl = base, + reponame = "P3M" + ) + +} + +# is this record restricted to the repository it names? see restore(strict = TRUE) +renv_graph_url_repository_strict <- function(desc) { + repository <- desc[["Repository"]] + strict <- renv_restore_state(key = "strict") %||% FALSE + strict && is.character(repository) && grepl("://", repository, fixed = TRUE) +} + # the package types to search, in preference order, when the record we're # resolving carries no type tag of its own renv_graph_url_repository_types <- function() { diff --git a/R/install.R b/R/install.R index 7c0322fea..a36b0161c 100644 --- a/R/install.R +++ b/R/install.R @@ -278,6 +278,12 @@ install <- function(packages = NULL, return(invisible(list())) } + # report which of the resolved packages will be built from source; this + # has to wait until dependencies are resolved, as compiled dependencies + # of the requested packages are just as likely to need a toolchain + if (!renv_install_preflight_binaries(descriptions[needed], project)) + cancel_if(prompt && !proceed()) + if (prompt || renv_verbose()) { renv_install_report(descriptions[needed], library = library) cancel_if(prompt && !proceed()) @@ -855,6 +861,145 @@ renv_install_preflight <- function(project, libpaths, records) { } +# report the packages which will be compiled from source because no binary +# is available for the requested version. on Windows without Rtools, such +# installs are going to fail, so this doubles as a toolchain check there. +# +# 'records' should be the packages actually being installed (after dependency +# resolution), and the restore state should be scoped so that the same cache +# and rebuild policy as the installer applies +renv_install_preflight_binaries <- function(records, project) { + + binaries <- "binary" %in% renv_graph_url_repository_types() + rtools <- !renv_platform_windows() || renv_rtools_available() + + # when binaries aren't in use, every package is built from source by + # choice (or by necessity, on Linux), so there's nothing to report + # unless those builds are going to fail + if (!binaries && rtools) + return(TRUE) + + renv_install_preflight_binaries_impl(records, project, rtools = rtools) + +} + +renv_install_preflight_binaries_impl <- function(records, + project = NULL, + rtools = TRUE, + version = getRversion()) +{ + # packages whose resolution failed have no description to classify; the + # failure is reported after the install, with the other failures + records <- filter(records, function(record) { + !isTRUE(attr(record, "resolution_failed", exact = TRUE)) + }) + + types <- map_chr(records, renv_install_preflight_binaries_type, project = project) + + compiled <- renv_install_preflight_binaries_format(records[types == "compiled"]) + unknown <- renv_install_preflight_binaries_format(records[types == "unknown"]) + + # with a toolchain, only packages known to need compiling are worth noting + if (rtools) { + fmt <- "The following %s will be compiled from source, as no binaries are available:" + bulletin(sprintf(fmt, nplural("package", length(compiled))), compiled) + return(TRUE) + } + + # without one, any package which might need compiling is going to fail; + # packages whose descriptions don't say (as is common for packages not yet + # built, e.g. from GitHub) are reported separately, as the install may + # well succeed for those + if (empty(compiled) && empty(unknown)) + return(TRUE) + + fmt <- "The following %s will need to be compiled from source, as no binaries are available:" + bulletin(sprintf(fmt, nplural("package", length(compiled))), compiled) + + fmt <- "The following %s will be built from source, and may need to be compiled:" + bulletin(sprintf(fmt, nplural("package", length(unknown))), unknown) + + fmt <- "Compiling packages requires Rtools %s, which was not found." + caution(fmt, renv_rtools_required(version)) + caution("Call renv::equip() to install it, or download it from https://cran.r-project.org/bin/windows/Rtools/.") + caution() + + FALSE + +} + +renv_install_preflight_binaries_format <- function(records) { + + values <- map_chr(records, function(record) { + sprintf("%s [%s]", record$Package, record$Version %||% "*") + }) + + sort(unname(values)) + +} + +# how will this record be installed? one of: +# +# "none" -- no build is needed (binary, cache, or not checked), +# "source" -- built from source, without compiled code, +# "compiled" -- built from source, with compiled code, +# "unknown" -- built from source; unknown whether it has compiled code +# +renv_install_preflight_binaries_type <- function(record, project = NULL) { + + # an already-built copy in the cache is reused as-is, unless the installer + # is going to ignore it (cache disabled, or a rebuild was requested) + if (!is.null(renv_graph_install_cache_find(record, project))) + return("none") + + package <- record$Package + version <- record$Version + source <- renv_record_source(record, normalize = TRUE) + + # Bioconductor repositories are only added to the repos option later + # during the install, so we can't yet tell whether a binary is available + if (source == "bioconductor") + return("none") + + # packages from the cellar may be available as binaries + if (source %in% c("cellar", "local")) { + path <- catch(renv_retrieve_cellar_find(record)) + if (!inherits(path, "error") && identical(names(path), "binary")) + return("none") + } + + if (source == "repository") { + + # ask the resolver how it would retrieve this record, so that the + # repositories, P3M, strict mode and the archive are weighed exactly as + # the installer will. a record it can't resolve is retrieved sequentially, + # where a binary might yet be found; assume a source build for those, as + # that errs towards reporting + info <- catch(renv_graph_url_repository(record)) + if (!inherits(info, "error") && identical(info$pkgtype, "binary")) + return("none") + + } + + # a source build is needed. a resolved description already says whether + # compiling is needed; otherwise, that can only be read from the current + # release of the package in the source repositories + needs <- record[["NeedsCompilation"]] + if (is.null(needs)) { + entry <- catch(renv_available_packages_entry(package, type = "source")) + if (inherits(entry, "error")) + return("unknown") + needs <- entry[["NeedsCompilation"]] + } + + case( + identical(needs, "yes") ~ "compiled", + identical(needs, "no") ~ "source", + ~ "unknown" + ) + +} + renv_install_report <- function(records, library) { renv_pretty_print_records( "The following package(s) will be installed:", diff --git a/R/platform-record.R b/R/platform-record.R new file mode 100644 index 000000000..0a7f5c41d --- /dev/null +++ b/R/platform-record.R @@ -0,0 +1,276 @@ + +# a description of the system R is running on: the aspects of the environment +# outside of the R library which can influence the results of an analysis, and +# so which need to be known when reproducing one elsewhere. +# +# for now this is only reported by diagnostics(). whether it should be recorded +# in the lockfile, or in a separate file, is still an open question; a lockfile +# is meant to be portable, and this record is specific to a single machine. +renv_platform_record <- function(project = NULL) { + + project <- renv_project_resolve(project) + + software <- renv_platform_record_extsoft() + + record <- list( + R = renv_platform_record_r(), + OS = renv_platform_record_os(), + BLAS = renv_platform_record_blas(software), + LAPACK = renv_platform_record_lapack(), + Software = renv_platform_record_software(software), + Locale = renv_platform_record_locale(), + Rtools = renv_platform_record_rtools(), + Tools = renv_platform_record_tools(), + SystemPackages = renv_platform_record_syspkgs(project) + ) + + reject(record, is.null) + +} + +renv_platform_record_r <- function() { + + list( + Version = format(getRversion()), + Platform = R.version$platform + ) + +} + +renv_platform_record_os <- function() { + + info <- the$sysinfo + + record <- list( + Name = info$sysname, + Release = info$release, + Version = info$version, + Machine = info$machine, + Description = renv_platform_record_os_description() + ) + + # on Linux, the distribution matters more than the kernel version + if (renv_platform_linux()) { + osrelease <- the$platform + record$Distro <- osrelease$ID + record$DistroVersion <- osrelease$VERSION_ID + record$Codename <- osrelease$VERSION_CODENAME + } + + reject(record, is.null) + +} + +renv_platform_record_os_description <- function() { + + # utils::osVersion is only available with R (>= 3.5.0) + utils <- asNamespace("utils") + if (!exists("osVersion", envir = utils, inherits = FALSE)) + return(NULL) + + get("osVersion", envir = utils, inherits = FALSE) + +} + +# the versions of the external software R was built against, or NULL +renv_platform_record_extsoft <- function() { + + software <- catch(extSoftVersion()) + if (inherits(software, "error")) + return(NULL) + + software + +} + +renv_platform_record_blas <- function(software = renv_platform_record_extsoft()) { + + blas <- software[["BLAS"]] + if (is.null(blas) || !nzchar(blas)) + return(NULL) + + blas + +} + +renv_platform_record_lapack <- function() { + + library <- catch(La_library()) + if (inherits(library, "error") || !nzchar(library)) + return(NULL) + + version <- catch(La_version()) + if (inherits(version, "error")) + version <- NULL + + reject(list(Library = library, Version = version), is.null) + +} + +renv_platform_record_software <- function(software = renv_platform_record_extsoft()) { + + if (is.null(software)) + return(NULL) + + # BLAS is reported separately + software <- software[setdiff(names(software), "BLAS")] + software <- software[nzchar(software)] + if (empty(software)) + return(NULL) + + as.list(software) + +} + +renv_platform_record_locale <- function() { + + list( + Collate = Sys.getlocale("LC_COLLATE"), + Ctype = Sys.getlocale("LC_CTYPE") + ) + +} + +renv_platform_record_rtools <- function() { + + if (!renv_platform_windows()) + return(NULL) + + spec <- renv_rtools_find() + if (is.null(spec)) + return(NULL) + + list( + Version = as.character(spec$version), + Path = spec$root + ) + +} + +# the installed versions of the system packages required by the packages in +# the project lockfile +renv_platform_record_syspkgs <- function(project) { + + if (!renv_platform_linux()) + return(NULL) + + type <- renv_sysreqs_type() + if (is.null(type)) + return(NULL) + + lockpath <- renv_lockfile_path(project) + if (!file.exists(lockpath)) + return(NULL) + + lockfile <- renv_lockfile_read(lockpath) + records <- renv_lockfile_records(lockfile) + sysreqs <- map(records, `[[`, "SystemRequirements") + sysdeps <- map(sysreqs, renv_sysreqs_resolve) + syspkgs <- sort(unique(unlist(map(sysdeps, `[[`, "packages"), use.names = FALSE))) + if (empty(syspkgs)) + return(NULL) + + # resolve virtual packages to the packages actually installed + aliases <- renv_sysreqs_aliases(type, syspkgs) + syspkgs <- alias(syspkgs, aliases) + + versions <- renv_sysreqs_versions(type, syspkgs) + if (empty(versions)) + return(NULL) + + versions + +} + +# external tools, outside of R, which commonly take part in producing the +# outputs of a project. these are reported, not managed, by renv +renv_platform_record_tools <- function() { + + tools <- list( + Pandoc = renv_platform_record_tool(renv_platform_record_tool_pandoc(), "--version"), + Quarto = renv_platform_record_tool(renv_platform_record_tool_quarto(), "--version"), + Java = renv_platform_record_tool(renv_platform_record_tool_java(), "-version"), + TeX = renv_platform_record_tool(Sys.which("pdflatex"), "--version") + ) + + tools <- reject(tools, is.null) + if (empty(tools)) + return(NULL) + + tools + +} + +renv_platform_record_tool <- function(path, args) { + + if (!nzchar(path) || !file.exists(path)) + return(NULL) + + # the first line of output is enough to identify the version + output <- catch( + suppressWarnings( + system2(path, args, stdout = TRUE, stderr = TRUE, timeout = 10L) + ) + ) + + version <- if (!inherits(output, "error")) + renv_platform_record_tool_version(output) + + reject(list(Version = version, Path = renv_path_normalize(path)), is.null) + +} + +renv_platform_record_tool_version <- function(output) { + + # a tool which fails to run (e.g. macOS's java stub, without a JDK) + # reports an error rather than a version + status <- attr(output, "status", exact = TRUE) %||% 0L + if (!identical(as.integer(status), 0L)) + return(NULL) + + # the JVM announces options picked up from the environment (e.g. + # JAVA_TOOL_OPTIONS) on stderr before anything else + output <- grep("^Picked up ", output, value = TRUE, invert = TRUE) + if (empty(output)) + return(NULL) + + trimws(output[[1L]]) + +} + +# the first of a set of candidate paths which exists, or "" +renv_platform_record_tool_find <- function(name, dirs) { + + exe <- if (renv_platform_windows()) paste0(name, ".exe") else name + dirs <- dirs[nzchar(dirs)] + for (dir in dirs) { + path <- file.path(dir, exe) + if (file.exists(path)) + return(path) + } + + Sys.which(name) + +} + +renv_platform_record_tool_pandoc <- function() { + dirs <- Sys.getenv("RSTUDIO_PANDOC") + renv_platform_record_tool_find("pandoc", dirs) +} + +renv_platform_record_tool_quarto <- function() { + + # QUARTO_PATH is the path to the quarto executable itself + path <- Sys.getenv("QUARTO_PATH") + if (nzchar(path) && file.exists(path)) + return(path) + + renv_platform_record_tool_find("quarto", character()) + +} + +renv_platform_record_tool_java <- function() { + home <- Sys.getenv("JAVA_HOME") + dirs <- if (nzchar(home)) file.path(home, "bin") + renv_platform_record_tool_find("java", dirs %||% character()) +} diff --git a/R/restore.R b/R/restore.R index 02a644a80..eb26f78e1 100644 --- a/R/restore.R +++ b/R/restore.R @@ -238,6 +238,18 @@ restore <- function(project = NULL, current <- renv_restore_normalize(current) lockfile <- renv_restore_normalize(lockfile) + # the restore state carries the rebuild and strict settings, which the + # preflight checks below consult as well; the dependency graph resolves + # dependencies below, so legacy retrieval must not crawl them again + renv_scope_restore( + project = project, + library = renv_libpaths_active(), + records = renv_lockfile_records(lockfile), + packages = names(diff), + rebuild = rebuild, + strict = strict, + recursive = FALSE + ) # TODO: should we avoid double-prompting here? # we prompt once here for the preflight check, and then again below based @@ -251,27 +263,14 @@ restore <- function(project = NULL, } # perform the restore - records <- renv_restore_run_actions(project, diff, current, lockfile, rebuild, strict, descriptions, retry) + records <- renv_restore_run_actions(project, diff, current, lockfile, descriptions, retry) renv_restore_successful(records, prompt, project) } -renv_restore_run_actions <- function(project, actions, current, lockfile, rebuild, strict = FALSE, descriptions = NULL, retry = NULL) { +renv_restore_run_actions <- function(project, actions, current, lockfile, descriptions = NULL, retry = NULL) { - packages <- names(actions) lockrecords <- renv_lockfile_records(lockfile) - # the dependency graph resolves dependencies below, so legacy retrieval - # must not crawl them again - renv_scope_restore( - project = project, - library = renv_libpaths_active(), - records = lockrecords, - packages = packages, - rebuild = rebuild, - strict = strict, - recursive = FALSE - ) - # first, handle package removals removes <- actions[actions == "remove"] enumerate(removes, function(package, action) { @@ -503,7 +502,12 @@ renv_restore_remove <- function(project, package, lockfile) { renv_restore_preflight <- function(project, libpaths, actions, current, lockfile) { records <- renv_lockfile_records(lockfile) matching <- keep(records, names(actions)) - renv_install_preflight(project, libpaths, matching) + + all( + renv_install_preflight(project, libpaths, matching), + renv_install_preflight_binaries(matching, project) + ) + } renv_restore_find <- function(package, record) { diff --git a/R/retrieve.R b/R/retrieve.R index 7d0535961..a1759403b 100644 --- a/R/retrieve.R +++ b/R/retrieve.R @@ -1064,11 +1064,13 @@ renv_retrieve_repos_archive_name <- function(record, type = "source") { } -renv_retrieve_repos_p3m <- function(record) { +# the date of the P3M snapshot providing a binary for this record on the +# current platform, or NULL if no such binary is known +renv_retrieve_repos_p3m_date <- function(record) { # TODO: support Linux if (renv_platform_linux()) - return(FALSE) + return(NULL) # ensure local database is up-to-date renv_p3m_database_refresh(explicit = FALSE) @@ -1076,31 +1078,40 @@ renv_retrieve_repos_p3m <- function(record) { # check that we have an available database path <- renv_p3m_database_path() if (!file.exists(path)) - return(FALSE) + return(NULL) # attempt to read it database <- catch(renv_p3m_database_load()) if (inherits(database, "error")) { warning(database) - return(FALSE) + return(NULL) } # get entry for this version of R + platform suffix <- contrib.url("", type = "binary") entry <- database[[suffix]] if (is.null(entry)) - return(FALSE) + return(NULL) # check for known entry for this package + version key <- paste(record$Package, record$Version) idate <- entry[[key]] if (is.null(idate)) - return(FALSE) + return(NULL) # convert from integer to date - date <- as.Date(idate, origin = "1970-01-01") + as.Date(idate, origin = "1970-01-01") + +} + +renv_retrieve_repos_p3m <- function(record) { + + date <- renv_retrieve_repos_p3m_date(record) + if (is.null(date)) + return(FALSE) # form url to binary package + suffix <- contrib.url("", type = "binary") base <- renv_p3m_url(date, suffix) name <- renv_retrieve_name(record, type = "binary") url <- file.path(base, name) diff --git a/R/rtools.R b/R/rtools.R index cbe22f3ea..54bba0b25 100644 --- a/R/rtools.R +++ b/R/rtools.R @@ -12,6 +12,7 @@ renv_rtools_list <- function() { Sys.getenv("RTOOLS43_HOME", unset = file.path(drive, "rtools43")), Sys.getenv("RTOOLS42_HOME", unset = file.path(drive, "rtools42")), Sys.getenv("RTOOLS40_HOME", unset = file.path(drive, "rtools40")), + file.path(drive, list.files(paste0(drive, "/"), pattern = "^rtools\\d\\d$")), file.path(drive, "Rtools"), list.files(file.path(drive, "RBuildTools"), full.names = TRUE), @@ -25,13 +26,16 @@ renv_rtools_list <- function() { } -renv_rtools_find <- function() { +renv_rtools_find <- function(rversion = getRversion()) { - for (spec in renv_rtools_list()) - if (renv_rtools_compatible(spec)) - return(spec) + specs <- filter(renv_rtools_list(), renv_rtools_compatible, rversion = rversion) + if (empty(specs)) + return(NULL) - NULL + # several series can be compatible with one version of R, since each + # serves newer releases until the next series appears; prefer the newest + versions <- numeric_version(map_chr(specs, function(spec) format(spec$version))) + specs[[order(versions, decreasing = TRUE)[[1L]]]] } @@ -71,7 +75,7 @@ renv_rtools_version <- function(root) { } -renv_rtools_compatible <- function(spec) { +renv_rtools_compatible <- function(spec, rversion = getRversion()) { if (is.null(spec$version)) return(FALSE) @@ -90,11 +94,14 @@ renv_rtools_compatible <- function(spec) { ) version <- numeric_version(spec$version)[1, 1:2] + rversion <- numeric_version(rversion) + + # newer series than those listed above follow the same pattern as + # Rtools 4.2 onwards: they support R from the matching minor release range <- ranges[[format(version)]] if (is.null(range)) - return(FALSE) + return(version >= "4.6" && rversion >= version) - rversion <- getRversion() range[[1]] <= rversion && rversion < range[[2]] } @@ -184,3 +191,145 @@ renv_rtools_envvars_rtools40 <- function(root) { list(PATH = path, BINPREF = binpref) } + +# the Rtools series known to be available from CRAN; used when the +# list can't be read from CRAN itself +renv_rtools_series <- function() { + c("4.0", "4.2", "4.3", "4.4", "4.5") +} + +# the Rtools series (e.g. "4.5") appropriate for a version of R: the newest +# series which is not newer than R itself, as each series supports the +# matching minor release of R, and (until the next series) newer releases +renv_rtools_required <- function(version = getRversion(), + series = renv_rtools_series()) +{ + version <- numeric_version(version) + if (version < "4.0.0") + return("3.5") + + # a series newer than R itself doesn't support it, so when none of the + # series provided are old enough (e.g. an index page which has dropped the + # oldest series), fall back to the known series instead + series <- sort(numeric_version(series)) + candidates <- series[series <= version[1, 1:2]] + if (empty(candidates)) + return(renv_rtools_required(version, renv_rtools_series())) + + format(max(candidates)) +} + +# the URL of the CRAN page describing a series of Rtools +renv_rtools_page_url <- function(series) { + + root <- "https://cran.r-project.org/bin/windows/Rtools" + nn <- gsub(".", "", series, fixed = TRUE) + + # Rtools 4.0 is documented on a single page; later series have their own + if (series == "4.0") + file.path(root, sprintf("rtools%s.html", nn)) + else + file.path(root, sprintf("rtools%s", nn), "rtools.html") + +} + +# the Rtools series listed on the CRAN Rtools index page +renv_rtools_series_available <- function() { + + root <- "https://cran.r-project.org/bin/windows/Rtools" + destfile <- renv_scope_tempfile("renv-rtools-", fileext = ".html") + html <- catch(download(file.path(root, "index.html"), destfile, quiet = TRUE)) + if (inherits(html, "error")) + return(renv_rtools_series()) + + series <- renv_rtools_series_parse(readLines(destfile, warn = FALSE)) + if (empty(series)) + return(renv_rtools_series()) + + series + +} + +renv_rtools_series_parse <- function(html) { + + pattern <- "rtools(\\d)(\\d)(?:/rtools)?[.]html" + matches <- regmatches(html, gregexpr(pattern, html, perl = TRUE)) + links <- unique(unlist(matches, use.names = FALSE)) + + series <- gsub(pattern, "\\1.\\2", links, perl = TRUE) + format(sort(numeric_version(series))) + +} + +# the URL of the installer for a series of Rtools, as linked from its page +# on CRAN; the installer file names include build numbers which change +# over time, so they have to be read from the page +renv_rtools_installer_url <- function(series, arch = R.version$arch) { + + pageurl <- renv_rtools_page_url(series) + destfile <- renv_scope_tempfile("renv-rtools-", fileext = ".html") + download(pageurl, destfile, quiet = TRUE) + html <- readLines(destfile, warn = FALSE) + + url <- renv_rtools_installer_url_parse(html, series, arch) + if (is.null(url)) + stopf("no Rtools %s installer for '%s' was found at %s", series, arch, pageurl) + + # links on the page are relative to the page itself + if (!grepl("^\\w+://", url)) + url <- file.path(dirname(pageurl), url) + + url + +} + +renv_rtools_installer_url_parse <- function(html, series, arch = R.version$arch) { + + pattern <- "href=\"([^\"]*rtools[^\"/]*[.]exe)\"" + matches <- regmatches(html, gregexpr(pattern, html, perl = TRUE)) + links <- gsub(pattern, "\\1", unique(unlist(matches, use.names = FALSE)), perl = TRUE) + + # only installers for the requested series are wanted + names <- basename(links) + prefix <- sprintf("rtools%s", gsub(".", "", series, fixed = TRUE)) + links <- links[startsWith(names, prefix)] + if (empty(links)) + return(NULL) + + # installers for other architectures are distinguished by name; + # the x86_64 installer is the unmarked one on newer pages + names <- basename(links) + aarch64 <- grepl("aarch64", names, fixed = TRUE) + i686 <- grepl("i686", names, fixed = TRUE) + + candidates <- case( + arch == "aarch64" ~ links[aarch64], + arch == "i386" ~ links[i686], + ~ links[!aarch64 & !i686] + ) + + if (empty(candidates)) + return(NULL) + + candidates[[1L]] + +} + +# is a build toolchain available for use with this version of R? +renv_rtools_available <- function() { + + # an explicitly-requested or discovered Rtools installation + root <- renv_paths_rtools() + if (nzchar(root) && file.exists(root)) + return(TRUE) + + # otherwise, check for a toolchain the user has placed on the PATH + renv_rtools_path_available() + +} + +# is a build toolchain (e.g. Rtools, or MSYS2) available on the PATH? +renv_rtools_path_available <- function() { + make <- Sys.getenv("MAKE", unset = "make") + nzchar(Sys.which(make)) && nzchar(Sys.which("gcc")) +} diff --git a/R/sysreqs.R b/R/sysreqs.R index 54e2c2002..6de711a50 100644 --- a/R/sysreqs.R +++ b/R/sysreqs.R @@ -686,3 +686,54 @@ renv_sysreqs_update <- function() { renv_json_write(contents, file = dest) } + +# the installed versions of a set of system packages, as a named list. +# packages which are not installed are omitted +renv_sysreqs_versions <- function(type, packages) { + + if (empty(packages)) + return(list()) + + # dpkg knows about packages which were removed but not purged (the + # 'config-files' state), and reports their versions, so ask for the status + # too. rpm only knows about installed packages + if (type == "deb") { + command <- "dpkg-query" + args <- c("-W", "-f", shQuote("${Package} ${Version} ${db:Status-Status}\\n")) + } else if (type == "rpm") { + command <- "rpm" + args <- c("-q", "--qf", shQuote("%{NAME} %{VERSION}-%{RELEASE} installed\\n")) + } else { + return(list()) + } + + # both tools report on the packages they do know about even when + # some are missing, so ignore the resulting non-zero exit status + output <- suppressWarnings( + system2( + command = command, + args = c(args, shQuote(packages)), + stdout = TRUE, + stderr = FALSE + ) + ) + + renv_sysreqs_versions_parse(output, packages) + +} + +# parse ' ' lines as emitted above +renv_sysreqs_versions_parse <- function(output, packages) { + + parts <- strsplit(as.character(output), " ", fixed = TRUE) + parts <- filter(parts, function(part) { + length(part) == 3L && + part[[1L]] %in% packages && + identical(part[[3L]], "installed") + }) + + versions <- extract_chr(parts, 2L) + names(versions) <- extract_chr(parts, 1L) + as.list(versions[order(names(versions))]) + +} diff --git a/_pkgdown.yml b/_pkgdown.yml index dd532c5f3..4d45a6657 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -70,6 +70,7 @@ reference: - checkout - consent - diagnostics + - equip - plan - refresh - run diff --git a/man/equip.Rd b/man/equip.Rd index 36d5ee163..70159be3a 100644 --- a/man/equip.Rd +++ b/man/equip.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/equip.R \name{equip} \alias{equip} -\title{Install required system libraries} +\title{Install a build toolchain} \usage{ equip() } @@ -10,16 +10,25 @@ equip() This function is normally called for its side effects. } \description{ -Equip your system with libraries commonly-used during compilation of -base and recommended \R packages. This was previously useful with older -versions of R on windows, but is no longer terribly helpful. +Equip your system with the tools required to build \R packages from +source. This is useful when a package recorded in the lockfile is not +available as a binary for your version of \R, as can happen when +restoring an older project. +} +\details{ +On Windows, \code{equip()} downloads and runs the installer for the version of +\href{https://cran.r-project.org/bin/windows/Rtools/}{Rtools} matching the +running version of \R. The installer may request administrator +privileges. (For \R versions older than 4.0.0, \code{equip()} instead installs +the external libraries once used when building packages on Windows.) + +On macOS, \code{equip()} ensures the Xcode command line tools are installed. } \examples{ \dontrun{ -# download useful build tools +# install a build toolchain, if one is not already available renv::equip() } } -\keyword{internal} diff --git a/tests/testthat/_snaps/install-preflight.md b/tests/testthat/_snaps/install-preflight.md new file mode 100644 index 000000000..f476a1fc6 --- /dev/null +++ b/tests/testthat/_snaps/install-preflight.md @@ -0,0 +1,40 @@ +# packages to be compiled from source are reported + + Code + status <- renv_install_preflight_binaries_impl(records, rtools = TRUE) + Output + The following 2 packages will be compiled from source, as no binaries are available: + - ragg [1.3.0] + - stringi [1.8.4] + + +--- + + Code + status <- renv_install_preflight_binaries_impl(records, rtools = FALSE, + version = "4.5.1") + Output + The following 2 packages will need to be compiled from source, as no binaries are available: + - ragg [1.3.0] + - stringi [1.8.4] + + The following 1 package will be built from source, and may need to be compiled: + - mypkg [0.1.0] + + Compiling packages requires Rtools 4.5, which was not found. + Call renv::equip() to install it, or download it from https://cran.r-project.org/bin/windows/Rtools/. + + +--- + + Code + status <- renv_install_preflight_binaries_impl(records["mypkg"], rtools = FALSE, + version = "4.5.1") + Output + The following 1 package will be built from source, and may need to be compiled: + - mypkg [0.1.0] + + Compiling packages requires Rtools 4.5, which was not found. + Call renv::equip() to install it, or download it from https://cran.r-project.org/bin/windows/Rtools/. + + diff --git a/tests/testthat/helper-testthat.R b/tests/testthat/helper-testthat.R index 1f12513e6..afb9abee2 100644 --- a/tests/testthat/helper-testthat.R +++ b/tests/testthat/helper-testthat.R @@ -62,6 +62,12 @@ renv_test_state <- function(cran) { opts <- opts[csort(names(opts))] envvars <- as.list(Sys.getenv()) + + # environment variable names are case-insensitive on Windows, and setting a + # variable through a differently-cased name rewrites the stored name + if (renv_platform_windows()) + names(envvars) <- toupper(names(envvars)) + envvars <- envvars[grep("^RENV_DEFAULT_", names(envvars), invert = TRUE)] envvars <- envvars[grep("^R_PACKRAT_", names(envvars), invert = TRUE)] envvars <- envvars[grep("^_R_", names(envvars), invert = TRUE)] diff --git a/tests/testthat/test-graph.R b/tests/testthat/test-graph.R index 1ed24ef34..6291dbc1c 100644 --- a/tests/testthat/test-graph.R +++ b/tests/testthat/test-graph.R @@ -508,6 +508,133 @@ test_that("renv_graph_url_repository honors a source request for untagged record }) +test_that("renv_graph_url_repository prefers a P3M binary over the source archive", { + + renv_tests_scope() + renv_scope_options(pkgType = "both") + + # the repositories only carry a newer version than the one requested + repourl <- contrib.url(getOption("repos")[[1L]], type = "source") + local_mocked_bindings( + renv_available_packages_latest = function(package, ...) { + record <- list(Package = package, Version = "9.9.9", Source = "Repository") + renv_record_tag(record, type = "source", url = repourl, name = "CRAN") + }, + renv_available_packages_entry = function(...) stop("version not available"), + renv_p3m_enabled = function(type = NULL) TRUE, + renv_retrieve_repos_p3m_date = function(record) as.Date("2024-01-15") + ) + + desc <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + info <- renv_graph_url_repository(desc) + + expect_equal(info$pkgtype, "binary") + expect_equal(info$reponame, "P3M") + expect_match(info$url, "2024-01-15", fixed = TRUE) + expect_true(endsWith(info$url, renv_package_ext("binary"))) + + # without a P3M binary, the source archive is used + local_mocked_bindings(renv_retrieve_repos_p3m_date = function(record) NULL) + info <- renv_graph_url_repository(desc) + + expect_equal(info$pkgtype, "source") + expect_match(info$url, "Archive", fixed = TRUE) + + # source-only requests don't consult P3M + local_mocked_bindings( + renv_p3m_enabled = function(type = NULL) FALSE, + renv_retrieve_repos_p3m_date = function(record) stop("should not be called") + ) + info <- renv_graph_url_repository(desc) + expect_equal(info$pkgtype, "source") + +}) + +test_that("P3M database failures fall through to the source archive", { + + renv_tests_scope() + renv_scope_options(pkgType = "both") + + repourl <- contrib.url(getOption("repos")[[1L]], type = "source") + local_mocked_bindings( + renv_available_packages_latest = function(package, ...) { + record <- list(Package = package, Version = "9.9.9", Source = "Repository") + renv_record_tag(record, type = "source", url = repourl, name = "CRAN") + }, + renv_available_packages_entry = function(...) stop("version not available"), + renv_p3m_enabled = function(type = NULL) TRUE, + renv_retrieve_repos_p3m_date = function(record) stop("could not download the P3M database") + ) + + desc <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + info <- renv_graph_url_repository(desc) + + expect_equal(info$pkgtype, "source") + expect_match(info$url, "Archive", fixed = TRUE) + +}) + +test_that("the type requested by install() governs whether P3M is consulted", { + + renv_tests_scope() + renv_scope_options(pkgType = "both", renv.config.ppm.enabled = TRUE) + + repourl <- contrib.url(getOption("repos")[[1L]], type = "source") + local_mocked_bindings( + renv_available_packages_latest = function(package, ...) { + record <- list(Package = package, Version = "9.9.9", Source = "Repository") + renv_record_tag(record, type = "source", url = repourl, name = "CRAN") + }, + renv_available_packages_entry = function(...) stop("version not available"), + renv_retrieve_repos_p3m_date = function(record) as.Date("2024-01-15") + ) + + desc <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + info <- renv_graph_url_repository(desc) + expect_equal(info$reponame, "P3M") + + renv_scope_binding(the, "install_pkg_type", "source") + info <- renv_graph_url_repository(desc) + expect_equal(info$pkgtype, "source") + expect_match(info$url, "Archive", fixed = TRUE) + +}) + +test_that("strict restores retrieve from the recorded repository, not P3M", { + + renv_tests_scope() + renv_scope_options(pkgType = "both") + + repourl <- contrib.url(getOption("repos")[[1L]], type = "source") + local_mocked_bindings( + renv_available_packages_latest = function(package, ...) { + record <- list(Package = package, Version = "9.9.9", Source = "Repository") + renv_record_tag(record, type = "source", url = repourl, name = "CRAN") + }, + renv_available_packages_entry = function(...) stop("version not available"), + renv_p3m_enabled = function(type = NULL) TRUE, + renv_retrieve_repos_p3m_date = function(record) as.Date("2024-01-15") + ) + + desc <- list( + Package = "bread", + Version = "1.0.0", + Source = "Repository", + Repository = getOption("repos")[[1L]] + ) + + renv_scope_restore(project = getwd(), strict = TRUE) + info <- renv_graph_url_repository(desc) + expect_equal(info$pkgtype, "source") + expect_match(info$url, "Archive", fixed = TRUE) + + # records naming a repository by name rather than URL aren't pinned to it + desc$Repository <- "CRAN" + info <- renv_graph_url_repository(desc) + expect_equal(info$reponame, "P3M") + +}) + # adjacency graph ---- test_that("renv_graph_adjacency computes correct structure for chain", { diff --git a/tests/testthat/test-install-preflight.R b/tests/testthat/test-install-preflight.R new file mode 100644 index 000000000..0904661e8 --- /dev/null +++ b/tests/testthat/test-install-preflight.R @@ -0,0 +1,236 @@ + +test_that("the way each record will be installed is classified", { + + renv_tests_scope() + + # earlier tests may have installed the test packages into the cache, + # in which case no build would be needed + renv_scope_envvars(RENV_PATHS_CACHE = renv_scope_tempfile("renv-cache-")) + + # the test scope requests source packages, so the resolver chooses a + # source build; the test packages have no compiled code (write_PACKAGES() + # records NeedsCompilation: no for them) + bread <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + expect_equal(renv_install_preflight_binaries_type(bread), "source") + + # packages not in the repositories at all can't be classified + missing <- list(Package = "nosuchpackage", Version = "1.0.0", Source = "Repository") + expect_equal(renv_install_preflight_binaries_type(missing), "unknown") + + # Bioconductor repositories aren't available yet during preflight + bioc <- list(Package = "Biobase", Version = "2.60.0", Source = "Bioconductor") + expect_equal(renv_install_preflight_binaries_type(bioc), "none") + + # packages whose current release has compiled code need compiling + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_graph_url_repository", + replacement = function(desc) list(pkgtype = "source") + ) + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_available_packages_entry", + replacement = function(package, type = "source", ...) { + if (identical(type, "binary")) + stop("no binaries available") + list(Package = package, NeedsCompilation = "yes") + } + ) + expect_equal(renv_install_preflight_binaries_type(bread), "compiled") + + # a description resolved by the dependency graph already says whether the + # package needs compiling, so the repositories aren't consulted again + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_available_packages_entry", + replacement = function(...) stop("should not be called") + ) + bread$NeedsCompilation <- "no" + expect_equal(renv_install_preflight_binaries_type(bread), "source") + +}) + +test_that("the resolver decides whether a binary will be used", { + + renv_tests_scope() + renv_scope_envvars(RENV_PATHS_CACHE = renv_scope_tempfile("renv-cache-")) + + # the current release needs compiling + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_available_packages_entry", + replacement = function(package, type = "source", ...) { + list(Package = package, NeedsCompilation = "yes") + } + ) + + bread <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + + # whether from a repository, P3M, or (in strict mode) the recorded + # repository alone, a binary chosen by the resolver needs no build + resolved <- list(pkgtype = "binary") + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_graph_url_repository", + replacement = function(desc) resolved + ) + expect_equal(renv_install_preflight_binaries_type(bread), "none") + + # a source build chosen by the resolver is classified by its need to compile + resolved <- list(pkgtype = "source") + expect_equal(renv_install_preflight_binaries_type(bread), "compiled") + + # a record the resolver can't place is retrieved sequentially; assume a build + resolved <- NULL + expect_equal(renv_install_preflight_binaries_type(bread), "compiled") + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_graph_url_repository", + replacement = function(desc) stop("resolution failed") + ) + expect_equal(renv_install_preflight_binaries_type(bread), "compiled") + +}) + +test_that("packages to be compiled from source are reported", { + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_install_preflight_binaries_type", + replacement = function(record, project = NULL) { + switch( + record$Package, + ragg = "compiled", + stringi = "compiled", + cli = "none", + rlang = "source", + mypkg = "unknown", + broken = "compiled" + ) + } + ) + + records <- list( + stringi = list(Package = "stringi", Version = "1.8.4", Source = "Repository"), + cli = list(Package = "cli", Version = "3.6.3", Source = "Repository"), + rlang = list(Package = "rlang", Version = "1.1.4", Source = "Repository"), + mypkg = list(Package = "mypkg", Version = "0.1.0", Source = "GitHub"), + ragg = list(Package = "ragg", Version = "1.3.0", Source = "Repository"), + broken = structure(list(Package = "broken"), resolution_failed = TRUE) + ) + + # with a toolchain, this is informational + expect_snapshot(status <- renv_install_preflight_binaries_impl(records, rtools = TRUE)) + expect_true(status) + + # without one, the install is expected to fail + expect_snapshot({ + status <- renv_install_preflight_binaries_impl( + records, + rtools = FALSE, + version = "4.5.1" + ) + }) + expect_false(status) + + # nothing is reported when nothing needs compiling + expect_silent(status <- renv_install_preflight_binaries_impl(records["cli"])) + expect_true(status) + + # packages which might need compiling are only a concern without a toolchain + expect_silent(status <- renv_install_preflight_binaries_impl(records["mypkg"])) + expect_true(status) + + expect_snapshot({ + status <- renv_install_preflight_binaries_impl( + records["mypkg"], + rtools = FALSE, + version = "4.5.1" + ) + }) + expect_false(status) + +}) + +# pretend a built copy of every package exists in the cache +renv_test_preflight_scope_cache <- function(scope = parent.frame()) { + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_cache_find", + replacement = function(record) file.path(tempdir(), record$Package), + scope = scope + ) + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_cache_package_validate", + replacement = function(path) TRUE, + scope = scope + ) + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_graph_url_repository", + replacement = function(desc) list(pkgtype = "source"), + scope = scope + ) + +} + +test_that("a usable cache entry means no build is needed", { + + renv_tests_scope() + renv_test_preflight_scope_cache() + + bread <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + expect_equal(renv_install_preflight_binaries_type(bread, getwd()), "none") + +}) + +test_that("a requested rebuild skips the cache during preflight", { + + renv_tests_scope() + renv_test_preflight_scope_cache() + + # the installer consults the restore state for the rebuild request + renv_scope_restore(project = getwd(), rebuild = "bread") + + bread <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + expect_equal(renv_install_preflight_binaries_type(bread, getwd()), "source") + +}) + +test_that("a disabled cache is not consulted during preflight", { + + renv_tests_scope() + renv_test_preflight_scope_cache() + renv_scope_options(renv.config.cache.enabled = FALSE) + + bread <- list(Package = "bread", Version = "1.0.0", Source = "Repository") + expect_equal(renv_install_preflight_binaries_type(bread, getwd()), "source") + +}) + +test_that("install() checks the resolved set of packages for source builds", { + + skip_on_cran() + renv_tests_scope("breakfast") + + checked <- NULL + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_install_preflight_binaries", + replacement = function(records, project) { + checked <<- names(records) + TRUE + } + ) + + # the dependencies of breakfast need installing too + install("breakfast") + expect_setequal(checked, c("breakfast", "bread", "oatmeal", "toast")) + +}) diff --git a/tests/testthat/test-platform.R b/tests/testthat/test-platform.R index ce24465b7..3d2c99c7f 100644 --- a/tests/testthat/test-platform.R +++ b/tests/testthat/test-platform.R @@ -26,3 +26,106 @@ UBUNTU_CODENAME=bionic expect_equal(prefix, "linux-ubuntu-bionic") }) + +test_that("the platform record describes the running system", { + + project <- renv_tests_scope() + record <- renv_platform_record(project = project) + + expect_equal(record$R$Version, format(getRversion())) + expect_equal(record$R$Platform, R.version$platform) + expect_equal(record$OS$Name, Sys.info()[["sysname"]]) + expect_type(record$Locale$Collate, "character") + + if (renv_platform_linux()) + expect_true(is.null(record$OS$Distro) || is.character(record$OS$Distro)) + else + expect_null(record$OS$Distro) + + if (!renv_platform_windows()) + expect_null(record$Rtools) + + # the record can be written as JSON, and read back + path <- renv_scope_tempfile("renv-platform-", fileext = ".json") + renv_json_write(record, file = path) + expect_equal(names(renv_json_read(path)), names(record)) + +}) + +test_that("system package versions can be queried", { + + expect_equal(renv_sysreqs_versions("deb", character()), list()) + + skip_if_not(nzchar(Sys.which("dpkg-query"))) + versions <- renv_sysreqs_versions("deb", c("dpkg", "no-such-package-renv")) + expect_equal(names(versions), "dpkg") + expect_true(nzchar(versions$dpkg)) + +}) + +test_that("only installed system packages are reported", { + + # dpkg reports on packages which were removed but not purged + output <- c( + "libcurl4-openssl-dev 7.81.0-1ubuntu1 config-files", + "libssl-dev 3.0.2-0ubuntu1 installed", + "zlib1g-dev 1:1.2.11.dfsg-2ubuntu9 installed", + "dpkg-query: no packages found matching no-such-package" + ) + + packages <- c("libcurl4-openssl-dev", "libssl-dev", "no-such-package") + versions <- renv_sysreqs_versions_parse(output, packages) + expect_equal(versions, list("libssl-dev" = "3.0.2-0ubuntu1")) + +}) + +test_that("the external software record tolerates a missing extSoftVersion()", { + + expect_null(renv_platform_record_blas(NULL)) + expect_null(renv_platform_record_software(NULL)) + + software <- c(BLAS = "libblas.so", zlib = "1.2.11", bzlib = "") + expect_equal(renv_platform_record_blas(software), "libblas.so") + expect_equal(renv_platform_record_software(software), list(zlib = "1.2.11")) + +}) + +test_that("tool versions are read from the first meaningful line of output", { + + expect_equal(renv_platform_record_tool_version("pandoc 3.1.11"), "pandoc 3.1.11") + expect_null(renv_platform_record_tool_version(character())) + + # a tool which fails to run reports an error, not a version + output <- structure("Unable to locate a Java Runtime.", status = 1L) + expect_null(renv_platform_record_tool_version(output)) + + # the JVM announces options from the environment before its version + output <- c( + "Picked up JAVA_TOOL_OPTIONS: -Xmx2g", + "openjdk version \"17.0.2\" 2022-01-18", + "OpenJDK Runtime Environment (build 17.0.2+8)" + ) + + expect_equal( + renv_platform_record_tool_version(output), + "openjdk version \"17.0.2\" 2022-01-18" + ) + +}) + +test_that("external tools are reported when available", { + + expect_null(renv_platform_record_tool("", "--version")) + expect_null(renv_platform_record_tool(file.path(tempdir(), "nosuchtool"), "--version")) + + pandoc <- renv_platform_record_tool_pandoc() + skip_if_not(nzchar(pandoc)) + + tool <- renv_platform_record_tool(pandoc, "--version") + expect_match(tool$Version, "pandoc") + expect_true(file.exists(tool$Path)) + + tools <- renv_platform_record_tools() + expect_equal(tools$Pandoc, tool) + +}) diff --git a/tests/testthat/test-rtools.R b/tests/testthat/test-rtools.R new file mode 100644 index 000000000..11d3470d2 --- /dev/null +++ b/tests/testthat/test-rtools.R @@ -0,0 +1,138 @@ + +test_that("the required Rtools series is inferred from the R version", { + + expect_equal(renv_rtools_required("3.6.3"), "3.5") + expect_equal(renv_rtools_required("4.0.5"), "4.0") + expect_equal(renv_rtools_required("4.1.3"), "4.0") + expect_equal(renv_rtools_required("4.2.1"), "4.2") + expect_equal(renv_rtools_required("4.5.1"), "4.5") + + # the newest series serves newer releases of R until the next appears + expect_equal(renv_rtools_required("4.6.0"), "4.5") + series <- c(renv_rtools_series(), "4.6") + expect_equal(renv_rtools_required("4.6.0", series), "4.6") + expect_equal(renv_rtools_required("4.5.1", series), "4.5") + + # a series newer than R is never appropriate, so a list missing the older + # series falls back to the known ones + expect_equal(renv_rtools_required("4.1.3", c("4.2", "4.3")), "4.0") + expect_equal(renv_rtools_required("4.3.2", c("4.4", "4.5")), "4.3") + +}) + +test_that("Rtools installations are discovered and matched to R", { + + # pretend the system drive is a temporary directory, so that the default + # installation locations can be populated + drive <- renv_scope_tempfile("renv-drive-") + ensure_directory(file.path(drive, c("rtools45", "rtools46"))) + renv_scope_envvars( + SYSTEMDRIVE = drive, + RTOOLS45_HOME = NULL, + RTOOLS44_HOME = NULL, + RTOOLS43_HOME = NULL, + RTOOLS42_HOME = NULL, + RTOOLS40_HOME = NULL + ) + + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_rtools_registry", + replacement = function() NULL + ) + + # both installations are found, even though only one has a dedicated entry + specs <- renv_rtools_list() + specs <- filter(specs, function(spec) startsWith(spec$root, drive)) + expect_setequal(map_chr(specs, `[[`, "version"), c("4.5", "4.6")) + + # ignore any Rtools installations actually present on this machine + renv_scope_binding( + envir = asNamespace("renv"), + symbol = "renv_rtools_list", + replacement = function() specs + ) + + # the newest compatible series is preferred + expect_equal(renv_rtools_find(rversion = "4.5.1")$version, "4.5") + expect_equal(renv_rtools_find(rversion = "4.6.0")$version, "4.6") + expect_null(renv_rtools_find(rversion = "4.2.1")) + +}) + +test_that("Rtools compatibility follows the series' supported R versions", { + + spec <- function(version) list(version = version) + + expect_true(renv_rtools_compatible(spec("4.2"), rversion = "4.2.1")) + expect_false(renv_rtools_compatible(spec("4.2"), rversion = "4.3.0")) + expect_true(renv_rtools_compatible(spec("4.5"), rversion = "4.6.0")) + expect_false(renv_rtools_compatible(spec("4.6"), rversion = "4.5.1")) + expect_true(renv_rtools_compatible(spec("4.6"), rversion = "4.7.0")) + expect_false(renv_rtools_compatible(spec(NULL), rversion = "4.5.1")) + +}) + +test_that("the available Rtools series can be read from the CRAN index page", { + + html <- c( + 'RTools 4.5', + 'RTools 4.4', + 'RTools 4.2', + 'RTools 4.0', + 'old versions' + ) + + expect_equal(renv_rtools_series_parse(html), c("4.0", "4.2", "4.4", "4.5")) + expect_equal(renv_rtools_series_parse(character()), character()) + +}) + +test_that("the Rtools installer URL is read from the series page", { + + expect_equal( + renv_rtools_page_url("4.0"), + "https://cran.r-project.org/bin/windows/Rtools/rtools40.html" + ) + + expect_equal( + renv_rtools_page_url("4.5"), + "https://cran.r-project.org/bin/windows/Rtools/rtools45/rtools.html" + ) + + html <- c( + '

Rtools45 installer

', + '

Rtools45 aarch64 installer

' + ) + + expect_equal( + renv_rtools_installer_url_parse(html, "4.5", arch = "x86_64"), + "files/rtools45-6768-6492.exe" + ) + + expect_equal( + renv_rtools_installer_url_parse(html, "4.5", arch = "aarch64"), + "files/rtools45-aarch64-6768-6492.exe" + ) + + expect_null(renv_rtools_installer_url_parse(html, "4.5", arch = "i386")) + + # installers for other series are ignored + expect_null(renv_rtools_installer_url_parse(html, "4.4", arch = "x86_64")) + + html <- c( + 'rtools40-x86_64.exe', + 'rtools40-i686.exe' + ) + + expect_equal( + renv_rtools_installer_url_parse(html, "4.0", arch = "x86_64"), + "rtools40-x86_64.exe" + ) + + expect_equal( + renv_rtools_installer_url_parse(html, "4.0", arch = "i386"), + "rtools40-i686.exe" + ) + +})