Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
79 changes: 75 additions & 4 deletions R/env.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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")) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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.",
Expand All @@ -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!"))
Expand Down
17 changes: 16 additions & 1 deletion man/simple_python.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions tests/testthat/test-env.R
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading