Skip to content

Commit eddf254

Browse files
Merge pull request #15 from milanmlft/use_python
Add support for setting up Python through renv
2 parents 5bd7ace + d096467 commit eddf254

10 files changed

+336
-6
lines changed

DESCRIPTION

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: sandpaper
22
Title: Create and Curate Carpentries Lessons
3-
Version: 0.12.0
3+
Version: 0.12.0.9000
44
Authors@R: c(
55
person(given = "Zhian N.",
66
family = "Kamvar",
@@ -64,7 +64,8 @@ Suggests:
6464
jsonlite,
6565
sessioninfo,
6666
mockr,
67-
varnish (>= 0.2.1)
67+
varnish (>= 0.2.1),
68+
reticulate
6869
Additional_repositories: https://carpentries.r-universe.dev/
6970
Remotes:
7071
ropensci/tinkr,

NAMESPACE

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export(move_episode)
2424
export(no_package_cache)
2525
export(package_cache_trigger)
2626
export(pin_version)
27+
export(py_install)
2728
export(renv_diagnostics)
2829
export(reset_episodes)
2930
export(reset_site)
@@ -54,6 +55,7 @@ export(update_cache)
5455
export(update_github_workflows)
5556
export(update_varnish)
5657
export(use_package_cache)
58+
export(use_python)
5759
export(validate_lesson)
5860
export(work_with_cache)
5961
importFrom(assertthat,validate_that)

R/create_lesson.R

+17-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
#' file extension in the lesson.
1212
#' @param rstudio create an RStudio project (defaults to if RStudio exits)
1313
#' @param open if interactive, the lesson will open in a new editor window.
14+
#' @param add_python if set to `TRUE`, will add Python as a dependency for the
15+
#' lesson. See [use_python()] for details. Defaults to `FALSE`.
16+
#' @param python the path to the version of Python to be used. The default,
17+
#' `NULL`, will prompt the user to select an appropriate version of Python in
18+
#' interactive sessions. In non-interactive sessions, \pkg{renv} will attempt
19+
#' to automatically select an appropriate version. See [renv::use_python()]
20+
#' for more details.
21+
#' @param type the type of Python environment to use. When `"auto"`, the
22+
#' default, virtual environments will be used. See [renv::use_python()] for
23+
#' more details.
1424
#'
1525
#' @export
1626
#' @return the path to the new lesson
@@ -19,7 +29,8 @@
1929
#' on.exit(unlink(tmp))
2030
#' lsn <- create_lesson(tmp, name = "This Lesson", open = FALSE)
2131
#' lsn
22-
create_lesson <- function(path, name = fs::path_file(path), rmd = TRUE, rstudio = rstudioapi::isAvailable(), open = rlang::is_interactive()) {
32+
create_lesson <- function(path, name = fs::path_file(path), rmd = TRUE, rstudio = rstudioapi::isAvailable(), open = rlang::is_interactive(),
33+
add_python = FALSE, python = NULL, type = c("auto", "virtualenv", "conda", "system")) {
2334

2435
path <- fs::path_abs(path)
2536
id <- cli::cli_status("{cli::symbol$arrow_right} Creating Lesson in {.file {path}}...")
@@ -92,6 +103,11 @@ create_lesson <- function(path, name = fs::path_file(path), rmd = TRUE, rstudio
92103
if (has_consent) {
93104
cli::cli_status_update("{cli::symbol$arrow_right} Managing Dependencies ...")
94105
manage_deps(path, snapshot = TRUE)
106+
107+
if (add_python) {
108+
cli::cli_status_update("{cli::symbol$arrow_right} Setting up Python ...")
109+
use_python(path = path, python = python, type = type)
110+
}
95111
}
96112

97113
cli::cli_status_update("{cli::symbol$arrow_right} Committing ...")

R/use_python.R

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#' Add Python as a lesson dependency
2+
#'
3+
#' Associate a version of Python with your lesson. This is essentially a wrapper
4+
#' around [renv::use_python()].
5+
#'
6+
#' @param path path to the current project
7+
#' @inheritParams renv::use_python
8+
#' @param ... Further arguments to be passed on to [renv::use_python()]
9+
#'
10+
#' @details
11+
#' This helper function adds Python as a dependency to the \pkg{renv} lockfile
12+
#' and installs a Python environment of the specified `type`. This ensures any
13+
#' Python packages used for this lesson are installed separately from the user's
14+
#' main library, much like the R packages (see [manage_deps()]).
15+
#'
16+
#' Note that \pkg{renv} is not (yet) able to automatically detect Python package
17+
#' dependencies (e.g. from `import` statements). So any required Python packages
18+
#' still need to be installed manually. To facilitate this, the [py_install()]
19+
#' helper is provided. This will install Python packages in the correct
20+
#' environment and record them in a `requirements.txt` file, which will be
21+
#' tracked by \pkg{renv}. Subsequent calls of [manage_deps()] will then
22+
#' correctly restore the required Python packages if needed.
23+
#'
24+
#' @export
25+
#' @rdname use_python
26+
#' @seealso [renv::use_python()], [py_install()]
27+
#' @return The path to the Python executable. Note that this function is mainly
28+
#' called for its side effects.
29+
#' @examples
30+
#' \dontrun{
31+
#' tmp <- tempfile()
32+
#' on.exit(unlink(tmp))
33+
#'
34+
#' ## Create lesson with Python support
35+
#' lsn <- create_lesson(tmp, name = "This Lesson", open = FALSE, add_python = TRUE)
36+
#' lsn
37+
#'
38+
#' ## Add Python as a dependency to an existing lesson
39+
#' setwd(lsn)
40+
#' use_python()
41+
#'
42+
#' ## Install Python packages and record them as dependencies
43+
#' py_install("numpy")
44+
#' }
45+
use_python <- function(path = ".", python = NULL,
46+
type = c("auto", "virtualenv", "conda", "system"), ...) {
47+
48+
wd <- getwd()
49+
50+
## Load the renv profile, unloading it upon exit
51+
on.exit({
52+
invisible(utils::capture.output(renv::deactivate(project = path), type = "message"))
53+
setwd(wd)
54+
}, add = TRUE, after = FALSE)
55+
56+
## Set up working directory, avoids some renv side effects
57+
setwd(path)
58+
renv::load(project = path)
59+
prof <- Sys.getenv("RENV_PROFILE")
60+
61+
install_reticulate(path = path)
62+
renv::use_python(python = python, type = type, ...)
63+
64+
## NOTE: use_python() deactivates the default profile, see https://github.com/rstudio/renv/issues/1217
65+
## Workaround: re-activate the profile
66+
renv::activate(project = path, profile = prof)
67+
invisible(path)
68+
}
69+
70+
71+
#' Install Python packages and add them to the cache
72+
#'
73+
#' To add Python packages, `py_install()` is provided, which installs Python
74+
#' packages with [reticulate::py_install()] and then records them in the renv
75+
#' environment. This ensures [manage_deps()] keeps track of the Python packages
76+
#' as well.
77+
#'
78+
#' @param packages Python packages to be installed as a character vecto.
79+
#' @param path path to your lesson. Defaults to the current working directory.
80+
#' @param ... Further arguments to be passed to [reticulate::py_install()]
81+
#'
82+
#' @export
83+
#' @rdname use_python
84+
py_install <- function(packages, path = ".", ...) {
85+
86+
## Load the renv profile, unloading it upon exit
87+
renv::load(project = path)
88+
89+
on.exit({
90+
invisible(utils::capture.output(renv::deactivate(project = path), type = "message"))
91+
}, add = TRUE, after = FALSE)
92+
93+
install_reticulate(path = path)
94+
reticulate::py_install(packages = packages, ...)
95+
96+
cli::cli_alert("Updating the package cache")
97+
renv::snapshot(lockfile = renv::paths$lockfile(project = path), prompt = FALSE)
98+
}
99+
100+
install_reticulate <- function(path) {
101+
renv_lib <- renv::paths$library(project = path)
102+
has_reticulate <- requireNamespace("reticulate", lib.loc = renv_lib, quietly = TRUE)
103+
if (!has_reticulate) {
104+
cli::cli_alert("Adding `reticulate` as a dependency")
105+
## Force reticulate to be recorded by renv
106+
dep_file <- fs::path(path, "dependencies.R")
107+
write("library(reticulate)", file = dep_file, append = TRUE)
108+
renv::install("reticulate", library = renv_lib)
109+
}
110+
invisible(NULL)
111+
}

R/utils-renv.R

+6-2
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,12 @@ callr_manage_deps <- function(path, repos, snapshot, lockfile_exists) {
288288
# recorded.
289289
if (lockfile_exists) {
290290
cli::cli_alert("Restoring any dependency versions")
291-
res <- renv::restore(project = path, library = renv_lib,
292-
lockfile = renv_lock, prompt = FALSE)
291+
# Load profile, this ensures Python dependencies also get restored
292+
renv::load(project = path)
293+
on.exit({
294+
invisible(utils::capture.output(renv::deactivate(project = path), type = "message"))
295+
}, add = TRUE)
296+
res <- renv::restore(project = path, prompt = FALSE)
293297
}
294298
if (snapshot) {
295299
# 3. Load the current profile, unloading it when we exit

man/create_lesson.Rd

+17-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/use_python.Rd

+76
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/helper-python.R

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Helpers to temporarily load renv environment
2+
local_load_py_pkg <- function(lsn, package) {
3+
local_renv_load(lsn)
4+
reticulate::import(package)
5+
}
6+
7+
## These helpers are used in `test-use_python.R`, they are implemented separately to ensure the
8+
## temporary loading of the renv environment doesn't interfere with the testing environment
9+
check_reticulate <- function(lsn) {
10+
local_renv_load(lsn)
11+
lib <- renv::paths$library(project = lsn)
12+
withr::local_libpaths(lib)
13+
rlang::is_installed("reticulate")
14+
}
15+
16+
check_reticulate_config <- function(lsn) {
17+
local_renv_load(lsn)
18+
reticulate::py_config()
19+
}
20+
21+
get_renv_env <- function(lsn, which = "RETICULATE_PYTHON") {
22+
local_renv_load(lsn)
23+
Sys.getenv(which)
24+
}
25+
26+
## Temporarily load a renv profile, unloading it upon exit
27+
local_renv_load <- function(lsn, env = parent.frame()) {
28+
## NOTE: renv:::unload() is currently not exported: https://github.com/rstudio/renv/issues/1285
29+
withr::defer(renv:::unload(project = lsn), envir = env)
30+
renv::load(lsn)
31+
}

tests/testthat/test-manage_deps.R

+34
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,37 @@ test_that("update_cache() will update old package versions", {
214214

215215
})
216216

217+
218+
test_that("manage_deps() does not overwrite requirements.txt", {
219+
skip_on_cran()
220+
skip_on_os("windows")
221+
222+
old_wd <- setwd(lsn)
223+
withr::defer(setwd(old_wd))
224+
225+
## Set up Python and manually add requirements.txt without actually installing
226+
## the Python package, mimicking the scenario where a Python dependency is missing
227+
use_python(lsn, type = "virtualenv")
228+
req_file <- fs::path(lsn, "requirements.txt")
229+
writeLines("numpy", req_file)
230+
231+
res <- manage_deps(lsn, quiet = TRUE)
232+
expect_true(grepl("^numpy", readLines(req_file)))
233+
})
234+
235+
236+
test_that("manage_deps() restores Python dependencies", {
237+
skip_on_cran()
238+
skip_on_os("windows")
239+
240+
old_wd <- setwd(lsn)
241+
withr::defer(setwd(old_wd))
242+
use_python(lsn, type = "virtualenv")
243+
244+
req_file <- fs::path(lsn, "requirements.txt")
245+
writeLines("numpy", req_file)
246+
res <- manage_deps(lsn, quiet = TRUE)
247+
248+
expect_no_error({numpy <- reticulate::import("numpy")})
249+
expect_s3_class(numpy, "python.builtin.module")
250+
})

0 commit comments

Comments
 (0)