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 34e6872..ad96127 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,8 +62,45 @@ 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. +* 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/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..fa79a1c 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" @@ -242,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 f8c0a77..178867b 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 @@ -327,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 17e694c..068f807 100644 --- a/R/plan.R +++ b/R/plan.R @@ -261,17 +261,22 @@ 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", 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/platform.R b/R/platform.R index 1388c4b..87a81db 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, incomparables = NA) + 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 b991742..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), @@ -130,7 +137,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 +184,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) } @@ -193,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/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/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_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/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/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/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 2d8092e..745d3b8 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") @@ -189,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 c36b5db..fbbbf87 100644 --- a/tests/testthat/test-diagnose.R +++ b/tests/testthat/test-diagnose.R @@ -176,3 +176,70 @@ 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) +}) + +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 e542200..e301a7f 100644 --- a/tests/testthat/test-plan.R +++ b/tests/testthat/test-plan.R @@ -32,3 +32,34 @@ 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") +}) + +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..947de62 100644 --- a/tests/testthat/test-platform.R +++ b/tests/testthat/test-platform.R @@ -149,3 +149,74 @@ 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) +}) + +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)) +}) diff --git a/tests/testthat/test-ppm.R b/tests/testthat/test-ppm.R index 37379c0..116e86c 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))) @@ -149,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") +}) 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.