Reproducible R Analysis With renv
Mix.install([
{:rx, "~> 0.1"}
])
Shared Analysis Scenario
This notebook models a common workflow: an Elixir Livebook opens an R analysis
project with a pinned renv.lock, starts Rx inside that project, reads project
configuration from R, and runs a small analysis that depends on the packages in
the project library.
The demo uses datasets::airquality and jsonlite so it can run without
downloading a large package set. On a real team project, replace the temporary
demo path with the project directory that contains your committed renv.lock.
For an already-restored project, call
:ok = Rx.renv_init("/path/to/your/project", restore: false).
To populate the project library from the lockfile first, call
:ok = Rx.renv_init("/path/to/your/project", restore: true).
Prepare A Demo Project
The helper below builds a disposable project that behaves like a checked-out R
project: it has a project file, an renv.lock, a project-local library, and a
small JSON configuration file. It reports %{status: "skipped"} when local R
prerequisites are missing instead of writing to a user library.
defmodule RenvAnalysisExample do
@moduledoc false
def prepare do
with {:ok, rscript} <- find_rscript(),
:ok <- require_r_package(rscript, "renv"),
:ok <- require_r_package(rscript, "jsonlite") do
prepare_project(rscript)
end
end
def run_analysis(%{status: "ready"} = context) do
{result, _globals} =
Rx.eval(
~S"""
project <- Sys.getenv("RX_RENV_PROJECT")
config <- jsonlite::fromJSON(file.path(project, "analysis_config.json"))
observations <- stats::na.omit(
datasets::airquality[, c("Ozone", "Solar.R", "Wind", "Temp")]
)
fit <- lm(Ozone ~ Solar.R + Wind + Temp, data = observations)
prediction_days <- as.data.frame(config$prediction_days)
prediction_days$predicted_ozone <- as.numeric(predict(fit, newdata = prediction_days))
coefficient_matrix <- coef(summary(fit))
coefficients <- data.frame(
term = rownames(coefficient_matrix),
estimate = unname(coefficient_matrix[, "Estimate"]),
std_error = unname(coefficient_matrix[, "Std. Error"]),
p_value = unname(coefficient_matrix[, "Pr(>|t|)"]),
row.names = NULL
)
report <- list(
status = "completed",
project = config$project,
rx_project = Sys.getenv("RX_RENV_PROJECT"),
lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
lib_paths = .libPaths(),
jsonlite_version = as.character(utils::packageVersion("jsonlite")),
r_version = as.character(getRversion()),
rows_used = nrow(observations),
model_formula = deparse(formula(fit)),
coefficients = coefficients,
predictions = prediction_days,
model_summary = paste(capture.output(summary(fit)), collapse = "\n"),
session = paste(capture.output(sessionInfo()), collapse = "\n")
)
as.character(
jsonlite::toJSON(report, dataframe = "rows", auto_unbox = TRUE, digits = 5, na = "null")
)
""",
%{}
)
result
|> Rx.decode()
|> Jason.decode!()
|> Map.put("project_directory", context.project)
end
def run_analysis(skipped), do: skipped
defp find_rscript do
case System.find_executable("Rscript") do
nil -> {:skip, "Rscript is not on PATH"}
path -> {:ok, path}
end
end
defp require_r_package(rscript, package) do
source = ~s|cat(requireNamespace("#{package}", quietly = TRUE))|
case System.cmd(rscript, ["--vanilla", "-e", source], stderr_to_stdout: true) do
{"TRUE", 0} -> :ok
{output, _status} -> {:skip, "R package #{package} is unavailable: #{String.trim(output)}"}
end
end
defp prepare_project(rscript) do
root = Path.join(System.tmp_dir!(), "rx-renv-analysis-#{System.unique_integer([:positive])}")
project = Path.join(root, "airquality-project")
renv_root = Path.join(root, "renv-root")
renv_library = Path.join(project, "renv-library")
config_path = Path.join(project, "analysis_config.json")
File.mkdir_p!(project)
config = %{
"project" => "airquality-ozone-forecast",
"prediction_days" => [
%{"Solar.R" => 190, "Wind" => 7.4, "Temp" => 87},
%{"Solar.R" => 250, "Wind" => 9.2, "Temp" => 92}
]
}
File.write!(config_path, Jason.encode!(config))
env = [
{"RENV_PATHS_ROOT", renv_root},
{"RENV_PATHS_LIBRARY", renv_library},
{"RENV_CONFIG_SANDBOX_ENABLED", "FALSE"}
]
source = ~S"""
args <- commandArgs(trailingOnly = TRUE)
if (length(args) > 0L && identical(args[[1]], "--args")) args <- args[-1L]
project <- normalizePath(args[[1]], mustWork = TRUE)
options(renv.consent = TRUE)
if (!requireNamespace("renv", quietly = TRUE)) {
cat("renv unavailable\n", file = stderr())
quit(save = "no", status = 10)
}
if (!requireNamespace("jsonlite", quietly = TRUE)) {
cat("jsonlite unavailable\n", file = stderr())
quit(save = "no", status = 11)
}
lockfile <- file.path(project, "renv.lock")
writeLines(c(
"{",
sprintf(' "R": { "Version": "%s" },', as.character(getRversion())),
' "Packages": {}',
"}"
), lockfile)
renv::init(project = project, bare = TRUE, restart = FALSE, load = FALSE)
library_path <- renv::paths$library(project = project)
dir.create(library_path, recursive = TRUE, showWarnings = FALSE)
source <- find.package("jsonlite")
target <- file.path(library_path, "jsonlite")
if (dir.exists(target)) unlink(target, recursive = TRUE)
if (!file.copy(source, library_path, recursive = TRUE)) {
cat("failed to copy jsonlite into isolated renv library\n", file = stderr())
quit(save = "no", status = 12)
}
lock <- renv::lockfile_read(lockfile, project = project)
lock$Packages$jsonlite <- list(
Package = "jsonlite",
Version = as.character(utils::packageVersion("jsonlite")),
Source = "Repository",
Repository = "CRAN"
)
renv::lockfile_write(lock, lockfile, project = project)
"""
case System.cmd(rscript, ["--vanilla", "-e", source, "--args", project],
env: env,
stderr_to_stdout: true
) do
{_output, 0} ->
{:ok,
%{
status: "prepared",
project: Path.expand(project),
lockfile: Path.expand(Path.join(project, "renv.lock")),
config_path: Path.expand(config_path),
renv_root: Path.expand(renv_root),
renv_library: Path.expand(renv_library),
renv_env: env
}}
{output, status} ->
{:skip, "could not prepare isolated renv project, status #{status}: #{String.trim(output)}"}
end
end
end
Start Rx In That Project
Rx.renv_init/2 validates the lockfile, checks that renv and jsonlite are
available to the selected project, and starts the process backend with
RX_RENV_PROJECT and RX_RENV_LOCKFILE set for the R process.
example =
case RenvAnalysisExample.prepare() do
{:ok, context} ->
project = context.project
:ok =
Rx.renv_init(project,
restore: false,
renv_env: context.renv_env
)
context
|> Map.put(:status, "ready")
|> Map.put(:backend, Rx.backend())
{:skip, reason} ->
%{status: "skipped", reason: reason}
end
Map.drop(example, [:renv_env])
Inspect The R Environment
This is the quick sanity check you would usually run before a notebook analysis: confirm the project path, lockfile path, project library, and package version R actually sees.
renv_environment =
case example do
%{status: "ready"} ->
{result, _globals} =
Rx.eval(
~S"""
list(
project = Sys.getenv("RX_RENV_PROJECT"),
lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
lib_paths = .libPaths(),
jsonlite_version = as.character(utils::packageVersion("jsonlite"))
)
""",
%{}
)
Rx.decode(result)
skipped ->
skipped
end
renv_environment
Run The Analysis
The R code reads the project configuration with jsonlite::fromJSON(), fits a
small ozone model against datasets::airquality, predicts for the configured
days, and returns a JSON report to Elixir.
analysis_report = RenvAnalysisExample.run_analysis(example)
case analysis_report do
%{"status" => "completed"} = report ->
%{
project: report["project"],
rows_used: report["rows_used"],
formula: report["model_formula"],
jsonlite_version: report["jsonlite_version"],
predictions: report["predictions"],
coefficient_terms: Enum.map(report["coefficients"], & &1["term"])
}
skipped ->
skipped
end
Keep The Full Report
The previous cell keeps the display compact. The full report still includes the
project paths, .libPaths(), model summary, and sessionInfo() output so the
notebook result can be audited later.
analysis_report
What Changes Between Projects
Rx treats the selected project, lockfile path, lockfile content, resolved
library paths, and selected renv environment as part of the backend identity.
If any of those change, call Rx.renv_init/2 again and recreate R object
handles in the new session before passing them back to R.
For example, repoint the process backend at another checked-out project with
:ok = Rx.renv_init("/path/to/another/project", restore: false).