Skip to content

Commit 7bdbe7f

Browse files
authored
Merge pull request #1850 from rstudio/check-env
Check env for py_require()d packages
2 parents 70034bd + f267ab5 commit 7bdbe7f

File tree

7 files changed

+176
-3
lines changed

7 files changed

+176
-3
lines changed

.github/workflows/R-CMD-check.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,16 @@ jobs:
9595
paste0("RETICULATE_PYTHON=", virtualenv_python(path_to_venv)),
9696
Sys.getenv("GITHUB_ENV"))
9797
98+
# - name: Setup tmate session
99+
# uses: mxschmitt/action-tmate@v3
100+
101+
# - name: debug
102+
# shell: Rscript {0}
103+
# run: |
104+
# Sys.setenv("_RETICULATE_DEBUG_UV_" = "1")
105+
# print(reticulate:::uv_binary(FALSE))
106+
# print(reticulate:::uv_binary())
107+
# list.files(reticulate:::reticulate_cache_dir(), recursive = TRUE, full.names = TRUE)
108+
# print(reticulate:::uv_get_or_create_env())
109+
98110
- uses: r-lib/actions/check-r-package@v2

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
`repl_python()`. Added support for assigning `%system` command output to multiple
4040
variables via unpacking (#1844).
4141

42+
- reticulate now warns when `py_require()`d packages are not found in the selected
43+
Python virtual environment. This behavior can be disabled by setting the environment variable `RETICULATE_CHECK_REQUIRED_PACKAGES=0` (#1850).
44+
4245
# reticulate 1.43.0
4346

4447
- Fixed usage of micromamba and mamba, next-generation conda environment management tools.

R/config.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ py_config_error_message <- function(prefix) {
8787
config <- py_config()
8888
if (!is.null(config)) {
8989
message <- paste0(message, "\n\nDetected Python configuration:\n\n",
90-
str(config), "\n")
90+
format(config), "\n")
9191
}
9292
message
9393
}

R/package.R

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,10 +291,126 @@ initialize_python <- function(required_module = NULL, use_environment = NULL) {
291291
}
292292
}
293293

294+
if (nzchar(config$virtualenv)) {
295+
check_required_packages <- Sys.getenv("RETICULATE_CHECK_REQUIRED_PACKAGES", "true")
296+
check_required_packages <- tolower(check_required_packages) %in% c("true", "1", "yes")
297+
if (check_required_packages) {
298+
tryCatch(check_virtualenv_required_packages(config), error = function(e) {
299+
# ignore errors, this should never block initialization
300+
})
301+
}
302+
}
303+
294304
# return config
295305
config
296306
}
297307

