diff --git a/DESCRIPTION b/DESCRIPTION index 33efcca8a..739316fb6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -55,6 +55,7 @@ Suggests: cli, rmarkdown, pillar, + processx, testthat LinkingTo: Rcpp RoxygenNote: 7.3.2 diff --git a/NAMESPACE b/NAMESPACE index c0f2257c7..1abeee449 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -74,6 +74,7 @@ S3method(print,python.builtin.list) S3method(print,python.builtin.module) S3method(print,python.builtin.object) S3method(print,python.builtin.tuple) +S3method(print,python_requirements) S3method(py_str,default) S3method(py_str,python.builtin.bytearray) S3method(py_str,python.builtin.dict) @@ -190,6 +191,7 @@ export(py_module_available) export(py_none) export(py_numpy_available) export(py_repr) +export(py_require) export(py_run_file) export(py_run_string) export(py_save_object) diff --git a/R/config.R b/R/config.R index e881560c3..92ed6dca8 100644 --- a/R/config.R +++ b/R/config.R @@ -325,6 +325,14 @@ py_discover_config <- function(required_module = NULL, use_environment = NULL) { else e }) + + uv_python <- uv_get_or_create_env() + if(!is.null(uv_python)) { + return( + python_config(uv_python) + ) + } + if (!inherits(python, "error")) try(return(python_config(python, required_module))) diff --git a/R/install.R b/R/install.R index d89bfca16..6308414cc 100644 --- a/R/install.R +++ b/R/install.R @@ -85,6 +85,27 @@ py_install <- function(packages, { check_forbidden_install("Python packages") + if (is_python_initialized() && + is_uv_reticulate_managed_env(py_exe()) && + is.null(envname)) { + if (!is.null(python_version)) { + stop( + "Python version requirements cannot be ", + "changed after Python has been initialized" + ) + } + warning( + "An 'uv' virtual environment managed by 'reticulate' is currently in use.\n", + "To add more packages to your current session, call `py_require()` instead\n", + "of `py_install()`. Running:\n ", + paste0( + "`py_require(", paste0(sprintf("\"%s\"", packages), collapse = ", "), ")`" + ) + ) + py_require(packages) + return(invisible()) + } + # if 'envname' was not provided, use the 'active' version of Python if (is.null(envname)) { diff --git a/R/py_require.R b/R/py_require.R new file mode 100644 index 000000000..8395a8a6a --- /dev/null +++ b/R/py_require.R @@ -0,0 +1,542 @@ +#' Declare Python requirements +#' +#' It allows you to specify the Python packages, and their versions, to use +#' during your working session. It also allows to specify Python version +#' requirements. It uses [uv](https://docs.astral.sh/uv/) to automatically +#' resolves multiple version requirements of the same package (e.g.: +#' 'numpy>=2.2.0', numpy==2.2.2'), as well as resolve multiple Python version +#' requirements (e.g.: '>=3.10', '3.11'). `uv` will automatically download and +#' install the resulting Python version and packages, so there is no need to +#' take any steps prior to starting the Python session. +#' +#' +#' The virtual environment will not be initialized until the users attempts to +#' interacts with Python for the first time during the session. Typically, +#' that would be the first time `import()` is called. +#' +#' If `uv` is not installed, `reticulate` will attempt to download and install +#' a version of it in an isolated folder. This will allow you to get the +#' advantages of `uv`, without modifying your computer's environment. +#' +#' +#' @param packages A vector of Python packages to make available during the +#' working session. +#' +#' @param python_version A vector of one, or multiple, Python versions to +#' consider. `uv` will not be able to process conflicting Python versions +#' (e.g.: '>=3.11', '3.10'). +#' +#' @param action What `py_require()` should do with the packages and Python +#' version provided during the given command call. There are three options: +#' - add - Adds the requirement to the list +#' - remove - Removes the requirement form the list. It has to be an exact match +#' to an existing requirement. For example, if 'numpy==2.2.2' is currently on +#' the list, passing 'numpy' with a 'remove' action will affect the list. +#' - set - Deletes any requirement already defined, and replaces them with what +#' is provided in the command call. Packages and Python version can be +#' independently set. +#' +#' @param exclude_newer Leverages a feature from `uv` that allows you to limit +#' the candidate package versions to those that were uploaded prior to a given +#' date. During the working session, the date can be "added" only one time. +#' After the first time the argument is used, only the 'set' `action` can +#' override the date afterwards. +#' +#' @export +py_require <- function(packages = NULL, + python_version = NULL, + exclude_newer = NULL, + action = c("add", "remove", "set")) { + action <- match.arg(action) + env_is_package <- isNamespace(topenv(parent.frame())) + uv_initialized <- is_python_initialized() && + is_uv_reticulate_managed_env(py_exe()) + + if (uv_initialized && !is.null(python_version)) { + stop( + "Python version requirements cannot be ", + "changed after Python has been initialized" + ) + } + + if (!is.null(exclude_newer)) { + if (env_is_package) { + stop("`exclude_newer` cannot be set inside a package") + } + if (action != "set" && !is.null(py_reqs_get("exclude_newer"))) { + stop( + "`exclude_newer` is already set to '", + py_reqs_get("exclude_newer"), + "', use `action` 'set' to override" + ) + } + } + + if (missing(packages) && missing(python_version) && missing(exclude_newer)) { + return(py_reqs_get()) + } + + pr <- py_reqs_get() + pr$packages <- py_reqs_action(action, packages, py_reqs_get("packages")) + pr$python_version <- py_reqs_action(action, python_version, py_reqs_get("python_version")) + pr$exclude_newer <- pr$exclude_newer %||% exclude_newer + pr$history <- c(pr$history, list(list( + requested_from = environmentName(topenv(parent.frame())), + env_is_package = env_is_package, + packages = packages, + python_version = python_version, + exclude_newer = exclude_newer, + action = action + ))) + .globals$python_requirements <- pr + + if (uv_initialized) { + new_path <- uv_get_or_create_env() + new_config <- python_config(new_path) + if (new_config$libpython == .globals$py_config$libpython) { + py_activate_virtualenv(file.path(dirname(new_path), "activate_this.py")) + .globals$py_config <- new_config + .globals$py_config$available <- TRUE + } else { + # TODO: Better error message? + stop("New environment does not use the same Python binary") + } + } + + invisible() +} + +#' @export +print.python_requirements <- function(x, ...) { + packages <- x$packages + if (is.null(packages)) { + packages <- "[No package(s) specified]" + } + python_version <- x$python_version + if (is.null(python_version)) { + python_version <- "[No Python version specified]" + } + + requested_from <- as.character(lapply(x$history, function(x) x$requested_from)) + history <- x$history[requested_from != "R_GlobalEnv"] + is_package <- as.logical(lapply(history, function(x) x$env_is_package)) + + if (uv_will_use_processx()) { + withr::with_options( + list("cli.width" = 73), + { + cli::cli_div( + theme = list(rule = list(color = "cyan", "line-type" = "double")) + ) + cli::cli_rule(center = "Python requirements") + cli::cli_div( + theme = list(rule = list("line-type" = "single")) + ) + cli::cli_rule("Current requirements") + cat(py_reqs_print( + packages = packages, + python_version = python_version, + exclude_newer = x$exclude_newer, + use_cli = TRUE + )) + cat("\n") + if (any(is_package)) { + cli::cli_rule("R package requests") + py_reqs_table(history[is_package], "R package", use_cli = TRUE) + } + if (any(!is_package)) { + cli::cli_rule("Environment requests") + py_reqs_table(history[!is_package], "R package", use_cli = TRUE) + } + } + ) + } else { + cat(paste0(rep("=", 26), collapse = "")) + cat(" Python requirements ") + cat(paste0(rep("=", 26), collapse = ""), "\n") + cat(py_reqs_print( + packages = packages, + python_version = python_version, + exclude_newer = x$exclude_newer + )) + cat("\n") + if (any(is_package)) { + cat("-- R package requests ") + cat(paste0(rep("-", 51), collapse = ""), "\n") + py_reqs_table(history[is_package], "R package") + } + if (any(!is_package)) { + cat("-- Environment requests ") + cat(paste0(rep("-", 49), collapse = ""), "\n") + py_reqs_table(history[!is_package], "R package") + } + } + invisible() +} + +# Python requirements - utils -------------------------------------------------- + +py_reqs_pad <- function(x = "", len, use_cli, is_title = FALSE) { + padding <- paste0(rep(" ", len - nchar(x)), collapse = "") + ret <- paste0(x, padding) + if (use_cli) { + if (is_title) { + ret <- cli::col_blue(ret) + } else { + ret <- cli::col_grey(ret) + } + } + ret +} + +py_reqs_table <- function(history, from_label, use_cli = FALSE) { + console_width <- 73 + python_width <- 20 + requested_from <- as.character(lapply(history, function(x) x$requested_from)) + pkg_names <- c(unique(requested_from), from_label) + name_width <- max(nchar(pkg_names)) + 1 + pkg_width <- console_width - python_width - name_width + header <- list(list( + requested_from = from_label, + packages = "Python packages", + python_version = "Python version", + is_title = 1 + )) + history <- lapply(unique(requested_from), py_reqs_flatten, history) + history <- c(header, history) + for (pkg_entry in history) { + pkg_lines <- strwrap( + x = paste0(pkg_entry$packages, collapse = ", "), + width = pkg_width + ) + python_lines <- strwrap( + x = paste0(pkg_entry$python_version, collapse = ", "), + width = python_width + ) + max_lines <- max(c(length(python_lines), length(pkg_lines))) + for (i in seq_len(max_lines)) { + nm <- ifelse(i == 1, pkg_entry$requested_from, "") + pk <- ifelse(i <= length(pkg_lines), pkg_lines[i], "") + py <- ifelse(i <= length(python_lines), python_lines[i], "") + cat(py_reqs_pad(nm, name_width, use_cli, !is.null(pkg_entry$is_title))) + cat(py_reqs_pad(pk, pkg_width, use_cli, !is.null(pkg_entry$is_title))) + cat(py_reqs_pad(py, python_width, use_cli, !is.null(pkg_entry$is_title))) + cat("\n") + } + } +} + +py_reqs_action <- function(action, x, y = NULL) { + if (is.null(x)) { + return(y) + } + switch(action, + add = unique(c(y, x)), + remove = setdiff(y, x), + set = x + ) +} + +py_reqs_flatten <- function(r_pkg = "", history) { + req_packages <- NULL + req_python <- NULL + for (entry in history) { + if (entry$requested_from == r_pkg | r_pkg == "") { + req_packages <- py_reqs_action(entry$action, entry$packages, req_packages) + req_python <- py_reqs_action(entry$action, entry$python_version, req_python) + } + } + list( + requested_from = r_pkg, + packages = req_packages, + python_version = req_python + ) +} + +py_reqs_print <- function(packages = NULL, + python_version = NULL, + exclude_newer = NULL, + use_cli = FALSE) { + msg <- c( + if (!use_cli) { + paste0( + "-- Current requirements ", paste0(rep("-", 49), collapse = ""), + collapse = "" + ) + }, + if (!is.null(python_version)) { + python <- ifelse(use_cli, cli::col_blue("Python:"), "Python:") + python_version <- paste0(python_version, collapse = ", ") + python_version <- ifelse(use_cli, cli::col_grey(python_version), python_version) + paste0(" ", python, " ", python_version) + }, + if (!is.null(packages)) { + pkg_lines <- strwrap(paste0(packages, collapse = ", "), 60) + pkgs <- "Packages:" + if (use_cli) { + pkgs <- cli::col_blue(pkgs) + pkg_lines <- as.character(lapply(pkg_lines, cli::col_grey)) + } + pkg_col <- c(paste0(" ", pkgs, " "), rep(" ", length(pkg_lines) - 1)) + out <- NULL + for (i in seq_along(pkg_lines)) { + out <- c(out, paste0(pkg_col[[i]], pkg_lines[[i]])) + } + out + }, + if (!is.null(exclude_newer)) { + exclude <- ifelse(use_cli, cli::col_blue("Exclude:"), "Exclude:") + exclude_newer <- paste0(" Anything newer than ", exclude_newer) + exclude_newer <- ifelse(use_cli, cli::col_grey(exclude_newer), exclude_newer) + paste0(" ", exclude, exclude_newer) + } + ) + paste0(msg, collapse = "\n") +} + +py_reqs_get <- function(x = NULL) { + pr <- .globals$python_requirements + if (is.null(pr)) { + pr <- structure( + .Data = list( + python_version = c(), + packages = c(), + exclude_newer = NULL, + history = list() + ), + class = "python_requirements" + ) + pkg_prime <- "numpy" + pr$packages <- pkg_prime + pr$history <- list(list( + requested_from = "reticulate", + env_is_package = TRUE, + action = "add", + packages = pkg_prime + )) + .globals$python_requirements <- pr + } + if (!is.null(x)) { + pr[[x]] + } else { + pr + } +} + +# uv --------------------------------------------------------------------------- + +uv_binary <- function() { + uv <- Sys.getenv("RETICULATE_UV", NA) + if (!is.na(uv)) { + return(path.expand(uv)) + } + + uv <- getOption("reticulate.uv_binary") + if (!is.null(uv)) { + return(path.expand(uv)) + } + + uv <- as.character(Sys.which("uv")) + if (uv != "") { + return(path.expand(uv)) + } + + uv <- path.expand("~/.local/bin/uv") + if (file.exists(uv)) { + return(path.expand(uv)) + } + + uv <- file.path(rappdirs::user_cache_dir("r-reticulate", NULL), "bin", "uv") + uv_file <- ifelse(is_windows(), paste0(uv, ".exe"), uv) + if (file.exists(uv_file)) { + return(path.expand(uv)) + } + + # Installing 'uv' in the 'r-reticulate' sub-folder inside the user's + # cache directory + # https://github.com/astral-sh/uv/blob/main/docs/configuration/installer.md + file_ext <- ifelse(is_windows(), ".ps1", ".sh") + target_url <- paste0("https://astral.sh/uv/install", file_ext) + install_uv <- tempfile("install-uv-", fileext = file_ext) + res <- tryCatch( + download.file(target_url, install_uv, quiet = TRUE), + warning = function(x) NULL, + error = function(x) NULL + ) + if (is.null(res)) { + return(NULL) + } + if (is_windows()) { + system2( + command = "powershell", + args = c( + "-ExecutionPolicy", + "ByPass", + "-c", + paste0( + "$env:UV_INSTALL_DIR='", dirname(uv), "';", + "$env:INSTALLER_NO_MODIFY_PATH= 1;", + # 'Out-Null' makes installation silent + "irm ", install_uv, " | iex *> Out-Null" + ) + ) + ) + } else if (is_macos() || is_linux()) { + Sys.chmod(install_uv, mode = "0755") + dir.create(dirname(uv), showWarnings = FALSE) + system2( + command = install_uv, + args = c("--quiet"), + env = c( + "INSTALLER_NO_MODIFY_PATH=1", + paste0("UV_INSTALL_DIR=", maybe_shQuote(dirname(uv))) + ) + ) + } + return(path.expand(uv)) +} + +uv_cache_dir <- function(...) { + path <- file.path(rappdirs::user_cache_dir("r-reticulate", NULL), "uv-cache", ...) + path.expand(path) +} + +uv_get_or_create_env <- function(packages = py_reqs_get("packages"), + python_version = py_reqs_get("python_version"), + exclude_newer = py_reqs_get("exclude_newer")) { + uv_binary_path <- uv_binary() + + if (is.null(uv_binary_path)) { + return(NULL) + } + + if (length(packages)) { + pkg_arg <- as.vector(rbind("--with", uv_maybe_processx(packages))) + } else { + pkg_arg <- NULL + } + + if (length(python_version)) { + has_const <- substr(python_version, 1, 1) %in% c(">", "<", "=", "!") + python_version[!has_const] <- paste0("==", python_version[!has_const]) + python_arg <- c("--python", paste0(uv_maybe_processx(python_version), collapse = ",")) + } else { + python_arg <- NULL + } + + if (!is.null(exclude_newer)) { + # todo, accept a POSIXct/lt, format correctly + exclude_arg <- c("--exclude-newer", maybe_shQuote(exclude_newer)) + } else { + exclude_arg <- NULL + } + + command_arg <- "import sys; print(sys.executable);" + if (!uv_will_use_processx()) { + command_arg <- maybe_shQuote(command_arg) + } + + args <- c( + "run", + "--color", "never", + "--no-project", + "--cache-dir", uv_cache_dir(), + "--python-preference=only-managed", + exclude_arg, + python_arg, + pkg_arg, + "python", "-c", command_arg + ) + + if (uv_will_use_processx()) { + on.exit( + try(p$kill(), silent = TRUE), + add = TRUE + ) + p <- processx::process$new( + command = uv_binary_path, + args = args, + stderr = "|", + stdout = "|" + ) + sp <- cli::make_spinner(template = "Downloading Python dependencies {spin}") + repeat { + pr <- p$poll_io(100) + if (all(as.vector(pr[c("error", "output")]) == "ready")) break + sp$spin() + } + sp$finish() + cmd_err <- p$read_error() + cmd_out <- p$read_output() + cmd_failed <- identical(cmd_out, "") + # This extra check is needed for Windows machines + # p$read_error may come back empty, so using p$read_all_error + # ensures forces the extraction. Running it as as the default + # will make the process run slower on successful runs, which is + # not ideal + if (trimws(cmd_err) == "") { + cmd_err <- p$read_all_error() + } + } else { + result <- suppressWarnings(system2( + command = uv_binary(), + args = args, + stderr = TRUE, + stdout = TRUE + )) + cmd_failed <- !is.null(attributes(result)) + if (cmd_failed) { + cmd_err <- paste0(result, collapse = "\n") + } else { + cmd_err <- paste0(result[[1]], "\n") + cmd_out <- result[[2]] + } + } + + if (cmd_failed) { + writeLines(cmd_err, con = stderr()) + msg <- py_reqs_print( + packages = packages, + python_version = python_version, + exclude_newer = exclude_newer + ) + msg <- c(msg, paste0(rep("-", 73), collapse = "")) + writeLines(msg, con = stderr()) + stop( + "Call `py_require()` to remove or replace conflicting requirements.", + call. = FALSE + ) + } + cat(cmd_err) + if (substr(cmd_out, nchar(cmd_out), nchar(cmd_out)) == "\n") { + cmd_out <- substr(cmd_out, 1, nchar(cmd_out) - 1) + } + if (substr(cmd_out, nchar(cmd_out), nchar(cmd_out)) == "\r") { + cmd_out <- substr(cmd_out, 1, nchar(cmd_out) - 1) + } + cmd_out +} + +# uv - utils ------------------------------------------------------------------- + +uv_will_use_processx <- function() { + interactive() && ! + isatty(stderr()) && + (is_rstudio() || is_positron()) && + requireNamespace("cli", quietly = TRUE) && + requireNamespace("processx", quietly = TRUE) +} + +uv_maybe_processx <- function(x) { + if (!uv_will_use_processx()) { + x <- maybe_shQuote(x) + } + x +} + +is_uv_reticulate_managed_env <- function(dir) { + str_cache <- as.character(uv_cache_dir()) + str_path <- as.character(dir) + sub_path <- substr(str_path, 1, nchar(str_cache)) + str_cache == sub_path +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 5dc4be412..8d2afcc74 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -102,3 +102,7 @@ reference: - miniconda_uninstall - miniconda_path - miniconda_update + + - title: "Python Requirements" + contents: + - py_require diff --git a/man/py_require.Rd b/man/py_require.Rd new file mode 100644 index 000000000..a4d39d6eb --- /dev/null +++ b/man/py_require.Rd @@ -0,0 +1,58 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/py_require.R +\name{py_require} +\alias{py_require} +\title{Declare Python requirements} +\usage{ +py_require( + packages = NULL, + python_version = NULL, + exclude_newer = NULL, + action = c("add", "remove", "set") +) +} +\arguments{ +\item{packages}{A vector of Python packages to make available during the +working session.} + +\item{python_version}{A vector of one, or multiple, Python versions to +consider. \code{uv} will not be able to process conflicting Python versions +(e.g.: '>=3.11', '3.10').} + +\item{exclude_newer}{Leverages a feature from \code{uv} that allows you to limit +the candidate package versions to those that were uploaded prior to a given +date. During the working session, the date can be "added" only one time. +After the first time the argument is used, only the 'set' \code{action} can +override the date afterwards.} + +\item{action}{What \code{py_require()} should do with the packages and Python +version provided during the given command call. There are three options: +\itemize{ +\item add - Adds the requirement to the list +\item remove - Removes the requirement form the list. It has to be an exact match +to an existing requirement. For example, if 'numpy==2.2.2' is currently on +the list, passing 'numpy' with a 'remove' action will affect the list. +\item set - Deletes any requirement already defined, and replaces them with what +is provided in the command call. Packages and Python version can be +independently set. +}} +} +\description{ +It allows you to specify the Python packages, and their versions, to use +during your working session. It also allows to specify Python version +requirements. It uses \href{https://docs.astral.sh/uv/}{uv} to automatically +resolves multiple version requirements of the same package (e.g.: +'numpy>=2.2.0', numpy==2.2.2'), as well as resolve multiple Python version +requirements (e.g.: '>=3.10', '3.11'). \code{uv} will automatically download and +install the resulting Python version and packages, so there is no need to +take any steps prior to starting the Python session. +} +\details{ +The virtual environment will not be initialized until the users attempts to +interacts with Python for the first time during the session. Typically, +that would be the first time \code{import()} is called. + +If \code{uv} is not installed, \code{reticulate} will attempt to download and install +a version of it in an isolated folder. This will allow you to get the +advantages of \code{uv}, without modifying your computer's environment. +} diff --git a/src/python.cpp b/src/python.cpp index b0a2278ab..b8030eb5b 100644 --- a/src/python.cpp +++ b/src/python.cpp @@ -2878,7 +2878,8 @@ extern "C" PyObject* initializeRPYCall(void) { // [[Rcpp::export]] void py_activate_virtualenv(const std::string& script) { - + GILScope _gil; + // import runpy PyObjectPtr runpy_module(PyImport_ImportModule("runpy")); if (runpy_module.is_null()) diff --git a/tests/testthat/_snaps/py_require.md b/tests/testthat/_snaps/py_require.md new file mode 100644 index 000000000..827623b7e --- /dev/null +++ b/tests/testthat/_snaps/py_require.md @@ -0,0 +1,249 @@ +# Error requesting conflicting package versions + + Code + r_session(attach_namespace = TRUE, { + py_require("numpy<2") + py_require("numpy>=2") + uv_get_or_create_env() + }) + Output + > py_require("numpy<2") + > py_require("numpy>=2") + > uv_get_or_create_env() + × No solution found when resolving `--with` dependencies: + ╰─▶ Because you require numpy<2 and numpy>=2, we can conclude that your + requirements are unsatisfiable. + -- Current requirements ------------------------------------------------- + Packages: numpy, numpy<2, numpy>=2 + ------------------------------------------------------------------------- + Error: Call `py_require()` to remove or replace conflicting requirements. + Execution halted + ------- session end ------- + success: false + exit_code: 1 + +# Error requesting newer package version against an older snapshot + + Code + r_session(attach_namespace = TRUE, { + py_require("tensorflow==2.18.*") + py_require(exclude_newer = "2024-10-20") + uv_get_or_create_env() + }) + Output + > py_require("tensorflow==2.18.*") + > py_require(exclude_newer = "2024-10-20") + > uv_get_or_create_env() + × No solution found when resolving `--with` dependencies: + ╰─▶ Because only tensorflow<2.18.dev0 is available and you require + tensorflow>=2.18.dev0, we can conclude that your requirements are + unsatisfiable. + + hint: `tensorflow` was requested with a pre-release marker (e.g., + tensorflow>=2.18.dev0), but pre-releases weren't enabled (try: + `--prerelease=allow`) + -- Current requirements ------------------------------------------------- + Packages: numpy, tensorflow==2.18.* + Exclude: Anything newer than 2024-10-20 + ------------------------------------------------------------------------- + Error: Call `py_require()` to remove or replace conflicting requirements. + Execution halted + ------- session end ------- + success: false + exit_code: 1 + +# Error requesting a package that does not exists + + Code + r_session(attach_namespace = TRUE, { + py_require(c("pandas", "numpy", "notexists")) + uv_get_or_create_env() + }) + Output + > py_require(c("pandas", "numpy", "notexists")) + > uv_get_or_create_env() + × No solution found when resolving `--with` dependencies: + ╰─▶ Because notexists was not found in the package registry and you require + notexists, we can conclude that your requirements are unsatisfiable. + -- Current requirements ------------------------------------------------- + Packages: numpy, pandas, notexists + ------------------------------------------------------------------------- + Error: Call `py_require()` to remove or replace conflicting requirements. + Execution halted + ------- session end ------- + success: false + exit_code: 1 + +# Error requesting conflicting Python versions + + Code + r_session(attach_namespace = TRUE, { + py_require(python_version = ">=3.10") + py_require(python_version = "<3.10") + uv_get_or_create_env() + }) + Output + > py_require(python_version = ">=3.10") + > py_require(python_version = "<3.10") + > uv_get_or_create_env() + error: No interpreter found for Python >=3.10, <3.10 in virtual environments or managed installations + -- Current requirements ------------------------------------------------- + Python: >=3.10, <3.10 + Packages: numpy + ------------------------------------------------------------------------- + Error: Call `py_require()` to remove or replace conflicting requirements. + Execution halted + ------- session end ------- + success: false + exit_code: 1 + +# Simple tests + + Code + r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require() + }) + Output + > py_require("pandas") + > py_require("numpy==2") + > py_require() + ========================== Python requirements ========================== + -- Current requirements ------------------------------------------------- + Python: [No Python version specified] + Packages: numpy, pandas, numpy==2 + -- R package requests --------------------------------------------------- + R package Python packages Python version + reticulate numpy + > + ------- session end ------- + success: true + exit_code: 0 + +--- + + Code + r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require() + }) + Output + > py_require("pandas") + > py_require("numpy==2") + > py_require("numpy==2", action = "remove") + > py_require() + ========================== Python requirements ========================== + -- Current requirements ------------------------------------------------- + Python: [No Python version specified] + Packages: numpy, pandas + -- R package requests --------------------------------------------------- + R package Python packages Python version + reticulate numpy + > + ------- session end ------- + success: true + exit_code: 0 + +--- + + Code + r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require(exclude_newer = "1990-01-01") + py_require() + }) + Output + > py_require("pandas") + > py_require("numpy==2") + > py_require("numpy==2", action = "remove") + > py_require(exclude_newer = "1990-01-01") + > py_require() + ========================== Python requirements ========================== + -- Current requirements ------------------------------------------------- + Python: [No Python version specified] + Packages: numpy, pandas + Exclude: Anything newer than 1990-01-01 + -- R package requests --------------------------------------------------- + R package Python packages Python version + reticulate numpy + > + ------- session end ------- + success: true + exit_code: 0 + +--- + + Code + r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require(exclude_newer = "1990-01-01") + py_require(python_version = c("3.11", ">=3.10")) + py_require() + }) + Output + > py_require("pandas") + > py_require("numpy==2") + > py_require("numpy==2", action = "remove") + > py_require(exclude_newer = "1990-01-01") + > py_require(python_version = c("3.11", ">=3.10")) + > py_require() + ========================== Python requirements ========================== + -- Current requirements ------------------------------------------------- + Python: 3.11, >=3.10 + Packages: numpy, pandas + Exclude: Anything newer than 1990-01-01 + -- R package requests --------------------------------------------------- + R package Python packages Python version + reticulate numpy + > + ------- session end ------- + success: true + exit_code: 0 + +# Multiple py_require() calls from package are shows in one row + + Code + r_session(attach_namespace = TRUE, { + gr_package <- (function() { + py_require(paste0("package", 1:20)) + py_require(paste0("package", 1:10), action = "remove") + py_require(python_version = c("3.11", ">=3.10")) + }) + environment(gr_package) <- asNamespace("graphics") + gr_package() + py_require() + }) + Output + > gr_package <- (function() { + + py_require(paste0("package", 1:20)) + + py_require(paste0("package", 1:10), action = "remove") + + py_require(python_version = c("3.11", ">=3.10")) + + }) + > environment(gr_package) <- asNamespace("graphics") + > gr_package() + > py_require() + ========================== Python requirements ========================== + -- Current requirements ------------------------------------------------- + Python: 3.11, >=3.10 + Packages: numpy, package11, package12, package13, package14, + package15, package16, package17, package18, package19, + package20 + -- R package requests --------------------------------------------------- + R package Python packages Python version + reticulate numpy + graphics package11, package12, package13, 3.11, >=3.10 + package14, package15, package16, + package17, package18, package19, + package20 + > + ------- session end ------- + success: true + exit_code: 0 + diff --git a/tests/testthat/helper-py-require.R b/tests/testthat/helper-py-require.R new file mode 100644 index 000000000..11d5fcf13 --- /dev/null +++ b/tests/testthat/helper-py-require.R @@ -0,0 +1,46 @@ +test_py_require_reset <- function() { + .globals$python_requirements <- NULL +} + +r_session <- function(exprs, echo = TRUE, color = FALSE, + attach_namespace = FALSE) { + exprs <- substitute(exprs) + if (!is.call(exprs)) + stop("exprs must be a call") + + exprs <- if (identical(exprs[[1]], quote(`{`))) + as.list(exprs)[-1] + else + list(exprs) + + exprs <- unlist(c( + if (attach_namespace) + 'attach(asNamespace("reticulate"), name = "namespace:reticulate", warn.conflicts = FALSE)', + if (echo) + "options(echo = TRUE)", + lapply(exprs, deparse) + )) + + writeLines(exprs, file <- tempfile(fileext = ".R")) + on.exit(unlink(file), add = TRUE) + + result <- suppressWarnings(system2( + R.home("bin/R"), + c("--quiet", "--no-save", "--no-restore", "--no-echo", "-f", file), + stdout = TRUE, stderr = TRUE, + env = c(character(), if (isFALSE(color)) "NO_COLOR=1") + )) + class(result) <- "r_session_record" + result +} + +print.r_session_record <- function(record, echo = TRUE) { + writeLines(record) + status <- attr(record, "status", TRUE) + cat(sep = "", + "------- session end -------\n", + "success: ", if (is.null(status)) "true" else "false", "\n", + "exit_code: ", status %||% 0L, "\n") +} +registerS3method("print", "r_session_record", print.r_session_record, + envir = environment(print)) diff --git a/tests/testthat/test-py_require.R b/tests/testthat/test-py_require.R new file mode 100644 index 000000000..509763518 --- /dev/null +++ b/tests/testthat/test-py_require.R @@ -0,0 +1,91 @@ +test_that("Error requesting conflicting package versions", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("numpy<2") + py_require("numpy>=2") + uv_get_or_create_env() + })) +}) + +test_that("Error requesting newer package version against an older snapshot", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("tensorflow==2.18.*") + py_require(exclude_newer = "2024-10-20") + uv_get_or_create_env() + })) +}) + +test_that("Error requesting a package that does not exists", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require(c("pandas", "numpy", "notexists")) + uv_get_or_create_env() + })) +}) + +test_that("Error requesting conflicting Python versions", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require(python_version = ">=3.10") + py_require(python_version = "<3.10") + uv_get_or_create_env() + })) +}) + +test_that("Simple tests", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require() + })) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require() + })) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require(exclude_newer = "1990-01-01") + py_require() + })) + expect_snapshot(r_session(attach_namespace = TRUE, { + py_require("pandas") + py_require("numpy==2") + py_require("numpy==2", action = "remove") + py_require(exclude_newer = "1990-01-01") + py_require(python_version = c("3.11", ">=3.10")) + py_require() + })) +}) + +test_that("uv cache testing", { + local_edition(3) + test_py_require_reset() + uv_exec <- ifelse(is_windows(), "uv.exe", "uv") + target_path <- path.expand( + file.path(rappdirs::user_cache_dir("r-reticulate", NULL), "bin", uv_exec) + ) + expect_equal( + normalizePath(target_path), + normalizePath(uv_binary()) + ) +}) + +test_that("Multiple py_require() calls from package are shows in one row", { + local_edition(3) + expect_snapshot(r_session(attach_namespace = TRUE, { + gr_package <- function() { + py_require(paste0("package", 1:20)) + py_require(paste0("package", 1:10), action = "remove") + py_require(python_version = c("3.11", ">=3.10")) + } + environment(gr_package) <- asNamespace("graphics") + gr_package() + py_require() + })) +}) diff --git a/vignettes/versions.Rmd b/vignettes/versions.Rmd index 1b04e2984..dc9cca008 100644 --- a/vignettes/versions.Rmd +++ b/vignettes/versions.Rmd @@ -95,7 +95,13 @@ The order in which Python installation will be discovered and used is as follows 12. In the absence of any expression of preference via one of the ways outlined above, reticulate falls back to using a virtual environment named `"r-reticulate"`. If one does not exist, reticulate will offer to create one. -13. If the "r-reticulate" environment is not available and cannot be created, then we fall back to using the Python on the `PATH`, +13. reticulate will attempt to use [uv](https://docs.astral.sh/uv/) to setup the Python environment. If reticulate does not find an existing +'uv' installation, it will attempt to download and install in a safe location in your computer. By safe, we mean that the installation will +not modify any system level settings. If the installation is successful, or 'uv' is already present, reticulate will pass the Python +requirements (Python packages and Python version) so that 'uv' can find or create a suitable virtual environment that matches those +requirements. + +14. If the "r-reticulate" environment is not available and cannot be created, then we fall back to using the Python on the `PATH`, or on Windows, the Python referenced by the registry. If both `python` and `python3` are on the `PATH`, then reticulate will prefer `python3`, unless only `python` has NumPy installed, or `python3` is built for a different architecture than R (e.g., x86).