From fb3f2f172fba0c1c1b3cb6a46cb7883e466e1096 Mon Sep 17 00:00:00 2001 From: choxos Date: Thu, 11 Jun 2026 19:48:31 -0400 Subject: [PATCH 1/3] Cross-distro bundled database, 33 log patterns, apk detection, PPM fallback fix The bundled fallback database now stores one row per (r_package, package_manager, system_package) with names for apt, dnf (reused by yum), zypper, and apk. The apt, dnf, and zypper names are generated from the Posit Package Manager database for Ubuntu 22.04, Red Hat 9, and openSUSE 15.6; the Alpine names are hand curated. backend = "bundled" and auto routing now work on Fedora, RHEL rebuilds, openSUSE, and Alpine. Log diagnosis patterns grew from 12 to 33, adding zlib, bzip2, xz, png, jpeg, tiff, freetype, fontconfig, cairo, SQLite, PostgreSQL, MariaDB, libsodium, GMP, MPFR, GLPK, GEOS, ImageMagick, poppler, leptonica, tesseract, ICU, webp, and Cyrus SASL, each with per-manager names. Fixes: Posit Package Manager query failures no longer stop with the misleading apt-only bundled error on non-apt platforms; installed-state detection now covers Alpine via apk info; the startup message suggests setup_advice() for the detected platform. --- NEWS.md | 25 + R/bundled-sysreqs.R | 843 +++++++++++++++++++++++++++--- R/check.R | 13 +- R/diagnose-log.R | 187 ++++++- R/plan.R | 2 + R/ppm.R | 16 +- R/zzz.R | 27 +- README.md | 8 +- data-raw/update-bundled-sysreqs.R | 179 ++++++- man/check_packages.Rd | 5 +- man/ppm_sysreqs.Rd | 3 +- tests/testthat/test-check.R | 139 +++-- tests/testthat/test-diagnose.R | 57 ++ tests/testthat/test-plan.R | 19 + tests/testthat/test-ppm.R | 39 ++ vignettes/faq.Rmd | 10 +- vignettes/preflight-setup.Rmd | 6 +- 17 files changed, 1399 insertions(+), 179 deletions(-) diff --git a/NEWS.md b/NEWS.md index 34e6872..b909aba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,12 +2,31 @@ ## New features +* The bundled fallback database is now cross-distro: it stores system + package names for `apt`, `dnf` (also used by `yum` platforms), `zypper`, + and `apk`, so `backend = "bundled"` and offline fallbacks work on Fedora, + RHEL and its rebuilds, openSUSE, and Alpine, not only on Debian and + Ubuntu. The `apt`, `dnf`, and `zypper` names are generated from the Posit + Package Manager database; the Alpine names are hand curated. The `"auto"` + backend now prefers bundled data on all of these platforms. +* Log diagnosis recognizes many more missing libraries. The direct error + patterns grew from 12 to 33 and now cover, among others, zlib, bzip2, xz, + png, jpeg, tiff, freetype, fontconfig, cairo, SQLite, PostgreSQL, + MariaDB, libsodium, GMP, MPFR, GLPK, GEOS, ImageMagick, poppler, + leptonica, tesseract, ICU, webp, and Cyrus SASL, each with names for all + supported package managers. * New `gitlab_ci()` generates a GitLab CI YAML job that installs the system packages a plan needs. GitLab CI jobs usually run as root inside a container image, so the commands are emitted without `sudo`. * The bundled fallback database now also covers `igraph`, `rJava`, `jqr`, `odbc`, `av`, `rsvg`, `xslt`, and `protolite` (40 curated packages in total). +* Installed-state detection now works on Alpine: `missing_only` filtering + and the `installed` plan column use `apk info` when running on an `apk` + platform. +* The startup message suggests `setup_advice()` with the detected platform + instead of a hardcoded `ubuntu-24.04` example when the current host is a + supported Linux distribution. ## Documentation @@ -43,6 +62,12 @@ instead of pasting them into the command line. * Posit Package Manager requirements that consist only of post-install commands (for example `R CMD javareconf`) now keep their row in the plan. +* When a Posit Package Manager query fails (for example with no network), + the fallback no longer stops with "Bundled fallback data currently + supports apt platforms only." on non-apt platforms. The bundled fallback + now serves platform-matching names, and on platforms outside the bundled + data (such as Homebrew) an empty plan is returned with the original + error recorded in the `fallback_error` attribute. * `detect_platform()` now reports Alpine hosts as supported, matching `resolve_platform("alpine-3.20")` and the documented platform list. * `use_ppm()` documentation no longer claims that `scope` selects which diff --git a/R/bundled-sysreqs.R b/R/bundled-sysreqs.R index 63fc1dd..095f2bf 100644 --- a/R/bundled-sysreqs.R +++ b/R/bundled-sysreqs.R @@ -2,104 +2,821 @@ # BEGIN BUNDLED SYSREQS DATA bundled_sysreqs_db <- data.frame( r_package = c( - "curl", "curl", - "xml2", - "openssl", - "ragg", "ragg", "ragg", "ragg", "ragg", - "systemfonts", "systemfonts", - "textshaping", "textshaping", "textshaping", - "sf", "sf", "sf", "sf", "sf", - "terra", "terra", "terra", "terra", "terra", - "units", - "git2r", "git2r", "git2r", + "av", + "curl", + "curl", + "fftwtools", "gert", - "magick", "magick", - "pdftools", "pdftools", - "tesseract", "tesseract", "tesseract", - "RPostgres", + "git2r", + "git2r", + "git2r", + "gmp", + "gsl", + "hdf5r", + "igraph", + "igraph", + "jpeg", + "jqr", + "magick", + "magick", + "ncdf4", + "odbc", + "openssl", + "pdftools", + "pdftools", + "png", + "protolite", + "protolite", + "protolite", + "ragg", + "ragg", + "ragg", + "ragg", + "ragg", + "RcppGSL", + "rJava", "RMariaDB", + "Rmpfr", + "Rmpfr", "RMySQL", "RODBC", - "V8", + "RPostgres", + "rsvg", + "sf", + "sf", + "sf", + "sf", + "sf", "sodium", "stringi", - "webp", - "png", - "jpeg", + "systemfonts", + "systemfonts", + "terra", + "terra", + "terra", + "terra", + "terra", + "tesseract", + "tesseract", + "tesseract", + "textshaping", + "textshaping", + "textshaping", + "tiff", "tiff", + "units", + "V8", + "webp", + "xml2", + "xslt", + "av", + "curl", + "curl", + "fftwtools", + "gert", + "git2r", + "git2r", + "git2r", + "gmp", "gsl", + "hdf5r", + "igraph", + "igraph", + "jpeg", + "jqr", + "magick", + "magick", + "ncdf4", + "odbc", + "openssl", + "pdftools", + "pdftools", + "png", + "protolite", + "protolite", + "ragg", + "ragg", + "ragg", + "ragg", + "ragg", "RcppGSL", - "gmp", - "Rmpfr", "Rmpfr", + "rJava", + "RMariaDB", + "Rmpfr", + "Rmpfr", + "RMySQL", + "RODBC", + "RPostgres", + "rsvg", + "sf", + "sf", + "sf", + "sf", + "sf", + "sodium", + "stringi", + "systemfonts", + "systemfonts", + "terra", + "terra", + "terra", + "terra", + "terra", + "tesseract", + "tesseract", + "textshaping", + "textshaping", + "textshaping", + "tiff", + "tiff", + "units", + "V8", + "webp", + "xml2", + "xslt", + "av", + "curl", + "curl", "fftwtools", + "gert", + "git2r", + "git2r", + "git2r", + "gmp", + "gsl", "hdf5r", + "igraph", + "jpeg", + "jqr", + "magick", + "magick", "ncdf4", - "igraph", "igraph", + "odbc", + "openssl", + "pdftools", + "pdftools", + "png", + "protolite", + "ragg", + "ragg", + "ragg", + "ragg", + "ragg", + "RcppGSL", "rJava", + "RMariaDB", + "Rmpfr", + "Rmpfr", + "RMySQL", + "RODBC", + "RPostgres", + "rsvg", + "sf", + "sf", + "sf", + "sf", + "sf", + "sf", + "sodium", + "stringi", + "systemfonts", + "systemfonts", + "terra", + "terra", + "terra", + "terra", + "terra", + "terra", + "tesseract", + "tesseract", + "textshaping", + "textshaping", + "textshaping", + "tiff", + "tiff", + "webp", + "xml2", + "xslt", + "av", + "curl", + "curl", + "fftwtools", + "gert", + "git2r", + "git2r", + "git2r", + "gmp", + "gsl", + "hdf5r", + "igraph", + "igraph", + "jpeg", "jqr", + "magick", + "ncdf4", "odbc", - "av", + "openssl", + "pdftools", + "pdftools", + "png", + "protolite", + "ragg", + "ragg", + "ragg", + "ragg", + "ragg", + "RcppGSL", + "rJava", + "RMariaDB", + "Rmpfr", + "Rmpfr", + "RMySQL", + "RODBC", + "RPostgres", "rsvg", - "xslt", - "protolite", "protolite", "protolite" + "sf", + "sf", + "sf", + "sf", + "sf", + "sodium", + "stringi", + "systemfonts", + "systemfonts", + "terra", + "terra", + "terra", + "terra", + "terra", + "tesseract", + "tesseract", + "textshaping", + "textshaping", + "textshaping", + "tiff", + "tiff", + "units", + "webp", + "xml2", + "xslt" + ), + package_manager = c( + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "apt", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "dnf", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "zypper", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk", + "apk" ), system_package = c( - "libcurl4-openssl-dev", "libssl-dev", - "libxml2-dev", + "libavfilter-dev", + "libcurl4-openssl-dev", "libssl-dev", - "libfreetype-dev", "libjpeg-dev", "libpng-dev", "libtiff-dev", "libwebp-dev", - "libfontconfig1-dev", "libfreetype-dev", - "libfreetype-dev", "libfribidi-dev", "libharfbuzz-dev", - "libgdal-dev", "gdal-bin", "libgeos-dev", "libproj-dev", "libsqlite3-dev", - "libgdal-dev", "gdal-bin", "libgeos-dev", "libproj-dev", "libsqlite3-dev", - "libudunits2-dev", - "libgit2-dev", "libssh2-1-dev", "libssl-dev", + "libfftw3-dev", "libgit2-dev", - "libmagick++-dev", "gsfonts", - "poppler-data", "libpoppler-cpp-dev", - "libleptonica-dev", "libtesseract-dev", "tesseract-ocr-eng", - "libpq-dev", - "default-libmysqlclient-dev", - "default-libmysqlclient-dev", + "libgit2-dev", + "libssh2-1-dev", + "libssl-dev", + "libgmp3-dev", + "libgsl-dev", + "libhdf5-dev", + "libglpk-dev", + "libxml2-dev", + "libjpeg-dev", + "libjq-dev", + "gsfonts", + "libmagick++-dev", + "libnetcdf-dev", "unixodbc-dev", - "libnode-dev", - "libsodium-dev", - "libicu-dev", - "libwebp-dev", + "libssl-dev", + "libpoppler-cpp-dev", + "poppler-data", "libpng-dev", + "libprotobuf-dev", + "libprotoc-dev", + "protobuf-compiler", + "libfreetype-dev", "libjpeg-dev", + "libpng-dev", "libtiff-dev", + "libwebp-dev", "libgsl-dev", - "libgsl-dev", - "libgmp3-dev", - "libgmp3-dev", "libmpfr-dev", - "libfftw3-dev", - "libhdf5-dev", - "libnetcdf-dev", - "libglpk-dev", "libxml2-dev", "default-jdk", - "libjq-dev", + "default-libmysqlclient-dev", + "libgmp3-dev", + "libmpfr-dev", + "default-libmysqlclient-dev", "unixodbc-dev", - "libavfilter-dev", + "libpq-dev", "librsvg2-dev", + "gdal-bin", + "libgdal-dev", + "libgeos-dev", + "libproj-dev", + "libsqlite3-dev", + "libsodium-dev", + "libicu-dev", + "libfontconfig1-dev", + "libfreetype-dev", + "gdal-bin", + "libgdal-dev", + "libgeos-dev", + "libproj-dev", + "libsqlite3-dev", + "libleptonica-dev", + "libtesseract-dev", + "tesseract-ocr-eng", + "libfreetype-dev", + "libfribidi-dev", + "libharfbuzz-dev", + "libjpeg-dev", + "libtiff-dev", + "libudunits2-dev", + "libnode-dev", + "libwebp-dev", + "libxml2-dev", "libxslt1-dev", - "libprotobuf-dev", "protobuf-compiler", "libprotoc-dev" + "libavfilter-free-devel", + "libcurl-devel", + "openssl-devel", + "fftw-devel", + "libgit2-devel", + "libgit2-devel", + "libssh2-devel", + "openssl-devel", + "gmp-devel", + "gsl-devel", + "hdf5-devel", + "glpk-devel", + "libxml2-devel", + "libjpeg-turbo-devel", + "jq-devel", + "ImageMagick-c++-devel", + "ImageMagick-devel", + "netcdf-devel", + "unixODBC-devel", + "openssl-devel", + "poppler-cpp-devel", + "poppler-data", + "libpng-devel", + "protobuf-compiler", + "protobuf-devel", + "freetype-devel", + "libjpeg-turbo-devel", + "libpng-devel", + "libtiff-devel", + "libwebp-devel", + "gsl-devel", + "java-11-openjdk-devel", + "mariadb-devel", + "gmp-devel", + "mpfr-devel", + "mariadb-devel", + "unixODBC-devel", + "libpq-devel", + "librsvg2-devel", + "gdal", + "gdal-devel", + "geos-devel", + "proj-devel", + "sqlite-devel", + "libsodium-devel", + "libicu-devel", + "fontconfig-devel", + "freetype-devel", + "gdal", + "gdal-devel", + "geos-devel", + "proj-devel", + "sqlite-devel", + "leptonica-devel", + "tesseract-devel", + "freetype-devel", + "fribidi-devel", + "harfbuzz-devel", + "libjpeg-turbo-devel", + "libtiff-devel", + "udunits2-devel", + "nodejs-libs", + "libwebp-devel", + "libxml2-devel", + "libxslt-devel", + "ffmpeg-4-libavfilter-devel", + "libcurl-devel", + "libopenssl-devel", + "fftw3-devel", + "libgit2-devel", + "libgit2-devel", + "libopenssl-devel", + "libssh2-devel", + "gmp-devel", + "gsl-devel", + "hdf5-devel", + "libxml2-devel", + "libjpeg8-devel", + "libjq-devel", + "ImageMagick-devel", + "libMagick++-devel", + "netcdf-devel", + "unixODBC-devel", + "libopenssl-devel", + "libpoppler-devel", + "poppler-data", + "libpng16-compat-devel", + "protobuf-devel", + "freetype2-devel", + "libjpeg8-devel", + "libpng16-compat-devel", + "libtiff-devel", + "libwebp-devel", + "gsl-devel", + "java-11-openjdk-devel", + "libmariadb-devel", + "gmp-devel", + "mpfr-devel", + "libmariadb-devel", + "unixODBC-devel", + "postgresql-devel", + "librsvg-devel", + "gdal", + "gdal-devel", + "geos-devel", + "proj", + "proj-devel", + "sqlite3-devel", + "libsodium-devel", + "libicu73_2-devel", + "fontconfig-devel", + "freetype2-devel", + "gdal", + "gdal-devel", + "geos-devel", + "proj", + "proj-devel", + "sqlite3-devel", + "leptonica-devel", + "tesseract-ocr-devel", + "freetype2-devel", + "fribidi-devel", + "harfbuzz-devel", + "libjpeg8-devel", + "libtiff-devel", + "libwebp-devel", + "libxml2-devel", + "libxslt-devel", + "ffmpeg-dev", + "curl-dev", + "openssl-dev", + "fftw-dev", + "libgit2-dev", + "libgit2-dev", + "libssh2-dev", + "openssl-dev", + "gmp-dev", + "gsl-dev", + "hdf5-dev", + "glpk-dev", + "libxml2-dev", + "libjpeg-turbo-dev", + "jq-dev", + "imagemagick-dev", + "netcdf-dev", + "unixodbc-dev", + "openssl-dev", + "poppler-data", + "poppler-dev", + "libpng-dev", + "protobuf-dev", + "freetype-dev", + "libjpeg-turbo-dev", + "libpng-dev", + "libwebp-dev", + "tiff-dev", + "gsl-dev", + "openjdk21-jdk", + "mariadb-connector-c-dev", + "gmp-dev", + "mpfr-dev", + "mariadb-connector-c-dev", + "unixodbc-dev", + "libpq-dev", + "librsvg-dev", + "gdal-dev", + "gdal-tools", + "geos-dev", + "proj-dev", + "sqlite-dev", + "libsodium-dev", + "icu-dev", + "fontconfig-dev", + "freetype-dev", + "gdal-dev", + "gdal-tools", + "geos-dev", + "proj-dev", + "sqlite-dev", + "leptonica-dev", + "tesseract-ocr-dev", + "freetype-dev", + "fribidi-dev", + "harfbuzz-dev", + "libjpeg-turbo-dev", + "tiff-dev", + "udunits-dev", + "libwebp-dev", + "libxml2-dev", + "libxslt-dev" ), stringsAsFactors = FALSE ) # END BUNDLED SYSREQS DATA +bundled_supported_managers <- c("apt", "dnf", "yum", "zypper", "apk") + +bundled_name_set <- function(package_manager) { + if (identical(package_manager, "yum")) "dnf" else package_manager +} + bundled_sysreqs <- function(packages, platform, repo = "cran", error = NULL) { - if (!identical(platform$package_manager, "apt")) { + pm <- platform$package_manager + if (!is.character(pm) || length(pm) != 1L || is.na(pm) || + !pm %in% bundled_supported_managers) { if (!is.null(error)) { stop(error) } - stop("Bundled fallback data currently supports apt platforms only.", call. = FALSE) + stop( + "Bundled fallback data currently supports apt, dnf, yum, zypper, ", + "and apk platforms only.", + call. = FALSE + ) } + name_set <- bundled_name_set(pm) packages <- compact_chr(packages) - data <- bundled_sysreqs_db[bundled_sysreqs_db$r_package %in% packages, , drop = FALSE] - unresolved <- setdiff(packages, bundled_sysreqs_db$r_package) + known <- unique(bundled_sysreqs_db$r_package) + data <- bundled_sysreqs_db[ + bundled_sysreqs_db$r_package %in% packages & + bundled_sysreqs_db$package_manager == name_set, , + drop = FALSE + ] + # Packages absent from the table entirely, plus packages the table knows + # for other package managers but has no name for on this one (for example + # V8 on zypper or apk). + unresolved <- c( + setdiff(packages, known), + setdiff(intersect(packages, known), unique(data$r_package)) + ) if (!nrow(data)) { if (!is.null(error)) { @@ -120,14 +837,22 @@ bundled_sysreqs <- function(packages, platform, repo = "cran", error = NULL) { data$pre_install <- NA_character_ data$post_install <- NA_character_ data$platform <- paste0(platform$distro, "-", platform$version) - data$package_manager <- platform$package_manager + data$package_manager <- pm data$installed <- NA data$source <- "bundled" data$confidence <- "medium" data$notes <- paste0( "Requirement from bundled sysreqR fallback data for repository `", repo, - "`." + "`.", + if (!identical(name_set, "apt")) { + paste0( + " Name from the bundled ", name_set, + " set; verify the exact name on your distribution." + ) + } else { + "" + } ) plan <- new_sysreqr_plan( diff --git a/R/check.R b/R/check.R index a36ae9b..9b00ebb 100644 --- a/R/check.R +++ b/R/check.R @@ -2,8 +2,9 @@ #' #' Resolves the system packages an R package needs on a given Linux platform #' and returns them in a structured plan. The `"auto"` backend prefers the -#' offline bundled database on `apt` platforms, then Posit Package Manager, -#' then `pak`. +#' offline bundled database (which carries names for the `apt`, `dnf`/`yum`, +#' `zypper`, and `apk` package managers), then Posit Package Manager, then +#' `pak`. #' #' @param packages Package names or package references. #' @param platform Platform specification accepted by [resolve_platform()]. @@ -85,9 +86,13 @@ select_backend <- function(packages, backend, platform = NULL) { return(backend) } - apt_platform <- !is.null(platform) && identical(platform$package_manager, "apt") + bundled_platform <- !is.null(platform) && + is.character(platform$package_manager) && + length(platform$package_manager) == 1L && + !is.na(platform$package_manager) && + platform$package_manager %in% bundled_supported_managers - if (apt_platform && all(is_simple_package_name(packages)) && bundled_has_packages(packages)) { + if (bundled_platform && all(is_simple_package_name(packages)) && bundled_has_packages(packages)) { "bundled" } else if (all(is_simple_package_name(packages))) { "ppm" diff --git a/R/diagnose-log.R b/R/diagnose-log.R index f8c0a77..36d9108 100644 --- a/R/diagnose-log.R +++ b/R/diagnose-log.R @@ -1,16 +1,44 @@ +# One row per missing library; the pattern matches the typical compiler +# header error and, where applicable, the linker error for the same library. +# Name columns: apt names follow the Debian/Ubuntu portability policy of the +# bundled database, dnf names follow Red Hat 9 (Fedora and the RHEL rebuilds +# use the same names; yum platforms reuse this column), zypper names follow +# openSUSE 15.6, apk names follow Alpine 3.20, brew names follow Homebrew +# formula names. diagnose_patterns <- data.frame( pattern = c( - "libxml/parser\\.h", - "curl/curl\\.h", - "openssl/ssl\\.h", - "\\bgdal\\.h\\b", - "\\bproj\\.h\\b", - "udunits2\\.h", - "harfbuzz/hb\\.h", - "fribidi\\.h", - "cannot find -lxml2", - "cannot find -lcurl", - "cannot find -lssl", + "libxml/parser\\.h|cannot find -lxml2", + "curl/curl\\.h|cannot find -lcurl", + "openssl/ssl\\.h|cannot find -lssl", + "\\bgdal\\.h\\b|cannot find -lgdal", + "\\bproj\\.h\\b|cannot find -lproj", + "udunits2\\.h|cannot find -ludunits2", + "harfbuzz/hb\\.h|cannot find -lharfbuzz", + "fribidi\\.h|cannot find -lfribidi", + "\\bzlib\\.h\\b|cannot find -lz\\b", + "bzlib\\.h|cannot find -lbz2", + "\\blzma\\.h\\b|cannot find -llzma", + "\\bpng\\.h\\b|cannot find -lpng", + "jpeglib\\.h|cannot find -ljpeg", + "\\btiff(io)?\\.h\\b|cannot find -ltiff", + "ft2build\\.h|cannot find -lfreetype", + "fontconfig\\.h|cannot find -lfontconfig", + "\\bcairo\\.h\\b|cannot find -lcairo", + "sqlite3\\.h|cannot find -lsqlite3", + "libpq-fe\\.h|cannot find -lpq\\b", + "\\bmysql\\.h\\b|cannot find -lmysqlclient|cannot find -lmariadb", + "sodium\\.h|cannot find -lsodium", + "\\bgmp\\.h\\b|cannot find -lgmp\\b", + "mpfr\\.h|cannot find -lmpfr", + "glpk\\.h|cannot find -lglpk", + "geos_c\\.h|cannot find -lgeos", + "Magick\\+\\+\\.h", + "poppler-document\\.h|cannot find -lpoppler", + "allheaders\\.h|cannot find -llept\\b", + "tesseract/baseapi\\.h|cannot find -ltesseract", + "unicode/u[a-z]+\\.h|cannot find -licu", + "webp/(de|en)code\\.h|cannot find -lwebp", + "sasl/sasl\\.h|cannot find -lsasl2", "pkg-config.*not found|pkg-config was not found" ), apt = c( @@ -22,9 +50,30 @@ diagnose_patterns <- data.frame( "libudunits2-dev", "libharfbuzz-dev", "libfribidi-dev", - "libxml2-dev", - "libcurl4-openssl-dev", - "libssl-dev", + "zlib1g-dev", + "libbz2-dev", + "liblzma-dev", + "libpng-dev", + "libjpeg-dev", + "libtiff-dev", + "libfreetype-dev", + "libfontconfig1-dev", + "libcairo2-dev", + "libsqlite3-dev", + "libpq-dev", + "default-libmysqlclient-dev", + "libsodium-dev", + "libgmp3-dev", + "libmpfr-dev", + "libglpk-dev", + "libgeos-dev", + "libmagick++-dev", + "libpoppler-cpp-dev", + "libleptonica-dev", + "libtesseract-dev", + "libicu-dev", + "libwebp-dev", + "libsasl2-dev", "pkg-config" ), dnf = c( @@ -36,9 +85,30 @@ diagnose_patterns <- data.frame( "udunits2-devel", "harfbuzz-devel", "fribidi-devel", - "libxml2-devel", - "libcurl-devel", - "openssl-devel", + "zlib-devel", + "bzip2-devel", + "xz-devel", + "libpng-devel", + "libjpeg-turbo-devel", + "libtiff-devel", + "freetype-devel", + "fontconfig-devel", + "cairo-devel", + "sqlite-devel", + "libpq-devel", + "mariadb-devel", + "libsodium-devel", + "gmp-devel", + "mpfr-devel", + "glpk-devel", + "geos-devel", + "ImageMagick-c++-devel", + "poppler-cpp-devel", + "leptonica-devel", + "tesseract-devel", + "libicu-devel", + "libwebp-devel", + "cyrus-sasl-devel", "pkgconf-pkg-config" ), zypper = c( @@ -50,9 +120,30 @@ diagnose_patterns <- data.frame( "udunits2-devel", "harfbuzz-devel", "fribidi-devel", - "libxml2-devel", - "libcurl-devel", - "libopenssl-devel", + "zlib-devel", + "libbz2-devel", + "xz-devel", + "libpng16-compat-devel", + "libjpeg8-devel", + "libtiff-devel", + "freetype2-devel", + "fontconfig-devel", + "cairo-devel", + "sqlite3-devel", + "postgresql-devel", + "libmariadb-devel", + "libsodium-devel", + "gmp-devel", + "mpfr-devel", + "glpk-devel", + "geos-devel", + "libMagick++-devel", + "libpoppler-devel", + "leptonica-devel", + "tesseract-ocr-devel", + "libicu-devel", + "libwebp-devel", + "cyrus-sasl-devel", "pkg-config" ), apk = c( @@ -64,9 +155,30 @@ diagnose_patterns <- data.frame( "udunits-dev", "harfbuzz-dev", "fribidi-dev", - "libxml2-dev", - "curl-dev", - "openssl-dev", + "zlib-dev", + "bzip2-dev", + "xz-dev", + "libpng-dev", + "libjpeg-turbo-dev", + "tiff-dev", + "freetype-dev", + "fontconfig-dev", + "cairo-dev", + "sqlite-dev", + "libpq-dev", + "mariadb-connector-c-dev", + "libsodium-dev", + "gmp-dev", + "mpfr-dev", + "glpk-dev", + "geos-dev", + "imagemagick-dev", + "poppler-dev", + "leptonica-dev", + "tesseract-ocr-dev", + "icu-dev", + "libwebp-dev", + "cyrus-sasl-dev", "pkgconf" ), brew = c( @@ -78,13 +190,34 @@ diagnose_patterns <- data.frame( "udunits", "harfbuzz", "fribidi", - "libxml2", - "curl", - "openssl@3", + "zlib", + "bzip2", + "xz", + "libpng", + "jpeg-turbo", + "libtiff", + "freetype", + "fontconfig", + "cairo", + "sqlite", + "libpq", + "mariadb-connector-c", + "libsodium", + "gmp", + "mpfr", + "glpk", + "geos", + "imagemagick", + "poppler", + "leptonica", + "tesseract", + "icu4c", + "webp", + "cyrus-sasl", "pkg-config" ), confidence = c( - rep("high", 11), + rep("high", 32), "medium" ), stringsAsFactors = FALSE diff --git a/R/plan.R b/R/plan.R index 17e694c..81a1109 100644 --- a/R/plan.R +++ b/R/plan.R @@ -272,6 +272,8 @@ list_installed_system_packages <- function(platform = NULL) { c("-qa", "--qf", shQuote("%{NAME}\n")), stdout = TRUE, stderr = FALSE ) + } else if (identical(pm, "apk") && nzchar(Sys.which("apk"))) { + system2("apk", "info", stdout = TRUE, stderr = FALSE) } else if (identical(pm, "brew") && nzchar(Sys.which("brew"))) { system2("brew", "list", stdout = TRUE, stderr = FALSE) } else { diff --git a/R/ppm.R b/R/ppm.R index b991742..52f3556 100644 --- a/R/ppm.R +++ b/R/ppm.R @@ -130,7 +130,8 @@ ppm_repo <- function(platform = NULL, repo = "cran", snapshot = "latest", #' Queries the Posit Package Manager `/sysreqs` endpoint for the given #' packages and platform, and normalizes the response into a #' `sysreqr_plan`. If the API call fails, the function falls back to the -#' bundled database and records the failure in the +#' bundled database (or to an empty plan when the bundled data has no names +#' for the platform's package manager) and records the failure in the #' `"fallback_error"` attribute of the returned plan. #' #' @param packages Package names. Required when `all = FALSE`. @@ -176,7 +177,18 @@ ppm_sysreqs <- function(packages = NULL, all = FALSE, platform = NULL, if (isTRUE(all)) { stop(res) } - plan <- bundled_sysreqs(packages, platform, repo = repo, error = NULL) + # The bundled fallback covers apt, dnf, yum, zypper, and apk. On other + # platforms (for example brew) return an empty plan that records the + # original Package Manager error instead of failing with a misleading + # message about the bundled data. + plan <- tryCatch( + bundled_sysreqs(packages, platform, repo = repo, error = NULL), + error = function(e2) { + empty <- new_sysreqr_plan(platform_info = platform, backend = "ppm") + attr(empty, "unresolved") <- packages + empty + } + ) if (isTRUE(check_installed)) { plan <- add_installed_state(plan, platform) } diff --git a/R/zzz.R b/R/zzz.R index d79c346..0018144 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -1,12 +1,37 @@ +startup_platform_example <- function() { + fallback <- "ubuntu-24.04" + platform <- tryCatch(detect_platform(), error = function(e) NULL) + if (is.null(platform) || !identical(platform$os, "linux") || + !isTRUE(platform$supported)) { + return(fallback) + } + distro <- platform$distro + version <- as.character(platform$version) + if (!is.character(distro) || is.na(distro) || !nzchar(distro) || + is.na(version) || !nzchar(version)) { + return(fallback) + } + key <- paste0(distro, "-", version) + # Only suggest keys that resolve_platform() accepts, so the startup hint + # never points users at an invalid platform string (for example when an + # os-release ID contains a dash). + resolved <- tryCatch(resolve_platform(key), error = function(e) NULL) + if (is.null(resolved)) { + return(fallback) + } + key +} + .onAttach <- function(libname, pkgname) { if (!interactive()) { return() } ver <- utils::packageVersion(pkgname) + example <- tryCatch(startup_platform_example(), error = function(e) "ubuntu-24.04") msg <- c( sprintf("sysreqr %s", ver), "", - "New here? Try setup_advice(platform = \"ubuntu-24.04\"),", + sprintf("New here? Try setup_advice(platform = \"%s\"),", example), "or help(package = \"sysreqr\") for the function reference.", "", "Guides and articles: https://choxos.github.io/sysreqR/articles/", diff --git a/README.md b/README.md index 5087fdb..a4b2dee 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,10 @@ tested for: AlmaLinux): 8, 9, 10 * **Fedora**: current releases * **CentOS** 7 (legacy) -* **openSUSE Leap** / **SUSE Linux Enterprise**: 15.6 -* **Alpine**: 3.20 +* **openSUSE Leap** / **SUSE Linux Enterprise**: 15.6 (Leap 16.0 is + detected and gets `zypper` commands, but Posit Package Manager does not + publish binaries for it yet) +* **Alpine**: 3.20 and newer macOS and Windows are detected, but most package installation problems on those platforms are handled by CRAN binaries rather than system package @@ -233,7 +235,7 @@ checks. | `pak::pkg_sysreqs()` | Authoritative live resolver | Requires `pak`; no log diagnosis | | `remotes::system_requirements()` | Light; widely available | No log diagnosis, no project scanner | | `renv::sysreqs()` | Project-oriented; integrates with `renv` workflow | Requires `renv` | -| `sysreqr` | Zero runtime deps; log diagnosis; beginner UX | Bundled DB is small; biased toward `apt` | +| `sysreqr` | Zero runtime deps; log diagnosis; beginner UX | Bundled DB covers ~40 curated packages | `sysreqr` can use `pak` as one of its backends (`backend = "pak"`) when it is installed. The tools are complementary, not competitors. diff --git a/data-raw/update-bundled-sysreqs.R b/data-raw/update-bundled-sysreqs.R index 6b02a86..3cdf4d1 100644 --- a/data-raw/update-bundled-sysreqs.R +++ b/data-raw/update-bundled-sysreqs.R @@ -1,15 +1,27 @@ -# Release utility for refreshing the bundled apt system requirement data. +# Release utility for refreshing the bundled system requirement data. # Run from the package root as part of release preparation, e.g. -# Rscript data-raw/update-bundled-sysreqs.R ubuntu 22.04 +# Rscript data-raw/update-bundled-sysreqs.R +# Rscript data-raw/update-bundled-sysreqs.R cran https://packagemanager.posit.co # # The bundled table is a small, hand-curated fallback for common CRAN -# packages. It targets an apt baseline (Ubuntu 22.04 by default), but the -# committed names in R/bundled-sysreqs.R are deliberately chosen to be -# portable across Debian (bookworm/trixie) and Ubuntu (jammy/noble) -- for -# example "default-libmysqlclient-dev" rather than the Ubuntu-only -# "libmysqlclient-dev". Posit Package Manager returns Ubuntu-specific names, -# so the maintainer must review the diff this script produces and re-apply -# any portable substitutions before committing. The committed +# packages. Since 0.2.0 it stores one row per (r_package, package_manager, +# system_package): +# +# * apt names come from Posit Package Manager for Ubuntu 22.04, with +# overrides that keep them portable across Debian (bookworm/trixie) and +# Ubuntu (jammy/noble) -- for example "default-libmysqlclient-dev" +# rather than the Ubuntu-only "libmysqlclient-dev". +# * dnf names come from Package Manager for Red Hat 9 (also used for +# Fedora and the RHEL rebuilds; yum platforms reuse them at run time). +# * zypper names come from Package Manager for openSUSE 15.6. +# * apk names come from the hand-curated table below, because Package +# Manager does not cover Alpine. +# +# Build tools (make and friends) are excluded: setup_advice() handles the +# toolchain separately. When Package Manager has no entry for a curated +# package on some distribution, no row is emitted for that package manager; +# bundled_sysreqs() reports the gap in the plan notes. The maintainer must +# review the diff this script produces before committing. The committed # R/bundled-sysreqs.R is the source of truth. # # This script only refreshes the curated package set below; it never expands @@ -29,6 +41,78 @@ bundled_packages <- c( "igraph", "rJava", "jqr", "odbc", "av", "rsvg", "xslt", "protolite" ) +# Sources queried per package manager. yum platforms reuse the dnf names at +# run time, so no separate yum rows are stored. +ppm_sources <- list( + apt = list(distribution = "ubuntu", release = "22.04"), + dnf = list(distribution = "redhat", release = "9"), + zypper = list(distribution = "opensuse", release = "15.6") +) + +# Toolchain packages are advice for setup_advice(), not per-package +# requirements; drop them wherever Package Manager reports them. +build_tool_excludes <- c( + "make", "automake", "autoconf", "cmake", "gcc", "gcc-c++", "g++", + "build-essential", "pkg-config", "pkgconf", "pkgconf-pkg-config" +) + +# Portability overrides applied to apt names, so the committed names work on +# both Debian and Ubuntu. +apt_overrides <- c( + "libfreetype6-dev" = "libfreetype-dev", + "libgsl0-dev" = "libgsl-dev", + "libmysqlclient-dev" = "default-libmysqlclient-dev", + "libxslt-dev" = "libxslt1-dev" +) + +# Hand-curated Alpine (apk) names. Alpine is not covered by Package Manager; +# names follow the Alpine 3.20 main/community repositories. V8 is omitted: +# Alpine has no maintained libnode/libv8 development package. +apk_sysreqs <- list( + curl = c("curl-dev", "openssl-dev"), + xml2 = "libxml2-dev", + openssl = "openssl-dev", + ragg = c( + "freetype-dev", "libjpeg-turbo-dev", "libpng-dev", "tiff-dev", + "libwebp-dev" + ), + systemfonts = c("fontconfig-dev", "freetype-dev"), + textshaping = c("freetype-dev", "fribidi-dev", "harfbuzz-dev"), + sf = c("gdal-dev", "gdal-tools", "geos-dev", "proj-dev", "sqlite-dev"), + terra = c("gdal-dev", "gdal-tools", "geos-dev", "proj-dev", "sqlite-dev"), + units = "udunits-dev", + git2r = c("libgit2-dev", "libssh2-dev", "openssl-dev"), + gert = "libgit2-dev", + magick = "imagemagick-dev", + pdftools = c("poppler-data", "poppler-dev"), + tesseract = c("leptonica-dev", "tesseract-ocr-dev"), + RPostgres = "libpq-dev", + RMariaDB = "mariadb-connector-c-dev", + RMySQL = "mariadb-connector-c-dev", + RODBC = "unixodbc-dev", + sodium = "libsodium-dev", + stringi = "icu-dev", + webp = "libwebp-dev", + png = "libpng-dev", + jpeg = "libjpeg-turbo-dev", + tiff = c("libjpeg-turbo-dev", "tiff-dev"), + gsl = "gsl-dev", + RcppGSL = "gsl-dev", + gmp = "gmp-dev", + Rmpfr = c("gmp-dev", "mpfr-dev"), + fftwtools = "fftw-dev", + hdf5r = "hdf5-dev", + ncdf4 = "netcdf-dev", + igraph = c("glpk-dev", "libxml2-dev"), + rJava = "openjdk21-jdk", + jqr = "jq-dev", + odbc = "unixodbc-dev", + av = "ffmpeg-dev", + rsvg = "librsvg-dev", + xslt = "libxslt-dev", + protolite = "protobuf-dev" +) + quote_chr <- function(x) { paste0("\"", gsub("([\"\\\\])", "\\\\\\1", x), "\"") } @@ -62,7 +146,7 @@ download_ppm_sysreqs <- function(base_url, repo, distribution, release) { json_read_file(tmp) } -normalize_ppm_packages <- function(response) { +normalize_ppm_packages <- function(response, package_manager) { items <- response$requirements %||% list() rows <- list() @@ -70,6 +154,7 @@ normalize_ppm_packages <- function(response) { r_package <- item$name %||% NA_character_ req <- item$requirements %||% list() system_packages <- compact_chr(as_chr(req$packages)) + system_packages <- setdiff(system_packages, build_tool_excludes) if (!length(system_packages) || is.na(r_package) || !nzchar(r_package)) { next } @@ -77,6 +162,7 @@ normalize_ppm_packages <- function(response) { for (system_package in system_packages) { rows[[length(rows) + 1L]] <- data.frame( r_package = r_package, + package_manager = package_manager, system_package = system_package, stringsAsFactors = FALSE ) @@ -95,7 +181,19 @@ normalize_ppm_packages <- function(response) { call. = FALSE ) } - out[order(tolower(out$r_package), out$system_package), , drop = FALSE] + out +} + +apk_rows <- function() { + rows <- lapply(names(apk_sysreqs), function(r_package) { + data.frame( + r_package = r_package, + package_manager = "apk", + system_package = apk_sysreqs[[r_package]], + stringsAsFactors = FALSE + ) + }) + do.call(rbind, rows) } bundled_data_lines <- function(data) { @@ -105,6 +203,9 @@ bundled_data_lines <- function(data) { " r_package = c(", format_chr_vector(data$r_package), " ),", + " package_manager = c(", + format_chr_vector(data$package_manager), + " ),", " system_package = c(", format_chr_vector(data$system_package), " ),", @@ -132,29 +233,49 @@ arg_or <- function(args, i, default) { } args <- commandArgs(trailingOnly = TRUE) -distribution <- arg_or(args, 1L, "ubuntu") -release <- arg_or(args, 2L, "22.04") -repo <- arg_or(args, 3L, "cran") -base_url <- arg_or(args, 4L, "https://packagemanager.posit.co") - -response <- download_ppm_sysreqs( - base_url = base_url, - repo = repo, - distribution = distribution, - release = release -) -data <- normalize_ppm_packages(response) +repo <- arg_or(args, 1L, "cran") +base_url <- arg_or(args, 2L, "https://packagemanager.posit.co") + +parts <- lapply(names(ppm_sources), function(pm) { + src <- ppm_sources[[pm]] + response <- download_ppm_sysreqs( + base_url = base_url, + repo = repo, + distribution = src$distribution, + release = src$release + ) + normalize_ppm_packages(response, package_manager = pm) +}) +data <- do.call(rbind, c(parts, list(apk_rows()))) + +idx <- match(data$system_package, names(apt_overrides)) +override <- data$package_manager == "apt" & !is.na(idx) +data$system_package[override] <- unname(apt_overrides[idx[override]]) + +data <- unique(data) +pm_order <- match(data$package_manager, c("apt", "dnf", "zypper", "apk")) +data <- data[order(pm_order, tolower(data$r_package), data$system_package), , drop = FALSE] + +for (pm in c("apt", "dnf", "zypper", "apk")) { + covered <- unique(data$r_package[data$package_manager == pm]) + missing <- setdiff(bundled_packages, covered) + if (length(missing)) { + message( + "No ", pm, " rows for: ", paste(missing, collapse = ", "), + " (bundled_sysreqs() will report these in the plan notes)." + ) + } +} + replace_between_markers( path = file.path("R", "bundled-sysreqs.R"), replacement = bundled_data_lines(data) ) message( - "Updated bundled sysreq data for ", - distribution, - " ", - release, - " with ", + "Updated bundled sysreq data with ", nrow(data), - " rows." + " rows across ", + length(unique(data$package_manager)), + " package managers." ) diff --git a/man/check_packages.Rd b/man/check_packages.Rd index a964386..5cfd6d1 100644 --- a/man/check_packages.Rd +++ b/man/check_packages.Rd @@ -38,8 +38,9 @@ A \code{sysreqr_plan}. \description{ Resolves the system packages an R package needs on a given Linux platform and returns them in a structured plan. The \code{"auto"} backend prefers the -offline bundled database on \code{apt} platforms, then Posit Package Manager, -then \code{pak}. +offline bundled database (which carries names for the \code{apt}, \code{dnf}/\code{yum}, +\code{zypper}, and \code{apk} package managers), then Posit Package Manager, then +\code{pak}. } \examples{ plan <- check_packages(c("xml2", "curl"), platform = "ubuntu-22.04") diff --git a/man/ppm_sysreqs.Rd b/man/ppm_sysreqs.Rd index 69cb9d4..0f80fe8 100644 --- a/man/ppm_sysreqs.Rd +++ b/man/ppm_sysreqs.Rd @@ -34,7 +34,8 @@ A \code{sysreqr_plan}. Queries the Posit Package Manager \verb{/sysreqs} endpoint for the given packages and platform, and normalizes the response into a \code{sysreqr_plan}. If the API call fails, the function falls back to the -bundled database and records the failure in the +bundled database (or to an empty plan when the bundled data has no names +for the platform's package manager) and records the failure in the \code{"fallback_error"} attribute of the returned plan. } \examples{ diff --git a/tests/testthat/test-check.R b/tests/testthat/test-check.R index 2d8092e..9aa1e82 100644 --- a/tests/testthat/test-check.R +++ b/tests/testthat/test-check.R @@ -8,25 +8,25 @@ test_that("auto backend uses bundled data for known packages on apt", { expect_true("libcurl4-openssl-dev" %in% plan$system_package) }) -test_that("auto backend does not pick bundled on non-apt platforms (regression)", { - # Regression test: previously, select_backend() returned "bundled" for any - # apt-keyed package set even on Fedora/Rocky/SUSE, which then hard-errored - # with "Bundled fallback data currently supports apt platforms only." - fedora <- resolve_platform("fedora-40") - expect_false(identical(select_backend("xml2", "auto", fedora), "bundled")) - - rocky <- resolve_platform("rockylinux-9") - expect_false(identical(select_backend("xml2", "auto", rocky), "bundled")) - - ubuntu <- resolve_platform("ubuntu-22.04") - expect_equal(select_backend("xml2", "auto", ubuntu), "bundled") +test_that("auto backend picks bundled for known packages on all bundled managers", { + # Since the bundled table carries per-package-manager names, auto routing + # uses it on apt, dnf, yum, zypper, and apk platforms alike. + expect_equal(select_backend("xml2", "auto", resolve_platform("ubuntu-22.04")), "bundled") + expect_equal(select_backend("xml2", "auto", resolve_platform("fedora-40")), "bundled") + expect_equal(select_backend("xml2", "auto", resolve_platform("rockylinux-9")), "bundled") + expect_equal(select_backend("xml2", "auto", resolve_platform("centos7")), "bundled") + expect_equal(select_backend("xml2", "auto", resolve_platform("opensuse156")), "bundled") + expect_equal(select_backend("xml2", "auto", resolve_platform("alpine-3.20")), "bundled") }) -test_that("select_backend routes a known package to ppm on non-apt platforms", { - # Positive assertion (not just "not bundled"): a simple, known package on a - # non-apt platform must route to ppm. - expect_equal(select_backend("xml2", "auto", resolve_platform("fedora-40")), "ppm") - expect_equal(select_backend("xml2", "auto", resolve_platform("rockylinux-9")), "ppm") +test_that("select_backend routes around bundled when it cannot help", { + # Unknown packages route to ppm even when the platform is bundled-capable. + expect_equal( + select_backend("definitelynotbundled", "auto", resolve_platform("fedora-40")), + "ppm" + ) + # brew has no bundled name set, so known packages route to ppm there. + expect_equal(select_backend("xml2", "auto", resolve_platform("macos-14")), "ppm") }) test_that("bundled database uses cross-distro-portable package names", { @@ -44,36 +44,27 @@ test_that("bundled database uses cross-distro-portable package names", { expect_false("libfreetype6-dev" %in% ragg$system_package) }) -test_that("check_packages on Fedora falls through without the apt-only error", { - # When auto routing previously chose bundled on any platform with known - # packages, this call errored with "Bundled fallback data currently supports - # apt platforms only." Mock pak so the test does not depend on a real pak - # install and pin the route to ppm with a mock that returns a Fedora plan. - fedora_ppm_mock <- function(endpoint, query, base_url) { - if (identical(endpoint, "status")) { - return(list( - version = "test", - distros = list(list( - os = "linux", distribution = "fedora", release = "40", - name = "fedora-40", binaryURL = "fedora-40", - sysReqs = TRUE, binaries = TRUE - )) - )) - } - list(requirements = list(list( - name = "xml2", - requirements = list( - packages = list("libxml2-devel"), - install_scripts = list("dnf install -y libxml2-devel") - ) - ))) - } +test_that("check_packages on Fedora resolves dnf names without error", { + withr::local_options(sysreqr.installed_system_packages = character()) + + expect_no_error( + plan <- check_packages("xml2", platform = "fedora-40", backend = "auto") + ) + expect_s3_class(plan, "sysreqr_plan") + expect_equal(attr(plan, "backend"), "bundled") + expect_true("libxml2-devel" %in% plan$system_package) + expect_false("libxml2-dev" %in% plan$system_package) +}) +test_that("auto routing on Fedora falls through to pak for unknown packages", { + # Packages outside the bundled table route to ppm; Package Manager has no + # Fedora support, so auto routing must continue to the mocked pak backend + # instead of erroring. pak_mock <- function(pkg, ...) { list( packages = data.frame( sysreq = "libxml2", - packages = I(list("xml2")), + packages = I(list("definitelynotbundled")), system_packages = I(list("libxml2-devel")), pre_install = I(list(character())), post_install = I(list(character())), @@ -85,16 +76,15 @@ test_that("check_packages on Fedora falls through without the apt-only error", { } withr::local_options( - sysreqr.ppm_get = fedora_ppm_mock, sysreqr.pak_pkg_sysreqs = pak_mock, sysreqr.installed_system_packages = character() ) expect_no_error( - plan <- check_packages("xml2", platform = "fedora-40", backend = "auto") + plan <- check_packages("definitelynotbundled", platform = "fedora-40", backend = "auto") ) expect_s3_class(plan, "sysreqr_plan") - expect_false(identical(attr(plan, "backend"), "bundled")) + expect_equal(attr(plan, "backend"), "pak") }) test_that("bundled database covers the expanded package set", { @@ -112,6 +102,65 @@ test_that("bundled database covers the expanded package set", { expect_true("default-jdk" %in% rjava$system_package) }) +test_that("bundled backend resolves per-package-manager names", { + withr::local_options(sysreqr.installed_system_packages = character()) + + fedora <- check_packages("xml2", platform = "fedora-40", backend = "bundled") + expect_equal(fedora$system_package, "libxml2-devel") + expect_equal(fedora$package_manager, "dnf") + expect_match(fedora$notes, "verify the exact name", fixed = TRUE) + + centos <- check_packages("RPostgres", platform = "centos7", backend = "bundled") + expect_equal(centos$system_package, "libpq-devel") + expect_equal(centos$package_manager, "yum") + expect_match(centos$install_script, "^yum install") + + suse <- check_packages("curl", platform = "opensuse156", backend = "bundled") + expect_true(all(c("libcurl-devel", "libopenssl-devel") %in% suse$system_package)) + + alpine <- check_packages("sf", platform = "alpine-3.20", backend = "bundled") + expect_true(all( + c("gdal-dev", "geos-dev", "proj-dev", "sqlite-dev") %in% alpine$system_package + )) + + ubuntu <- check_packages("xml2", platform = "ubuntu-22.04", backend = "bundled") + expect_false(any(grepl("verify the exact name", ubuntu$notes, fixed = TRUE))) +}) + +test_that("bundled backend reports packages with no name set for the platform", { + withr::local_options(sysreqr.installed_system_packages = character()) + + plan <- check_packages("V8", platform = "opensuse156", backend = "bundled") + expect_equal(nrow(plan), 0L) + expect_true("V8" %in% attr(plan, "unresolved")) + + apt <- check_packages("V8", platform = "ubuntu-22.04", backend = "bundled") + expect_true("libnode-dev" %in% apt$system_package) +}) + +test_that("bundled backend still rejects unsupported package managers", { + platform <- resolve_platform("ubuntu-22.04") + platform$package_manager <- "brew" + expect_error( + check_packages("xml2", platform = platform, backend = "bundled"), + "apt, dnf, yum, zypper" + ) +}) + +test_that("bundled database has well-formed cross-distro rows", { + db <- getFromNamespace("bundled_sysreqs_db", "sysreqr") + + expect_setequal(unique(db$package_manager), c("apt", "dnf", "zypper", "apk")) + expect_false(any(is.na(db$system_package))) + expect_true(all(grepl("^[A-Za-z0-9][A-Za-z0-9._+:@-]*$", db$system_package))) + + # Every curated package must at least have apt names; the apt set is the + # baseline the package has shipped since 0.1.0. + known <- unique(db$r_package) + apt_known <- unique(db$r_package[db$package_manager == "apt"]) + expect_setequal(known, apt_known) +}) + test_that("check_packages errors when given no packages", { expect_error(check_packages(character(), platform = "ubuntu-22.04"), "at least one package") diff --git a/tests/testthat/test-diagnose.R b/tests/testthat/test-diagnose.R index c36b5db..a88580a 100644 --- a/tests/testthat/test-diagnose.R +++ b/tests/testthat/test-diagnose.R @@ -176,3 +176,60 @@ test_that("diagnose_failed_packages handles an empty package vector", { expect_s3_class(plan, "sysreqr_plan") expect_equal(nrow(plan), 0L) }) + +test_that("expanded log patterns map common libraries per package manager", { + cases <- list( + list("fatal error: png.h: No such file or directory", "ubuntu-22.04", "libpng-dev"), + list("fatal error: png.h: No such file or directory", "fedora-40", "libpng-devel"), + list("/usr/bin/ld: cannot find -lpq", "ubuntu-22.04", "libpq-dev"), + list("/usr/bin/ld: cannot find -lpq", "fedora-40", "libpq-devel"), + list("fatal error: sqlite3.h: No such file or directory", "opensuse156", "sqlite3-devel"), + list("fatal error: Magick++.h: No such file or directory", "ubuntu-22.04", "libmagick++-dev"), + list("fatal error: ft2build.h: No such file or directory", "alpine-3.20", "freetype-dev"), + list("fatal error: unicode/ucnv.h: No such file or directory", "fedora-40", "libicu-devel"), + list("/usr/bin/ld: cannot find -lz", "ubuntu-22.04", "zlib1g-dev"), + list("fatal error: cairo.h: No such file or directory", "ubuntu-22.04", "libcairo2-dev"), + list( + "fatal error: mysql.h: No such file or directory", + "ubuntu-22.04", "default-libmysqlclient-dev" + ), + list( + "fatal error: tesseract/baseapi.h: No such file or directory", + "fedora-40", "tesseract-devel" + ) + ) + for (case in cases) { + plan <- diagnose_log(text = case[[1]], platform = case[[2]], check_installed = FALSE) + expect_true( + case[[3]] %in% plan$system_package, + label = paste0(case[[2]], " / ", case[[1]], " -> ", case[[3]]) + ) + } +}) + +test_that("linker patterns do not over-match similar library names", { + # -lzstd must not trigger the zlib rule, -lgmpxx must not trigger gmp. + zstd <- diagnose_log( + text = "/usr/bin/ld: cannot find -lzstd", + platform = "ubuntu-22.04", + check_installed = FALSE + ) + expect_false("zlib1g-dev" %in% zstd$system_package) + + gmpxx <- diagnose_log( + text = "/usr/bin/ld: cannot find -lgmpxx", + platform = "ubuntu-22.04", + check_installed = FALSE + ) + expect_false("libgmp3-dev" %in% gmpxx$system_package) +}) + +test_that("a header and linker hit for the same library produce one suggestion", { + text <- paste( + "fatal error: png.h: No such file or directory", + "/usr/bin/ld: cannot find -lpng", + sep = "\n" + ) + plan <- diagnose_log(text = text, platform = "ubuntu-22.04", check_installed = FALSE) + expect_equal(sum(plan$system_package == "libpng-dev"), 1L) +}) diff --git a/tests/testthat/test-plan.R b/tests/testthat/test-plan.R index e542200..0ab5d6b 100644 --- a/tests/testthat/test-plan.R +++ b/tests/testthat/test-plan.R @@ -32,3 +32,22 @@ test_that("print.sysreqr_plan emits the expected sections", { expect_match(out, "Backend:", fixed = TRUE) expect_match(out, "libxml2-dev", fixed = TRUE) }) + +test_that("list_installed_system_packages has an apk branch", { + list_installed <- getFromNamespace("list_installed_system_packages", "sysreqr") + + skip_if(Sys.which("apk") == "", "apk is not available on this host") + installed <- list_installed(resolve_platform("alpine-3.20")) + expect_type(installed, "character") +}) + +test_that("startup_platform_example returns a usable platform key", { + helper <- getFromNamespace("startup_platform_example", "sysreqr") + example <- helper() + expect_type(example, "character") + expect_length(example, 1L) + expect_true(nzchar(example)) + # Whatever the host, the example must resolve so the startup suggestion + # never points users at an invalid platform string. + expect_s3_class(resolve_platform(example), "sysreqr_platform") +}) diff --git a/tests/testthat/test-ppm.R b/tests/testthat/test-ppm.R index 37379c0..6dc12bd 100644 --- a/tests/testthat/test-ppm.R +++ b/tests/testthat/test-ppm.R @@ -86,6 +86,45 @@ test_that("ppm_sysreqs falls back to bundled data on API error", { expect_match(attr(plan, "fallback_error"), "simulated PPM outage") }) +test_that("ppm_sysreqs API-error fallback uses platform-matching bundled names", { + failing_mock <- function(endpoint, query, base_url) { + stop("simulated PPM outage", call. = FALSE) + } + withr::local_options( + sysreqr.ppm_get = failing_mock, + sysreqr.installed_system_packages = character() + ) + + suse <- ppm_sysreqs("xml2", platform = "opensuse156") + expect_true("libxml2-devel" %in% suse$system_package) + expect_match(attr(suse, "fallback_error"), "simulated PPM outage") + + rocky <- ppm_sysreqs("xml2", platform = "rockylinux-9") + expect_true("libxml2-devel" %in% rocky$system_package) + expect_match(rocky$install_script, "^dnf install") +}) + +test_that("ppm_sysreqs API-error fallback returns an empty plan on brew", { + # Regression test: this previously hard-errored with "Bundled fallback data + # currently supports apt platforms only." instead of reporting the original + # Package Manager failure. + failing_mock <- function(endpoint, query, base_url) { + stop("simulated PPM outage", call. = FALSE) + } + withr::local_options( + sysreqr.ppm_get = failing_mock, + sysreqr.installed_system_packages = character() + ) + platform <- resolve_platform("ubuntu-22.04") + platform$package_manager <- "brew" + + plan <- ppm_sysreqs("xml2", platform = platform) + expect_s3_class(plan, "sysreqr_plan") + expect_equal(nrow(plan), 0L) + expect_equal(attr(plan, "unresolved"), "xml2") + expect_match(attr(plan, "fallback_error"), "simulated PPM outage") +}) + test_that("use_ppm dry_run returns repo configuration lines", { lines <- use_ppm("user", platform = "ubuntu-22.04", dry_run = TRUE) expect_true(any(grepl("packagemanager.posit.co", lines, fixed = TRUE))) diff --git a/vignettes/faq.Rmd b/vignettes/faq.Rmd index 70579b5..3411e0a 100644 --- a/vignettes/faq.Rmd +++ b/vignettes/faq.Rmd @@ -57,7 +57,7 @@ that in one place. | `pak::pkg_sysreqs()` | Authoritative live resolver | Requires `pak`; no log diagnosis | | `remotes::system_requirements()` | Light; widely available | No log diagnosis, no project scanner | | `renv::sysreqs()` | Project-oriented; integrates with `renv` workflow | Requires `renv` | -| `sysreqr` | Zero runtime deps; log diagnosis; beginner UX | Bundled DB is small; biased to `apt` | +| `sysreqr` | Zero runtime deps; log diagnosis; beginner UX | Bundled DB covers ~40 curated packages | `sysreqr` can use `pak` as one of its backends (`backend = "pak"`) when it is installed. The two tools are complementary, not competitors. @@ -126,9 +126,11 @@ openSUSE, the equivalent is `R-base`. ## I'm on Alpine. What's supported? Alpine uses `apk` and is detected as `alpine`. Install commands use -`apk add --no-cache`. The bundled fallback database currently uses -Debian/Ubuntu names, so prefer the `ppm` or `pak` backend on Alpine. Many -common system libraries on Alpine are in the +`apk add --no-cache`. The bundled fallback database carries Alpine package +names for its curated set, and log diagnosis maps error patterns to `apk` +names. Posit Package Manager does not cover Alpine, so for packages outside +the bundled set use the `pak` backend. Many common system libraries on +Alpine are in the [community repository](https://pkgs.alpinelinux.org/packages?branch=v3.20&repo=community). ## I'm behind a corporate proxy. PPM queries fail diff --git a/vignettes/preflight-setup.Rmd b/vignettes/preflight-setup.Rmd index 05872e0..d43f534 100644 --- a/vignettes/preflight-setup.Rmd +++ b/vignettes/preflight-setup.Rmd @@ -182,9 +182,11 @@ explain(plan) `check_packages()` accepts four backend modes. * `backend = "auto"` uses bundled data for simple, known CRAN packages on - `apt` platforms, then Package Manager, then `pak` when possible. + `apt`, `dnf`/`yum`, `zypper`, and `apk` platforms, then Package Manager, + then `pak` when possible. * `backend = "bundled"` uses only the static database shipped with the - installed `sysreqr` release. Currently optimized for `apt`. + installed `sysreqr` release. It carries package names for `apt`, + `dnf`/`yum`, `zypper`, and `apk`. * `backend = "ppm"` uses the Posit Package Manager API when network access is available. * `backend = "pak"` uses `pak::pkg_sysreqs()` when `pak` is installed. From 802980483a943702c371260026257bd9dc82c2cf Mon Sep 17 00:00:00 2001 From: choxos Date: Wed, 16 Sep 2026 19:13:07 -0400 Subject: [PATCH 2/3] Fix Package Manager lookups on real EL, openSUSE Leap, and Ubuntu-derivative hosts Posit Package Manager keys on rockylinux 9, redhat 9, opensuse 15.6, and ubuntu 24.04, but /etc/os-release on those machines reports rocky 9.4, opensuse-leap 15.6, or linuxmint 22. ppm_sysreqs(), check_ppm(), and ppm_repo() therefore failed with "Unsupported system" or "No Package Manager binary URL" on real hosts. A single ppm_target() helper now maps the detected distro and version to the Package Manager pair; the detected values themselves are unchanged. Ubuntu derivatives report the underlying Ubuntu release taken from UBUNTU_CODENAME. Also: * resolve_platform() splits at the last dash so "opensuse-leap-15.6" resolves correctly, and "opensuse-15.6", "centos-7", "rockylinux-9.4" find their binary URL segment. * "rhel9" and "rhel10" describe Red Hat Enterprise Linux, not Rocky. * Failed package extraction reads "compilation failed" and "lazy loading failed" lines in addition to "configuration failed". * Project scans and check_library() drop packages that ship with R. * write_json() writes null for missing strings instead of "NA". * apt installed-state detection ignores removed-but-unpurged packages. * The detect_platform() example ships its fixture in inst/extdata. --- .Rbuildignore | 2 + DESCRIPTION | 2 +- NEWS.md | 31 +++++++ R/check.R | 2 +- R/diagnose-log.R | 5 +- R/json.R | 3 + R/plan.R | 7 +- R/platform.R | 33 +++++-- R/ppm.R | 57 ++++++++---- R/project.R | 4 +- R/utils.R | 8 ++ inst/extdata/os-release-fedora-40 | 22 +++++ man/check_project.Rd | 2 +- .../testthat/fixtures/os-release-linuxmint-22 | 12 +++ .../fixtures/os-release-opensuse-leap-15.6 | 12 +++ tests/testthat/test-check.R | 17 ++++ tests/testthat/test-diagnose.R | 10 ++ tests/testthat/test-json.R | 14 +++ tests/testthat/test-plan.R | 12 +++ tests/testthat/test-platform.R | 55 +++++++++++ tests/testthat/test-ppm.R | 92 +++++++++++++++++++ tests/testthat/test-project.R | 16 ++++ 22 files changed, 382 insertions(+), 36 deletions(-) create mode 100644 inst/extdata/os-release-fedora-40 create mode 100644 tests/testthat/fixtures/os-release-linuxmint-22 create mode 100644 tests/testthat/fixtures/os-release-opensuse-leap-15.6 diff --git a/.Rbuildignore b/.Rbuildignore index b4a90ad..cb3b8a0 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -25,3 +25,5 @@ ^codecov\.yml$ ^CITATION\.cff$ ^vignettes/\..*$ +^documentation$ +^documentation/ diff --git a/DESCRIPTION b/DESCRIPTION index 035df65..5b78fba 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -30,4 +30,4 @@ Config/testthat/edition: 3 Config/Needs/website: pkgdown Encoding: UTF-8 Roxygen: list(markdown = TRUE) -Config/roxygen2/version: 8.0.0 +Config/roxygen2/version: 8.1.0 diff --git a/NEWS.md b/NEWS.md index b909aba..ad96127 100644 --- a/NEWS.md +++ b/NEWS.md @@ -70,6 +70,37 @@ error recorded in the `fallback_error` attribute. * `detect_platform()` now reports Alpine hosts as supported, matching `resolve_platform("alpine-3.20")` and the documented platform list. +* Posit Package Manager queries now work from real Rocky Linux, AlmaLinux, + Red Hat, openSUSE Leap, and Ubuntu-derivative hosts. Package Manager keys + on `rockylinux 9`, `redhat 9`, `opensuse 15.6`, and `ubuntu 24.04`, but + `/etc/os-release` reports `rocky 9.4`, `opensuse-leap 15.6`, or + `linuxmint 22`, so `ppm_sysreqs()`, `check_ppm()`, and `ppm_repo()` all + failed with "Unsupported system" or "No Package Manager binary URL" on + those machines. The detected `distro` and `version` are unchanged; only + the Package Manager lookup maps them. Ubuntu derivatives (Linux Mint, + Pop!_OS, elementary OS) now report the underlying Ubuntu release, taken + from `UBUNTU_CODENAME`. +* `resolve_platform()` splits `-` at the last dash, so + `"opensuse-leap-15.6"` resolves to openSUSE Leap 15.6 instead of distro + `opensuse` with version `leap-15.6`. The shorthand forms `"opensuse-15.6"`, + `"centos-7"`, and `"rockylinux-9.4"` now find their Package Manager binary + URL segment. +* `resolve_platform("rhel9")` and `resolve_platform("rhel10")` now describe + Red Hat Enterprise Linux rather than Rocky Linux. +* `diagnose_log()` and `check_error()` recognize `compilation failed for + package` and `lazy loading failed for package` lines, not only + `configuration failed for package`, when extracting failed package names. +* `detect_project_packages()`, `check_project()`, and `check_library()` drop + packages that ship with R (`stats`, `utils`, `methods`, and so on) instead + of reporting them as unresolved. +* `write_json()` writes `null` for missing strings; previously `NA` values + in columns such as `sysreq` were written as the string `"NA"`. +* Installed-state detection on `apt` hosts ignores packages that were + removed but not purged (dpkg state `config-files`), which `dpkg-query -W` + lists alongside installed packages. +* The `detect_platform()` example now ships its fixture file + (`inst/extdata/os-release-fedora-40`), so it runs instead of silently + skipping. * `use_ppm()` documentation no longer claims that `scope` selects which `.Rprofile` is edited; `path` is always required for writing, and the error message now suggests a scope-appropriate path. diff --git a/R/check.R b/R/check.R index 9b00ebb..fa79a1c 100644 --- a/R/check.R +++ b/R/check.R @@ -247,7 +247,7 @@ check_library <- function(packages = NULL, library = .libPaths()[1], packages <- .packages(all.available = TRUE, lib.loc = library) } - packages <- compact_chr(as.character(packages)) + packages <- setdiff(compact_chr(as.character(packages)), base_r_packages) if (!length(packages)) { stop("No installed packages were found to check.", call. = FALSE) } diff --git a/R/diagnose-log.R b/R/diagnose-log.R index 36d9108..178867b 100644 --- a/R/diagnose-log.R +++ b/R/diagnose-log.R @@ -460,7 +460,10 @@ extract_failed_packages <- function(text) { packages <- c( extract_first_group( lines, - "(?:ERROR:\\s*)?configuration failed for package ['\"]([^'\"]+)['\"]" + paste0( + "(?:ERROR:\\s*)?(?:configuration|compilation|lazy loading) failed ", + "for package ['\"]([^'\"]+)['\"]" + ) ), extract_first_group( lines, diff --git a/R/json.R b/R/json.R index 0e30c2b..43a1ad2 100644 --- a/R/json.R +++ b/R/json.R @@ -239,6 +239,9 @@ json_serialize <- function(x, pretty = FALSE, indent = 0L) { if (length(x) != 1L) { return(json_serialize_array(as.list(x), pretty, indent)) } + if (is.na(x)) { + return("null") + } return(json_quote(x)) } diff --git a/R/plan.R b/R/plan.R index 81a1109..068f807 100644 --- a/R/plan.R +++ b/R/plan.R @@ -261,11 +261,14 @@ list_installed_system_packages <- function(platform = NULL) { # tokens intact when passed through the shell. out <- tryCatch({ if (identical(pm, "apt") && nzchar(Sys.which("dpkg-query"))) { - system2( + # Packages removed but not purged stay in the dpkg database in the + # "config-files" state; only "installed" counts. + lines <- system2( "dpkg-query", - c("-W", shQuote("-f=${Package}\n")), + c("-W", shQuote("-f=${db:Status-Status} ${Package}\n")), stdout = TRUE, stderr = FALSE ) + sub("^installed ", "", lines[startsWith(lines, "installed ")]) } else if (pm %in% c("dnf", "yum", "zypper") && nzchar(Sys.which("rpm"))) { system2( "rpm", diff --git a/R/platform.R b/R/platform.R index 1388c4b..a4b8199 100644 --- a/R/platform.R +++ b/R/platform.R @@ -103,6 +103,16 @@ detect_platform_from_file <- function(os_release) { } if (!is.na(distro) && "UBUNTU_CODENAME" %in% names(props)) { + if (!identical(distro, "ubuntu")) { + # Ubuntu derivatives (Linux Mint, Pop!_OS, elementary OS) report their + # own VERSION_ID; map the Ubuntu codename back to the Ubuntu release so + # Package Manager and the plan see the base distribution. + known <- known_platforms() + idx <- match(codename, known$codename) + if (!is.na(idx)) { + version <- known$version[[idx]] + } + } distro <- "ubuntu" } @@ -220,10 +230,12 @@ resolve_platform <- function(platform = NULL) { )) } - parts <- strsplit(platform, "-", fixed = TRUE)[[1]] - if (length(parts) >= 2) { - distro <- parts[[1]] - version <- paste(parts[-1], collapse = "-") + # Split at the last dash so os-release IDs that contain a dash + # ("opensuse-leap-15.6") keep their full distro name. + parts <- regmatches(platform, regexec("^(.+)-([^-]+)$", platform))[[1]] + if (length(parts) == 3L) { + distro <- parts[[2]] + version <- parts[[3]] return(new_platform( os = "linux", distro = distro, @@ -258,7 +270,7 @@ known_platforms <- function() { "ubuntu", "ubuntu", "ubuntu", "debian", "debian", "debian", "debian", "redhat", "redhat", "redhat", "redhat", - "rockylinux", "rockylinux", "rockylinux", "rockylinux", + "rockylinux", "rockylinux", "redhat", "redhat", "almalinux", "almalinux", "centos", "opensuse", "sle", "sle" ), @@ -292,7 +304,8 @@ known_platforms <- function() { "Debian 12", "Debian 13", "Debian 12", "Debian 13", "Red Hat Enterprise Linux 7", "Red Hat Enterprise Linux 8", "Red Hat Enterprise Linux 9", "Red Hat Enterprise Linux 10", - "Rocky Linux 9", "Rocky Linux 10", "Rocky Linux 9", "Rocky Linux 10", + "Rocky Linux 9", "Rocky Linux 10", + "Red Hat Enterprise Linux 9", "Red Hat Enterprise Linux 10", "AlmaLinux 9", "AlmaLinux 10", "CentOS 7", "OpenSUSE 15.6", "SLE 15.6", "SLE 15.6" ), @@ -302,16 +315,18 @@ known_platforms <- function() { ppm_binary_from_platform <- function(distro, version, codename) { distro <- tolower(distro %||% "") - version <- as.character(version %||% "") codename <- codename %||% NA_character_ if (distro %in% c("ubuntu", "debian") && !is.na(codename) && nzchar(codename)) { return(codename) } + # Look up by the Package Manager distribution and release rather than the + # alias key, so "opensuse-15.6", "centos-7", and a Rocky host reporting + # VERSION_ID 9.4 all find their binary URL segment. + target <- ppm_target(distro, version) known <- known_platforms() - key <- paste0(distro, "-", version) - idx <- match(key, known$key) + idx <- match(TRUE, known$distro == target$distribution & known$version == target$release) if (!is.na(idx)) { return(known$ppm_binary_url[[idx]]) } diff --git a/R/ppm.R b/R/ppm.R index 52f3556..dd4704e 100644 --- a/R/ppm.R +++ b/R/ppm.R @@ -78,11 +78,18 @@ check_ppm <- function(platform = NULL, base_url = ppm_default_base_url()) { status <- ppm_api_get("status", base_url = base_url) distros <- status$distros %||% list() - matched <- Filter(function(x) { - identical(x$os, platform$os) && - identical(x$distribution, platform$distro) && - identical(as.character(x$release), as.character(platform$version)) - }, distros) + # Compare against the names Package Manager uses (rockylinux, redhat, + # opensuse, major EL releases), not the raw os-release values. + target <- tryCatch(ppm_distribution_release(platform), error = function(e) NULL) + matched <- if (is.null(target)) { + list() + } else { + Filter(function(x) { + identical(x$os, platform$os) && + identical(x$distribution, target$distribution) && + identical(as.character(x$release), target$release) + }, distros) + } list( supported = length(matched) > 0 && isTRUE(matched[[1]]$sysReqs), @@ -205,27 +212,39 @@ ppm_sysreqs <- function(packages = NULL, all = FALSE, platform = NULL, plan } -ppm_distribution_release <- function(platform) { - distro <- platform$distro - version <- as.character(platform$version) - - if (distro %in% c("rhel")) { - distro <- "redhat" +# Map a detected distro/version pair to the (distribution, release) pair that +# Posit Package Manager keys on. Detected values stay untouched everywhere +# else; only the Package Manager query and binary URL lookup use this. +ppm_target <- function(distro, version) { + distro <- tolower(distro %||% "") + version <- as.character(version %||% "") + aliases <- c( + rhel = "redhat", + rocky = "rockylinux", + almalinux = "rockylinux", + sles = "sle", + "opensuse-leap" = "opensuse" + ) + if (distro %in% names(aliases)) { + distro <- aliases[[distro]] } - - if (distro %in% c("rocky", "almalinux")) { - distro <- "rockylinux" + # Enterprise Linux hosts report point releases in os-release (9.4) while + # Package Manager only knows the major version (9). + if (distro %in% c("redhat", "rockylinux", "centos")) { + version <- sub("[.].*$", "", version) } + list(distribution = distro, release = version) +} - if (distro %in% c("sles")) { - distro <- "sle" - } +ppm_distribution_release <- function(platform) { + target <- ppm_target(platform$distro, platform$version) - if (!distro %in% c("centos", "debian", "opensuse", "redhat", "rockylinux", "sle", "ubuntu")) { + supported <- c("centos", "debian", "opensuse", "redhat", "rockylinux", "sle", "ubuntu") + if (!target$distribution %in% supported) { stop("This platform is not supported by the Package Manager sysreqs API.", call. = FALSE) } - list(distribution = distro, release = version) + target } normalize_ppm_sysreqs <- function(res, platform, repo = "cran") { diff --git a/R/project.R b/R/project.R index 693c026..e99539b 100644 --- a/R/project.R +++ b/R/project.R @@ -43,7 +43,7 @@ detect_project_packages <- function(path = ".", include_suggests = FALSE) { packages <- packages_from_source_files(path) } - sort(setdiff(compact_chr(packages), "R")) + sort(setdiff(compact_chr(packages), c("R", base_r_packages))) } #' Check system requirements for a project @@ -54,7 +54,7 @@ detect_project_packages <- function(path = ".", include_suggests = FALSE) { #' @param path Project path. #' @param include_suggests Whether to include `Suggests` from `DESCRIPTION`. #' @param platform Platform specification accepted by [resolve_platform()]. -#' @param backend One of `"auto"`, `"ppm"`, or `"pak"`. +#' @param backend One of `"auto"`, `"bundled"`, `"ppm"`, or `"pak"`. #' @param ... Passed to [check_packages()]. #' #' @return A `sysreqr_plan`. diff --git a/R/utils.R b/R/utils.R index 2d7b2c8..20633fa 100644 --- a/R/utils.R +++ b/R/utils.R @@ -70,6 +70,14 @@ is_simple_package_name <- function(x) { grepl("^[A-Za-z][A-Za-z0-9.]*$", x) } +# Packages that ship with R itself. They never have system requirements, so +# project scans and library audits drop them instead of reporting them as +# unresolved. +base_r_packages <- c( + "base", "compiler", "datasets", "graphics", "grDevices", "grid", "methods", + "parallel", "splines", "stats", "stats4", "tcltk", "tools", "utils" +) + package_manager_for_distro <- function(distro, version = NA_character_) { distro <- tolower(distro %||% "") version <- as.character(version %||% NA_character_) diff --git a/inst/extdata/os-release-fedora-40 b/inst/extdata/os-release-fedora-40 new file mode 100644 index 0000000..7d23982 --- /dev/null +++ b/inst/extdata/os-release-fedora-40 @@ -0,0 +1,22 @@ +NAME="Fedora Linux" +VERSION="40 (Workstation Edition)" +ID=fedora +VERSION_ID=40 +VERSION_CODENAME="" +PLATFORM_ID="platform:f40" +PRETTY_NAME="Fedora Linux 40 (Workstation Edition)" +ANSI_COLOR="0;38;2;60;110;180" +LOGO=fedora-logo-icon +CPE_NAME="cpe:/o:fedoraproject:fedora:40" +DEFAULT_HOSTNAME="fedora" +HOME_URL="https://fedoraproject.org/" +DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f40/system-administrators-guide/" +SUPPORT_URL="https://ask.fedoraproject.org/" +BUG_REPORT_URL="https://bugzilla.redhat.com/" +REDHAT_BUGZILLA_PRODUCT="Fedora" +REDHAT_BUGZILLA_PRODUCT_VERSION=40 +REDHAT_SUPPORT_PRODUCT="Fedora" +REDHAT_SUPPORT_PRODUCT_VERSION=40 +SUPPORT_END=2025-05-13 +VARIANT="Workstation Edition" +VARIANT_ID=workstation diff --git a/man/check_project.Rd b/man/check_project.Rd index 313d1e0..baf6d2a 100644 --- a/man/check_project.Rd +++ b/man/check_project.Rd @@ -19,7 +19,7 @@ check_project( \item{platform}{Platform specification accepted by \code{\link[=resolve_platform]{resolve_platform()}}.} -\item{backend}{One of \code{"auto"}, \code{"ppm"}, or \code{"pak"}.} +\item{backend}{One of \code{"auto"}, \code{"bundled"}, \code{"ppm"}, or \code{"pak"}.} \item{...}{Passed to \code{\link[=check_packages]{check_packages()}}.} } diff --git a/tests/testthat/fixtures/os-release-linuxmint-22 b/tests/testthat/fixtures/os-release-linuxmint-22 new file mode 100644 index 0000000..ac9ea2f --- /dev/null +++ b/tests/testthat/fixtures/os-release-linuxmint-22 @@ -0,0 +1,12 @@ +NAME="Linux Mint" +VERSION="22 (Wilma)" +ID=linuxmint +ID_LIKE="ubuntu debian" +PRETTY_NAME="Linux Mint 22" +VERSION_ID="22" +HOME_URL="https://www.linuxmint.com/" +SUPPORT_URL="https://forums.linuxmint.com/" +BUG_REPORT_URL="http://linuxmint-troubleshooting-guide.readthedocs.io/en/latest/" +PRIVACY_POLICY_URL="https://www.linuxmint.com/" +VERSION_CODENAME=wilma +UBUNTU_CODENAME=noble diff --git a/tests/testthat/fixtures/os-release-opensuse-leap-15.6 b/tests/testthat/fixtures/os-release-opensuse-leap-15.6 new file mode 100644 index 0000000..a17dbee --- /dev/null +++ b/tests/testthat/fixtures/os-release-opensuse-leap-15.6 @@ -0,0 +1,12 @@ +NAME="openSUSE Leap" +VERSION="15.6" +ID="opensuse-leap" +ID_LIKE="suse opensuse" +VERSION_ID="15.6" +PRETTY_NAME="openSUSE Leap 15.6" +ANSI_COLOR="0;32" +CPE_NAME="cpe:/o:opensuse:leap:15.6" +BUG_REPORT_URL="https://bugs.opensuse.org" +HOME_URL="https://www.opensuse.org/" +DOCUMENTATION_URL="https://en.opensuse.org/Portal:Leap" +LOGO="distributor-logo-Leap" diff --git a/tests/testthat/test-check.R b/tests/testthat/test-check.R index 9aa1e82..745d3b8 100644 --- a/tests/testthat/test-check.R +++ b/tests/testthat/test-check.R @@ -238,3 +238,20 @@ test_that("check_library validates the package vector and accepts overrides", { expect_true("libxml2-dev" %in% plan$system_package) expect_false(is.null(attr(plan, "library"))) }) + +test_that("check_library drops packages that ship with R", { + withr::local_options(sysreqr.installed_system_packages = character()) + + plan <- check_library( + packages = c("stats", "utils", "xml2"), + platform = "ubuntu-22.04", + backend = "bundled" + ) + expect_equal(unique(plan$r_package), "xml2") + expect_length(attr(plan, "unresolved"), 0L) + + expect_error( + check_library(packages = c("stats", "utils"), platform = "ubuntu-22.04"), + "No installed packages" + ) +}) diff --git a/tests/testthat/test-diagnose.R b/tests/testthat/test-diagnose.R index a88580a..fbbbf87 100644 --- a/tests/testthat/test-diagnose.R +++ b/tests/testthat/test-diagnose.R @@ -233,3 +233,13 @@ test_that("a header and linker hit for the same library produce one suggestion", plan <- diagnose_log(text = text, platform = "ubuntu-22.04", check_installed = FALSE) expect_equal(sum(plan$system_package == "libpng-dev"), 1L) }) + +test_that("failed package extraction reads compilation and lazy loading failures", { + text <- paste( + "ERROR: compilation failed for package 'xml2'", + "ERROR: lazy loading failed for package ‘rJava’", + sep = "\n" + ) + + expect_equal(extract_failed_packages(text), c("xml2", "rJava")) +}) diff --git a/tests/testthat/test-json.R b/tests/testthat/test-json.R index de14144..b8e01ee 100644 --- a/tests/testthat/test-json.R +++ b/tests/testthat/test-json.R @@ -97,3 +97,17 @@ test_that("JSON writer escapes control characters for a clean round-trip", { expect_match(escape_char, "\\u001b", fixed = TRUE) expect_equal(json_read_text(escape_char)$x, "a\033b") }) + +test_that("JSON writer emits null for missing strings", { + json_serialize <- getFromNamespace("json_serialize", "sysreqr") + + # Regression: NA_character_ serialized as the string "NA", so written plans + # carried "sysreq": "NA" instead of null. + expect_equal(json_serialize(list(x = NA_character_)), '{"x":null}') + expect_null(json_read_text(json_serialize(list(x = NA_character_)))$x) + + tmp <- withr::local_tempfile(pattern = "sysreqr-json-", fileext = ".json") + json_write(data.frame(a = NA_character_, b = "ok", stringsAsFactors = FALSE), tmp) + expect_false(any(grepl('"NA"', readLines(tmp, warn = FALSE), fixed = TRUE))) + expect_null(json_read_file(tmp)[[1]]$a) +}) diff --git a/tests/testthat/test-plan.R b/tests/testthat/test-plan.R index 0ab5d6b..e301a7f 100644 --- a/tests/testthat/test-plan.R +++ b/tests/testthat/test-plan.R @@ -51,3 +51,15 @@ test_that("startup_platform_example returns a usable platform key", { # never points users at an invalid platform string. expect_s3_class(resolve_platform(example), "sysreqr_platform") }) + +test_that("list_installed_system_packages reads dpkg status on apt hosts", { + list_installed <- getFromNamespace("list_installed_system_packages", "sysreqr") + + skip_if(Sys.which("dpkg-query") == "", "dpkg-query is not available on this host") + installed <- list_installed(resolve_platform("ubuntu-22.04")) + expect_type(installed, "character") + # dpkg itself is installed on every Debian-family host, and the status + # prefix must be stripped from the returned names. + expect_true("dpkg" %in% installed) + expect_false(any(startsWith(installed, "installed "))) +}) diff --git a/tests/testthat/test-platform.R b/tests/testthat/test-platform.R index a57bba3..c66f622 100644 --- a/tests/testthat/test-platform.R +++ b/tests/testthat/test-platform.R @@ -149,3 +149,58 @@ test_that("ppm_repo builds a URL for AlmaLinux", { "https://packagemanager.posit.co/cran/__linux__/rhel9/latest" ) }) + +test_that("detect_platform keeps openSUSE Leap hosts usable with Package Manager", { + skip_on_os("windows") + platform <- detect_platform( + os_release = test_path("fixtures", "os-release-opensuse-leap-15.6") + ) + + # The raw os-release ID is kept; only the Package Manager lookup maps it. + expect_equal(platform$distro, "opensuse-leap") + expect_equal(platform$version, "15.6") + expect_equal(platform$package_manager, "zypper") + expect_equal(platform$ppm_binary_url, "opensuse156") + expect_equal( + ppm_repo(platform = platform), + "https://packagemanager.posit.co/cran/__linux__/opensuse156/latest" + ) +}) + +test_that("detect_platform maps Ubuntu derivatives to the Ubuntu release", { + skip_on_os("windows") + platform <- detect_platform( + os_release = test_path("fixtures", "os-release-linuxmint-22") + ) + + expect_equal(platform$distro, "ubuntu") + expect_equal(platform$version, "24.04") + expect_equal(platform$codename, "noble") + expect_equal(platform$ppm_binary_url, "noble") +}) + +test_that("resolve_platform splits distro IDs that contain a dash at the last dash", { + leap <- resolve_platform("opensuse-leap-15.6") + expect_equal(leap$distro, "opensuse-leap") + expect_equal(leap$version, "15.6") + expect_equal(leap$package_manager, "zypper") + expect_equal(leap$ppm_binary_url, "opensuse156") + + expect_error(resolve_platform("ubuntu-"), "Unknown platform") +}) + +test_that("shorthand specs find Package Manager binary URLs by distro and version", { + expect_equal(resolve_platform("opensuse-15.6")$ppm_binary_url, "opensuse156") + expect_equal(resolve_platform("centos-7")$ppm_binary_url, "centos7") + expect_equal(resolve_platform("rockylinux-9.4")$ppm_binary_url, "rhel9") + expect_equal(resolve_platform("redhat-9.4")$ppm_binary_url, "rhel9") + expect_equal(resolve_platform("rhel-9.4")$ppm_binary_url, "rhel9") +}) + +test_that("rhel aliases resolve to Red Hat, not Rocky", { + rhel9 <- resolve_platform("rhel9") + expect_equal(rhel9$distro, "redhat") + expect_equal(rhel9$version, "9") + expect_equal(rhel9$ppm_binary_url, "rhel9") + expect_match(rhel9$label, "Red Hat", fixed = TRUE) +}) diff --git a/tests/testthat/test-ppm.R b/tests/testthat/test-ppm.R index 6dc12bd..116e86c 100644 --- a/tests/testthat/test-ppm.R +++ b/tests/testthat/test-ppm.R @@ -188,3 +188,95 @@ test_that("ppm_sysreqs keeps requirements with only post-install commands", { expect_match(plan$post_install, "javareconf", fixed = TRUE) expect_true("R CMD javareconf" %in% attr(plan, "post_install")) }) + +test_that("ppm_sysreqs queries Package Manager with the names it understands", { + ppm_distribution_release <- getFromNamespace("ppm_distribution_release", "sysreqr") + + # Rocky, AlmaLinux, and RHEL report point releases (9.4) in os-release; + # Package Manager only accepts the major version and the rockylinux name. + rocky <- detect_platform( + os_release = test_path("fixtures", "os-release-rockylinux-9") + ) + expect_equal( + ppm_distribution_release(rocky), + list(distribution = "rockylinux", release = "9") + ) + expect_equal( + ppm_distribution_release(resolve_platform("almalinux-9")), + list(distribution = "rockylinux", release = "9") + ) + expect_equal( + ppm_distribution_release(resolve_platform("rhel-9.4")), + list(distribution = "redhat", release = "9") + ) + + leap <- detect_platform( + os_release = test_path("fixtures", "os-release-opensuse-leap-15.6") + ) + expect_equal( + ppm_distribution_release(leap), + list(distribution = "opensuse", release = "15.6") + ) + + expect_error( + ppm_distribution_release(resolve_platform("fedora-40")), + "not supported by the Package Manager" + ) +}) + +test_that("ppm_sysreqs sends the mapped distribution and release", { + seen <- NULL + recording_mock <- function(endpoint, query, base_url) { + seen <<- query + mock_ppm_get(endpoint, query, base_url) + } + withr::local_options( + sysreqr.ppm_get = recording_mock, + sysreqr.installed_system_packages = character() + ) + + rocky <- detect_platform( + os_release = test_path("fixtures", "os-release-rockylinux-9") + ) + ppm_sysreqs("xml2", platform = rocky) + expect_equal(seen$distribution, "rockylinux") + expect_equal(seen$release, "9") + + mint <- detect_platform( + os_release = test_path("fixtures", "os-release-linuxmint-22") + ) + ppm_sysreqs("xml2", platform = mint) + expect_equal(seen$distribution, "ubuntu") + expect_equal(seen$release, "24.04") +}) + +test_that("check_ppm matches hosts through the Package Manager names", { + mock <- function(endpoint, query, base_url) { + if (identical(endpoint, "status")) { + return(list( + version = "2026.04.2", + distros = list( + list( + name = "rhel9", os = "linux", binaryURL = "rhel9", + display = "Rocky Linux 9", distribution = "rockylinux", + release = "9", sysReqs = TRUE, binaries = TRUE, hidden = FALSE + ) + ) + )) + } + mock_ppm_get(endpoint, query, base_url) + } + withr::local_options(sysreqr.ppm_get = mock) + + rocky <- detect_platform( + os_release = test_path("fixtures", "os-release-rockylinux-9") + ) + status <- check_ppm(rocky) + expect_true(status$supported) + expect_true(status$binaries) + + # Platforms outside the sysreqs API report unsupported instead of erroring. + fedora <- check_ppm("fedora-40") + expect_false(fedora$supported) + expect_null(fedora$ppm) +}) diff --git a/tests/testthat/test-project.R b/tests/testthat/test-project.R index 765ae8f..bfe3c1f 100644 --- a/tests/testthat/test-project.R +++ b/tests/testthat/test-project.R @@ -85,3 +85,19 @@ test_that("check_project routes through check_packages", { expect_s3_class(plan, "sysreqr_plan") expect_true("libxml2-dev" %in% plan$system_package) }) + +test_that("detect_project_packages drops packages that ship with R", { + tmp <- withr::local_tempdir() + writeLines( + c("library(xml2)", "stats::median(1)", "utils::head(1)", "library(methods)"), + file.path(tmp, "main.R") + ) + + expect_equal(detect_project_packages(tmp), "xml2") + + writeLines(c( + "Package: demo", + "Imports: methods, stats, curl" + ), file.path(tmp, "DESCRIPTION")) + expect_equal(detect_project_packages(tmp), "curl") +}) From 85f0523f7e64671a23f651011fef9e12aad686af Mon Sep 17 00:00:00 2001 From: choxos Date: Wed, 16 Sep 2026 19:15:36 -0400 Subject: [PATCH 3/3] Guard Ubuntu-derivative codename lookup against NA match(NA_character_, known$codename) matched the first known platform whose codename is NA and rewrote the detected version. Use incomparables = NA so an empty UBUNTU_CODENAME leaves the version alone. --- R/platform.R | 2 +- tests/testthat/test-platform.R | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/R/platform.R b/R/platform.R index a4b8199..87a81db 100644 --- a/R/platform.R +++ b/R/platform.R @@ -108,7 +108,7 @@ detect_platform_from_file <- function(os_release) { # own VERSION_ID; map the Ubuntu codename back to the Ubuntu release so # Package Manager and the plan see the base distribution. known <- known_platforms() - idx <- match(codename, known$codename) + idx <- match(codename, known$codename, incomparables = NA) if (!is.na(idx)) { version <- known$version[[idx]] } diff --git a/tests/testthat/test-platform.R b/tests/testthat/test-platform.R index c66f622..947de62 100644 --- a/tests/testthat/test-platform.R +++ b/tests/testthat/test-platform.R @@ -204,3 +204,19 @@ test_that("rhel aliases resolve to Red Hat, not Rocky", { expect_equal(rhel9$ppm_binary_url, "rhel9") expect_match(rhel9$label, "Red Hat", fixed = TRUE) }) + +test_that("Ubuntu derivatives without a known codename keep their own version", { + os_release <- withr::local_tempfile(pattern = "os-release-") + writeLines( + c("NAME=\"elementary OS\"", "ID=elementary", "ID_LIKE=ubuntu", + "VERSION_ID=\"8\"", "UBUNTU_CODENAME="), + os_release + ) + platform <- detect_platform(os_release = os_release) + + # Regression: an NA codename must not match the NA codename of an + # unrelated known platform and rewrite the version. + expect_equal(platform$distro, "ubuntu") + expect_equal(platform$version, "8") + expect_true(is.na(platform$codename)) +})