308+
#' @returns Invisibly returns TRUE if all required packages are installed.
309+
#' It returns `NULL` if it is unable to perform the check.
310+
#' Returns `FALSE` if required packages are missing, but also issues warnings
311+
#' containing more details about the missing packages.
312+
check_virtualenv_required_packages <- function(config) {
313+
314+
# TODO: also check py_require()$python_version.
315+
# TODO: also warn if py_require() is called after reticulate has initialize Python.
316+
317+
# we don't install uv if it's not yet installed
318+
uv <- uv_binary(bootstrap_install = FALSE)
319+
if (is.null(uv)) {
320+
return(invisible(NULL))
321+
}
322+
# force uv to use the selected python binary.
323+
# in principle this could be ommited assuming that VIRTUAL_ENV is set
324+
325+
packages <- py_require()$packages
326+
if (length(packages) == 0) {
327+
return(invisible(TRUE))
328+
}
329+
330+
args <- c("pip install --dry-run --no-deps",
331+
"--color never --no-progress",
332+
"--no-build",
333+
# "--verbose",
334+
# "--offline", # we don't set offline because it blocks env resolution
335+
"--no-config",
336+
"--python", maybe_shQuote(config$python),
337+
maybe_shQuote(packages))
338+
339+
suppressWarnings({
340+
pip_output <- uv_exec(args, stdout = TRUE, stderr = TRUE)
341+
})
342+
343+
status <- attr(pip_output, "status") %||% 0L
344+
if (status != 0L) {
345+
# check failed.
346+
# there are a few possible reasons:
347+
# 1) a required package is not available in the index.
348+
# No solution found when resolving dependencies:
349+
# ╰─▶ Because <pkg> was not found in the package registry and you require <pkg>,
350+
# we can conclude that your requirements are unsatisfiable.
351+
# 2) Any failure to access the index (timeouts, network issues, etc)
352+
# same error as above. Since there's no way to differentiate these cases,
353+
# we just return NULL.
354+
return(invisible(NULL))
355+
}
356+
357+
# we don't want this to trigger package downloads. we forward
358+
# downloading lines to warnings so that we can detect if a download
359+
# happened on CI.
360+
# In case uv downloads a package with the --no-progress option it would show something
361+
# like:
362+
# Resolved 3 packages in 2ms
363+
# Downloading numba (2.6MiB) <-
364+
# Downloading numba <-
365+
# Prepared 1 package in 579ms
366+
# Uninstalled 2 packages in 61ms
367+
# Installed 2 packages in 11ms
368+
# - llvmlite==0.43.0
369+
# + llvmlite==0.44.0
370+
# - numba==0.60.0
371+
# + numba==0.61.0
372+
if (any(grepl("downloading", pip_output, ignore.case = TRUE))) {
373+
warning(paste(
374+
pip_output[grepl("downloading", pip_output, ignore.case = TRUE)],
375+
collapse = "\n"
376+
))
377+
}
378+
379+
# uv pip doesn't have an option to produce structured output,
380+
# the best we can do is parse using a regex.
381+
# the output contains lines like:
382+
# > Would install <n> packages
383+
# we use that to determine if any packages would be installed
384+
pattern <- "Would install (\\d+) package(s)?"
385+
would_install <- any(grepl(pattern, pip_output))
386+
387+
if (would_install) {
388+
# subset the py_require()$packages vector to keep only those that
389+
# match output from `uv pip install`.
390+
391+
# keep lines that list package versions like " + docutils==1.2.3"
392+
would_install_pkgs <- grep("==", pip_output, fixed = TRUE, value = TRUE)
393+
394+
# extract just the package name like "docutils"
395+
would_install_pkgs <- sub("(...)([^=]+)(==.*)", "\\2", would_install_pkgs)
396+
397+
# subset packages to keep only entries found in `uv pip` output.
398+
would_install_pkgs <- packages[
399+
vapply(packages, function(pkg) any(startsWith(pkg, would_install_pkgs)), TRUE,
400+
USE.NAMES = FALSE)
401+
]
402+
warning(
403+
"Some Python package requirements declared via `py_require()` are not",
404+
" installed in the selected Python environment: (", config$python, ")\n",
405+
" ", paste0(would_install_pkgs, collapse=" "),
406+
call. = FALSE
407+
)
408+
return(invisible(FALSE))
409+
}
410+
411+
invisible(TRUE)
412+
}
413+
298414
# unused presently, formerly called from initialize_python()
299415
# https://github.com/rstudio/reticulate/commit/e8c82a1f95eb97c4e5fc27b6550a4498827438e0#r122213856
300416
check_forbidden_initialization <- function() {

R/py_require.R

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,11 @@ uv_binary <- function(bootstrap_install = TRUE) {
636636
## multiple uv installations attempt to modify that config file.
637637
}
638638

639-
if (bootstrap_install) {
639+
if (!bootstrap_install) {
640+
uv <- NULL
641+
return()
642+
}
643+
640644
# Install 'uv' in the 'r-reticulate' sub-folder inside the user's cache directory
641645
# https://github.com/astral-sh/uv/blob/main/docs/configuration/installer.md
642646

@@ -679,7 +683,6 @@ uv_binary <- function(bootstrap_install = TRUE) {
679683
})
680684

681685
}
682-
}
683686

684687
# if we bootstrap-installed successfully, return the path to the uv binary
685688
# if not, reset `uv` for the on.exit() hook and return NULL visibly

tests/testthat/_snaps/py_require.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,21 @@
357357
success: false
358358
exit_code: 1
359359

360+
# py_require() warns missing packages in a virtual env
361+
362+
Code
363+
do.call(r_session, list(force_managed_python = FALSE, exprs = expr))
364+
Output
365+
> library(reticulate)
366+
> use_virtualenv("***",
367+
+ required = TRUE)
368+
> py_require("polars")
369+
> config <- py_config()
370+
Warning message:
371+
Some Python package requirements declared via `py_require()` are not installed in the selected Python environment: (***)
372+
polars
373+
>
374+
------- session end -------
375+
success: true
376+
exit_code: 0
377+

tests/testthat/test-py_require.R

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,24 @@ test_that("py_require() standard library module", {
193193
os <- import("os")
194194
}))
195195
})
196+
197+
test_that("py_require() warns missing packages in a virtual env", {
198+
local_edition(3)
199+
venv <- tempfile("venv")
200+
virtualenv_create(envname = venv)
201+
expr = bquote({
202+
library(reticulate)
203+
use_virtualenv(.(venv), required = TRUE)
204+
py_require("polars")
205+
206+
config <- py_config()
207+
})
208+
expect_snapshot2(
209+
do.call(r_session, list(force_managed_python = FALSE, exprs = expr)),
210+
transform = function(x) {
211+
x <- transform_scrub_python_patch(x)
212+
# scrub paths
213+
gsub("[A-Za-z]:[\\\\/][^\"' ]+|/[A-Za-z0-9._/\\\\-]+", "***", x)
214+
}
215+
)
216+
})

0 commit comments

Comments
 (0)