diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index d745606..8b4f77f 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -9,7 +9,7 @@ # user). # # The matrix spans the Python range we care about on one platform: -# * reticulate's own default miniconda Python (what most users get); +# * simple_python()'s own shipped default (3.12) -- what most users get; # * the floor -- Python 3.9, caveclient's current minimum; # * the ceiling -- Python 3.13, the newest stable release the current # reticulate fully supports (3.14 is not yet). @@ -31,15 +31,15 @@ jobs: fail-fast: false matrix: config: - - { label: "py-reticulate-default", python: "" } + - { label: "py-shipped-default", python: "" } - { label: "py-3.9-min", python: "3.9" } - { label: "py-3.13-max", python: "3.13" } env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} R_KEEP_PKG_SOURCE: yes - # Empty for the "reticulate default" leg; a version string otherwise. Read - # in the provisioning step and applied before install_miniconda() so it - # controls the Python version of reticulate's r-reticulate environment. + # Empty for the "shipped default" leg (exercises simple_python()'s own + # default pin, 3.12); a version string otherwise, passed to + # simple_python(python_version=) in the provisioning step. NATPY_PYTHON_VERSION: ${{ matrix.config.python }} steps: - uses: actions/checkout@v4 @@ -55,22 +55,25 @@ jobs: - name: Install nat.python and provision Python via simple_python() run: | - v <- Sys.getenv("NATPY_PYTHON_VERSION") - if (nzchar(v)) { - # simple_python() -> install_miniconda() honours this, so it pins the - # Python version of the managed r-reticulate environment. - Sys.setenv(RETICULATE_MINICONDA_PYTHON_VERSION = v) - message("Pinning miniconda Python to ", v) - } else { - message("Using reticulate's default miniconda Python") - } + v <- Sys.getenv("NATPY_PYTHON_VERSION") # "" on the shipped-default leg pak::local_install() library(nat.python) # The end-user path: provision miniconda + r-reticulate + pandas (via # pip, pulling a mutually-compatible numpy). "minimal" is nat.python's - # own baseline, all pandas2df() needs. - simple_python("minimal") + # own baseline, all pandas2df() needs. With no version the shipped + # default (3.12) is pinned; the matrix legs pass one explicitly. + if (nzchar(v)) { + simple_python("minimal", python_version = v) + } else { + simple_python("minimal") + } print(py_module_info(c("numpy", "pandas"))) + # Assert the interpreter is the one we asked for (3.12 by default). + want <- if (nzchar(v)) v else "3.12" + have <- as.character(reticulate::py_config()$version) + if (!startsWith(have, want)) + stop(sprintf("Provisioned Python %s, expected %s", have, want)) + message("Provisioned Python ", have, " (wanted ", want, ")") py <- reticulate::py_config()$python cat(sprintf("RETICULATE_PYTHON=%s\n", py), file = Sys.getenv("GITHUB_ENV"), append = TRUE) diff --git a/NEWS.md b/NEWS.md index 03c7693..b95a1bc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,11 @@ # nat.python 0.2.0.9000 (development version) +* `simple_python()` now pins the managed environment's Python interpreter to a + known-good version (new `python_version` argument, default `"3.12"`, overridable + via `options(nat.python.python_version=)`) instead of letting reticulate pick, + which on a fresh install can be a bleeding-edge Python that key packages do not + yet support. An existing environment at a different version is kept, with a + warning pointing at `simple_python("cleanenv")`. * `pandas2df()` now converts pandas extension-array columns that reticulate leaves unconverted, in particular pandas 3.0's default Arrow-backed string dtype (PDEP-14): string columns become R character vectors and other Arrow diff --git a/R/env.R b/R/env.R index 9177de9..dd3aab6 100644 --- a/R/env.R +++ b/R/env.R @@ -11,6 +11,45 @@ # with options(nat.python.condaenv = ...). np_condaenv <- function() getOption("nat.python.condaenv", "r-reticulate") +# Resolve the Python version to pin for the managed miniconda environment. +# Precedence: explicit `python_version` arg > options(nat.python.python_version) +# > a pre-set RETICULATE_MINICONDA_PYTHON_VERSION > the built-in default "3.12". +# NA or "" (at the arg or option level) means "do not pin -- defer to +# reticulate's own default", signalled by returning NA_character_. +resolve_python_version <- function(python_version = NULL) { + if (is.null(python_version)) + python_version <- getOption("nat.python.python_version", NULL) + if (!is.null(python_version)) { + if (length(python_version) != 1L) + cli::cli_abort("{.arg python_version} must be a single value.") + if (is.na(python_version) || !nzchar(python_version)) return(NA_character_) + return(as.character(python_version)) + } + env <- Sys.getenv("RETICULATE_MINICONDA_PYTHON_VERSION") + if (nzchar(env)) return(env) + "3.12" +} + +# Warn (rather than silently rebuild) when the managed environment's interpreter +# does not match the version we asked for -- reticulate keeps an existing env's +# Python, so a pin only takes effect on a fresh install. +warn_python_version_mismatch <- function(target) { + if (is.na(target)) return(invisible()) + cfg <- tryCatch(reticulate::py_discover_config(), error = function(e) NULL) + # $version can be a numeric_version, so coerce before string ops (nzchar + # tolerates that but startsWith does not). + have <- if (!is.null(cfg)) as.character(cfg$version) else NULL + if (length(have) != 1L || is.na(have) || !nzchar(have)) return(invisible()) + want <- sub("^([0-9]+\\.[0-9]+).*", "\\1", target) + if (!startsWith(have, want)) + cli::cli_warn(c( + "The managed Python is version {have}, not the requested {target}.", + "i" = "An existing environment keeps its interpreter; to rebuild at {target}:", + " " = paste("run {.run nat.python::simple_python(\"cleanenv\")} then", + "{.run nat.python::simple_python()}."))) + invisible() +} + #' Install a managed Python environment for R #' #' @description Sets up (and optionally populates) a dedicated miniconda Python @@ -36,12 +75,25 @@ np_condaenv <- function() getOption("nat.python.condaenv", "r-reticulate") #' ([forget_module_version()]) and the [check_module()] memoise cache are #' cleared so that subsequent checks reflect the new environment. #' +#' The managed environment's Python interpreter is pinned to a known-good +#' version (`python_version`, default `"3.12"`) rather than whatever reticulate +#' would otherwise select, which on a fresh install can be a bleeding-edge +#' Python that key packages do not yet support. If an environment already +#' exists at a different version it is kept (not silently rebuilt) and a +#' warning points at `simple_python("cleanenv")`. +#' #' @param pyinstall Which package bundle to install. One of `"basic"`, `"full"`, #' `"extra"`, `"minimal"`, `"cleanenv"`, `"blast"` or `"none"`. #' @param pkgs Optional character vector of additional Python packages (pip #' specifications) to install into the environment. #' @param miniconda Whether to use the managed miniconda environment (strongly #' recommended). When `FALSE` your current Python is used as-is. +#' @param python_version Python version to pin for the managed miniconda +#' environment. `NULL` (the default) resolves to +#' `getOption("nat.python.python_version", "3.12")`, falling back to any +#' pre-set `RETICULATE_MINICONDA_PYTHON_VERSION`; pass a string like `"3.11"` +#' to override, or `NA` to not pin and defer to reticulate's own default. +#' Ignored when `miniconda = FALSE`. #' #' @return Invisibly `NULL`. Called for its side effect of provisioning Python. #' @export @@ -54,7 +106,7 @@ np_condaenv <- function() getOption("nat.python.condaenv", "r-reticulate") #' } simple_python <- function(pyinstall = c("basic", "full", "extra", "minimal", "cleanenv", "blast", "none"), - pkgs = NULL, miniconda = TRUE) { + pkgs = NULL, miniconda = TRUE, python_version = NULL) { check_reticulate(check_python = FALSE) check_python(initialize = FALSE) @@ -68,9 +120,10 @@ simple_python <- function(pyinstall = c("basic", "full", "extra", "minimal", forget_module_version() forget_check_module() }) + pyver <- resolve_python_version(python_version) pyinstall <- match.arg(pyinstall) if (pyinstall != "none") - simple_python_base(pyinstall, miniconda) + simple_python_base(pyinstall, miniconda, python_version = pyver) if (pyinstall %in% c("cleanenv", "blast")) return(invisible(NULL)) if (pyinstall %in% c("minimal", "basic", "full", "extra")) { @@ -201,7 +254,7 @@ update_miniconda_base <- function() { length(js$actions) > 0 } -simple_python_base <- function(what, miniconda) { +simple_python_base <- function(what, miniconda, python_version = NA_character_) { if (what == "cleanenv") { checkownpython(miniconda) e <- default_pyenv() @@ -236,6 +289,21 @@ simple_python_base <- function(what, miniconda) { "{.run usethis::edit_r_environ()}."), "i" = "If you are sure, use {.code simple_python(miniconda = FALSE)}.")) + # Pin the interpreter version of the managed environment. reticulate's + # install_miniconda()/conda_create() read RETICULATE_MINICONDA_PYTHON_VERSION, + # so set it for the duration of provisioning (restored on exit) rather than + # leaving reticulate to pick, which on a fresh install can be a bleeding-edge + # Python. NA means the caller asked us not to pin. + if (!is.na(python_version)) { + old <- Sys.getenv("RETICULATE_MINICONDA_PYTHON_VERSION", unset = NA) + Sys.setenv(RETICULATE_MINICONDA_PYTHON_VERSION = python_version) + on.exit(if (is.na(old)) + Sys.unsetenv("RETICULATE_MINICONDA_PYTHON_VERSION") + else Sys.setenv(RETICULATE_MINICONDA_PYTHON_VERSION = old), + add = TRUE) + cli::cli_inform("Targeting Python {python_version} for the managed environment") + } + cli::cli_inform("Installing/updating a dedicated miniconda Python environment for R") tryCatch({ reticulate::install_miniconda() @@ -248,7 +316,9 @@ simple_python_base <- function(what, miniconda) { condaenv <- np_condaenv() if (nzchar(condaenv) && condaenv != "r-reticulate") reticulate::conda_create(envname = condaenv, - conda = reticulate::miniconda_path()) + conda = reticulate::miniconda_path(), + python_version = if (!is.na(python_version)) + python_version) if (py_was_running && pychanged) { cli::cli_abort(c( "You have just updated your version of Python on disk.", @@ -258,6 +328,7 @@ simple_python_base <- function(what, miniconda) { cli::cli_inform("Ensuring pip is available in conda environment {.val {condaenv}}") reticulate::conda_install(envname = condaenv, packages = "pip") reticulate::use_miniconda(condaenv) + warn_python_version_mismatch(python_version) } else { cli::cli_inform(c( "Using the following existing Python install. I hope you know what you're doing!")) diff --git a/man/simple_python.Rd b/man/simple_python.Rd index 21fb45a..4539686 100644 --- a/man/simple_python.Rd +++ b/man/simple_python.Rd @@ -7,7 +7,8 @@ simple_python( pyinstall = c("basic", "full", "extra", "minimal", "cleanenv", "blast", "none"), pkgs = NULL, - miniconda = TRUE + miniconda = TRUE, + python_version = NULL ) } \arguments{ @@ -19,6 +20,13 @@ specifications) to install into the environment.} \item{miniconda}{Whether to use the managed miniconda environment (strongly recommended). When \code{FALSE} your current Python is used as-is.} + +\item{python_version}{Python version to pin for the managed miniconda +environment. \code{NULL} (the default) resolves to +\code{getOption("nat.python.python_version", "3.12")}, falling back to any +pre-set \code{RETICULATE_MINICONDA_PYTHON_VERSION}; pass a string like \code{"3.11"} +to override, or \code{NA} to not pin and defer to reticulate's own default. +Ignored when \code{miniconda = FALSE}.} } \value{ Invisibly \code{NULL}. Called for its side effect of provisioning Python. @@ -47,6 +55,13 @@ remove an environment; they never delete anything themselves. After any installation the cached module versions (\code{\link[=forget_module_version]{forget_module_version()}}) and the \code{\link[=check_module]{check_module()}} memoise cache are cleared so that subsequent checks reflect the new environment. + +The managed environment's Python interpreter is pinned to a known-good +version (\code{python_version}, default \code{"3.12"}) rather than whatever reticulate +would otherwise select, which on a fresh install can be a bleeding-edge +Python that key packages do not yet support. If an environment already +exists at a different version it is kept (not silently rebuilt) and a +warning points at \code{simple_python("cleanenv")}. } \examples{ \dontrun{ diff --git a/tests/testthat/test-env.R b/tests/testthat/test-env.R index a2a14f1..3ea8e8c 100644 --- a/tests/testthat/test-env.R +++ b/tests/testthat/test-env.R @@ -10,6 +10,33 @@ test_that("np_condaenv honours the option and defaults to r-reticulate", { expect_identical(np_condaenv(), "my-env") }) +test_that("resolve_python_version honours the precedence chain", { + withr::local_options(nat.python.python_version = NULL) + withr::local_envvar(RETICULATE_MINICONDA_PYTHON_VERSION = "") + # nothing set anywhere -> built-in default + expect_identical(resolve_python_version(), "3.12") + # a pre-set env var wins over the default (so CI's matrix pin is respected) + withr::local_envvar(RETICULATE_MINICONDA_PYTHON_VERSION = "3.9") + expect_identical(resolve_python_version(), "3.9") + # the option beats the env var + withr::local_options(nat.python.python_version = "3.11") + expect_identical(resolve_python_version(), "3.11") + # an explicit argument beats everything + expect_identical(resolve_python_version("3.13"), "3.13") +}) + +test_that("resolve_python_version treats NA/empty as 'do not pin'", { + withr::local_options(nat.python.python_version = NULL) + withr::local_envvar(RETICULATE_MINICONDA_PYTHON_VERSION = "3.9") + # NA at the arg level defers even when an env var is set + expect_identical(resolve_python_version(NA), NA_character_) + # "" at the option level likewise defers + withr::local_options(nat.python.python_version = "") + expect_identical(resolve_python_version(), NA_character_) + # a bad (length != 1) value errors + expect_error(resolve_python_version(c("3.11", "3.12")), "single value") +}) + test_that("ownpythonrequested reflects RETICULATE_PYTHON", { withr::local_envvar(RETICULATE_PYTHON = "") expect_false(ownpythonrequested())