From 31e7859ab2757d8019b42eb18522703415ff8c85 Mon Sep 17 00:00:00 2001 From: Frankie Robertson Date: Tue, 30 Jan 2024 21:43:03 +0200 Subject: [PATCH] Get Rhome and libR from Preferences.jl when provided (#496) * Get Rhome and libR from preferences when provided * Allow installing package without R installation This is in case the user wants to specify and installation via Preferences.jl after installation. An error message is printed at import time if no installation is available. * Add docs for Preferences based R customization * Only precompile when Rhome is set * Add note about current downsides of installation time R configuration * Add additional note about switching to preference based Rlib config * Add _ option for R_HOME to explicitly unset it * Add docs for installing with R_HOME=_ * Add installation tests * Add cookbook snippet for usage with CondaPkg * Clarify precompile-abort case and print explanitory message in this case * patch bump * Refer to RCall by uuid in CondaPkg example * Add note about use with CondaPkg and making sure the environment is activate * coverage --------- Co-authored-by: Phillip Alday --- Project.toml | 8 +- deps/build.jl | 39 +++++++--- docs/src/installation.md | 73 ++++++++++++++++++- src/RCall.jl | 33 +++++++-- src/setup.jl | 6 ++ test/installation.jl | 70 ++++++++++++++++++ test/installation/drop_preferences.jl | 18 +++++ test/installation/install_conda.jl | 18 +++++ test/installation/preferences_invalid_env.jl | 39 ++++++++++ test/installation/rcall_without_r.jl | 21 ++++++ .../swap_to_prefs_and_condapkg.jl | 50 +++++++++++++ test/runtests.jl | 4 + 12 files changed, 357 insertions(+), 22 deletions(-) create mode 100644 test/installation.jl create mode 100644 test/installation/drop_preferences.jl create mode 100644 test/installation/install_conda.jl create mode 100644 test/installation/preferences_invalid_env.jl create mode 100644 test/installation/rcall_without_r.jl create mode 100644 test/installation/swap_to_prefs_and_condapkg.jl diff --git a/Project.toml b/Project.toml index 9cbf6070..339bdc64 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "RCall" uuid = "6f49c342-dc21-5d91-9882-a32aef131414" authors = ["Douglas Bates ", "Randy Lai ", "Simon Byrne "] -version = "0.14.0" +version = "0.14.1" [deps] CategoricalArrays = "324d7699-5711-5eae-9e2f-1d82baa6b597" @@ -11,6 +11,7 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" Missings = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Requires = "ae029012-a4dd-5104-9daa-d747884805df" @@ -24,6 +25,7 @@ Conda = "1.4" DataFrames = "0.21, 0.22, 1.0" DataStructures = "0.5, 0.6, 0.7, 0.8, 0.9, 0.10, 0.11, 0.12, 0.13, 0.14, 0.15, 0.16, 0.17, 0.18" Missings = "0.2, 0.3, 0.4, 1.0" +Preferences = "1" Requires = "0.5.2, 1" StatsModels = "0.6, 0.7" WinReg = "0.2, 0.3, 1" @@ -31,10 +33,12 @@ julia = "1" [extras] AxisArrays = "39de3d68-74b9-583c-8d2d-e117c070f3a9" +CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Dates", "AxisArrays", "REPL", "Test", "Random"] +test = ["Dates", "AxisArrays", "REPL", "Test", "Random", "CondaPkg", "Pkg"] diff --git a/deps/build.jl b/deps/build.jl index 2bd34622..8d35c96d 100644 --- a/deps/build.jl +++ b/deps/build.jl @@ -25,6 +25,7 @@ try @info "Using previously configured R at $Rhome with libR in $libR." else Rhome = get(ENV, "R_HOME", "") + libR = nothing if Rhome == "*" # install with Conda @info "Installing R via Conda. To use a different R installation,"* @@ -35,6 +36,8 @@ try Conda.add("r-base>=3.4.0,<5") # greater than or equal to 3.4.0 AND strictly less than 5.0 Rhome = joinpath(Conda.LIBDIR, "R") libR = locate_libR(Rhome) + elseif Rhome == "_" + Rhome = "" else if isempty(Rhome) try Rhome = readchomp(`R RHOME`); catch; end @@ -48,22 +51,36 @@ try try Rhome = WinReg.querykey(WinReg.HKEY_CURRENT_USER, "Software\\R-Core\\R", "InstallPath"); catch; end end - else - if !isdir(Rhome) - error("R_HOME is not a directory.") - end end - isempty(Rhome) && error("R cannot be found. Set the \"R_HOME\" environment variable to re-run Pkg.build(\"RCall\").") - libR = locate_libR(Rhome) + if !isempty(Rhome) && !isdir(Rhome) + error("R_HOME is not a directory.") + end + + if !isempty(Rhome) + libR = locate_libR(Rhome) + end end - @info "Using R at $Rhome and libR at $libR." - if DepFile.Rhome != Rhome || DepFile.libR != libR + if isempty(Rhome) + @info ( + "No R installation found. " * + "You will not be able to import RCall without " * + "providing values for its preferences Rhome and libR." + ) open(depfile, "w") do f - println(f, "const Rhome = \"", escape_string(Rhome), '"') - println(f, "const libR = \"", escape_string(libR), '"') - println(f, "const conda_provided_r = $(conda_provided_r)") + println(f, "const Rhome = \"\"") + println(f, "const libR = \"\"") + println(f, "const conda_provided_r = nothing") + end + else + @info "Using R at $Rhome and libR at $libR." + if DepFile.Rhome != Rhome || DepFile.libR != libR + open(depfile, "w") do f + println(f, "const Rhome = \"", escape_string(Rhome), '"') + println(f, "const libR = \"", escape_string(libR), '"') + println(f, "const conda_provided_r = $(conda_provided_r)") + end end end end diff --git a/docs/src/installation.md b/docs/src/installation.md index e022a5a6..28706457 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -8,7 +8,65 @@ Pkg.add("RCall") ## Customizing the R installation -The RCall build script (run by `Pkg.add`) +There are two ways to configure the R installation used by RCall.jl: + + * [Using Julia's Preferences system](#Customizing-the-R-installation-using-Julia's-Preferences-system) + * [At RCall.jl install time, or when manually re-building RCall.jl, using the `R_HOME` environment variable](#Customizing-the-R-installation-using-R_HOME) + +Should you experience problems with any of these methods, please [open an issue](https://github.com/JuliaStats/RCall.jl/issues/new). + +### Customizing the R installation using Julia's Preferences system + +You can customize the R installation using [Julia's Preferences system](https://docs.julialang.org/en/v1/manual/code-loading/#preferences) by providing appropriate paths using RCall's `Rhome` and `libR` preferences. Julia's Preferences system allows these to be set in a few different ways. One possibility is to add the following to a `LocalPreferences.toml` file in the same directory as a project's `Project.toml` file: + +```toml +[RCall] +Rhome = "/path/to/env/lib/R" +libR = "/path/to/env/lib/R/lib/libR.so" +``` + +!!! note + When these preferences are set, they take precedence over the R installation configured using the `R_HOME` environment variable when RCall.jl was last built. + +#### (Experimental) Usage with CondaPkg + +Unlike [customizing the R installation using `R_HOME`](#Customizing-the-R-installation-using-R_HOME), the Preferences-based approach allows for each of your Julia projects using RCall.jl to use a different R installation. As such, it is appropriate for when you want to install and manage R with [CondaPkg](https://github.com/JuliaPy/CondaPkg.jl). Assuming that RCall and CondaPkg are installed, the following script will install a CondaPkg-managed R and set the correct preferences so that RCall.jl will make use of it. + +``` +using Libdl +using CondaPkg +using Preferences +using UUIDs + +const RCALL_UUID = UUID("6f49c342-dc21-5d91-9882-a32aef131414") + +CondaPkg.add("r") +target_rhome = joinpath(CondaPkg.envdir(), "lib", "R") +if Sys.iswindows() + target_libr = joinpath(Rhome, "bin", Sys.WORD_SIZE==64 ? "x64" : "i386", "R.dll") +else + target_libr = joinpath(Rhome, "lib", "libR.$(Libdl.dlext)") +end +set_preferences!(RCALL_UUID, "Rhome" => target_rhome, "libR" => target_libr) +``` + +So that CondaPkg managed R finds the correct versions of its shared library dependencies, such as BLAS, you must arrange for the Conda environment to be active when `RCall` is imported so that native library loading paths are set up correctly. If you do not do so, it is still possible that things will appear to work correctly if compatible versions are available from elsewhere in your library loading path, but the resulting code can break in some environments and so is not portable. + +At the moment there are two options for arranging for this: +1. (Recommended) Use `CondaPkg.activate!(ENV)` to permanently modify the environment *before* loading RCall. +2. (Experimental) Use `CondaPkg.withenv()` to change the environment while loading RCall/R and R libraries using native code. After the `CondaPkg.withenv()` block, the Conda environment will no longer be active. This approach may be needed if you need to return to a unmodified environment after loading R. Note this approach has not been thouroughly tested and may not work with all R packages. + +```julia +RCall = CondaPkg.withenv() do + RCall = @eval using RCall + # Load all R libraries that may load native code from the Conda environment here + return RCall +end +``` + +### Customizing the R installation using `R_HOME` + +The RCall build script (run by `Pkg.add(...)` or `Pkg.build(...)`) will check for an existing R installation by looking in the following locations, in order. @@ -19,7 +77,7 @@ in order. * Otherwise, on Windows, it looks in the [Windows registry](https://cran.r-project.org/bin/windows/base/rw-FAQ.html#Does-R-use-the-Registry_003f). To change which R installation is used for RCall, set the `R_HOME` environment variable -and run `Pkg.build("RCall")`. Once this is configured, RCall remembers the location +and run `Pkg.build("RCall")`. Once this is configured, RCall remembers the location of R in future updates, so you don't need to set `R_HOME` permanently. ```julia @@ -27,9 +85,16 @@ ENV["R_HOME"] = "....directory of R home...." Pkg.build("RCall") ``` -When `R_HOME` is set to `"*"`, RCall.jl will automatically install R for you using [Conda](https://github.com/JuliaPy/Conda.jl). +As well as being setting `R_HOME` to a path, it can also be set to certain special values: -Should you experience problems with any of these methods, please [open an issue](https://github.com/JuliaStats/RCall.jl/issues/new). +* When `R_HOME="*"`, RCall.jl will automatically install R for you using [Conda](https://github.com/JuliaPy/Conda.jl). +* When `R_HOME=""`, or is unset, RCall will try to locate `R_HOME` by asking the copy of R in your `PATH` and then --- on Windows only --- by checking the registry. +* When `R_HOME="_"`, you opt out of all attempts to automatically locate R. + +In case no R installation is found or given at build time, the build will complete with a warning, but no error. RCall.jl will not be importable until you set a location for R [using the Preferences system](#Customizing-the-R-installation-using-Julia's-Preferences-system). + +!!! note "R_HOME-based R installation is shared" + When the R installation is configured at RCall.jl install time, the absolute path to the R installation is currently hard-coded into the RCall.jl package, which can be shared between projects. This may cause problems if you are using different R installations for different projects which end up using the same copy of RCall.jl. In this case, please [use the Preferences system instead](#Customizing-the-R-installation-using-Julia's-Preferences-system) which keeps different copies of the compiled RCall for different R installations. You do not need to rebuild RCall.jl manually for this, simply setting the relevant preferences will trigger rebuilds as necessary. ## Standard installations diff --git a/src/RCall.jl b/src/RCall.jl index 6be387e4..de50f4c7 100644 --- a/src/RCall.jl +++ b/src/RCall.jl @@ -1,6 +1,6 @@ -__precompile__() module RCall +using Preferences using Requires using Dates using Libdl @@ -29,11 +29,34 @@ export RObject, robject, rcopy, rparse, rprint, reval, rcall, rlang, rimport, @rimport, @rlibrary, @rput, @rget, @var_str, @R_str -const depfile = joinpath(dirname(@__FILE__),"..","deps","deps.jl") -if isfile(depfile) - include(depfile) +# These two preference get marked as compile-time preferences by being accessed +# here +const Rhome_set_as_preference = @has_preference("Rhome") +const libR_set_as_preference = @has_preference("libR") + +if Rhome_set_as_preference || libR_set_as_preference + if !(Rhome_set_as_preference && libR_set_as_preference) + error("RCall: Either both Rhome and libR must be set or neither of them") + end + const Rhome = @load_preference("Rhome") + const libR = @load_preference("libR") + const conda_provided_r = false else - error("RCall not properly installed. Please run Pkg.build(\"RCall\")") + const depfile = joinpath(dirname(@__FILE__),"..","deps","deps.jl") + if isfile(depfile) + include(depfile) + else + error("RCall not properly installed. Please run Pkg.build(\"RCall\")") + end +end + +if Rhome == "" + @info ( + "No R installation found by RCall.jl. " * + "Precompilation of RCall and all dependent packages postponed. " * + "Importing RCall will fail until an R installation is configured beforehand." + ) + __precompile__(false) end include("types.jl") diff --git a/src/setup.jl b/src/setup.jl index acde2293..4415955b 100644 --- a/src/setup.jl +++ b/src/setup.jl @@ -171,6 +171,12 @@ end include(joinpath(dirname(@__FILE__),"..","deps","setup.jl")) function __init__() + # This should actually error much sooner, but this is just in case + isempty(Rhome) && error( + "No R installation was detected at RCall installation time. " * + "Please provided the location of R by setting the Rhome and libR preferences or " * + "else set R_HOME='*' and rerun Pkg.build(\"RCall\") to use Conda.jl.") + validate_libR(libR) # Check if R already running diff --git a/test/installation.jl b/test/installation.jl new file mode 100644 index 00000000..9c8f4612 --- /dev/null +++ b/test/installation.jl @@ -0,0 +1,70 @@ +# This file is used to test installation of the RCall package. We run +# a new Julia process in a temporary environment so that we +# can test what happens without already having imported RCall. + +using Test + +const RCALL_DIR = dirname(@__DIR__) + +function test_installation(file, project=mktempdir()) + path = joinpath(@__DIR__, "installation", file) + @static if Sys.isunix() + # this weird stub is necessary so that all the nested conda installation processes + # have access to the PATH + cmd = `sh -c $(Base.julia_cmd()) --project=$(project) $(path)` + elseif Sys.iswindows() + cmd = `cmd /C $(Base.julia_cmd()) --project=$(project) $(path)` + else + error("What system are you on?!") + end + cmd = Cmd(cmd; env=Dict("RCALL_DIR" => RCALL_DIR)) + @test mktemp() do file, io + try + result = run(pipeline(cmd; stdout=io, stderr=io)) + return success(result) + catch + @error open(f -> read(f, String), file) + return false + end + end +end + +mktempdir() do dir + @testset "No R" begin + test_installation("rcall_without_r.jl", dir) + end + # We want to guard this with a version check so we don't run into the following + # (non-widespread) issue on older versions of Julia: + # https://github.com/JuliaLang/julia/issues/34276 + # (related to incompatible libstdc++ versions) + @static if VERSION ≥ v"1.9" + @testset "Preferences" begin + test_installation("swap_to_prefs_and_condapkg.jl", dir) + end + end +end + +# We want to guard this with a version check so we don't run into the following +# issue on older versions of Julia: +# https://github.com/JuliaLang/julia/issues/34276 +# (related to incompatible libstdc++ versions) +@static if VERSION ≥ v"1.9" + # Test whether we can install RCall with Conda, and then switch to using + # Preferences + CondaPkg + mktempdir() do dir + # we run into weird issues with this on CI + @static if Sys.isunix() + @testset "Conda" begin + test_installation("install_conda.jl", dir) + end + end + @testset "Swap to Preferences" begin + test_installation("swap_to_prefs_and_condapkg.jl", dir) + end + @static if Sys.isunix() + @testset "Swap back from Preferences" begin + test_installation("drop_preferences.jl", dir) + end + end + end +end diff --git a/test/installation/drop_preferences.jl b/test/installation/drop_preferences.jl new file mode 100644 index 00000000..7a77dfff --- /dev/null +++ b/test/installation/drop_preferences.jl @@ -0,0 +1,18 @@ +# Test removal of Rhome from preferences. +# +# If run after `install_conda.jl` and `swap_to_prefs_and_condapkg.jl` in the same enviroment, +# then it tests returning to the build status quo after removal of preferences. +# +# This file is meant to be run in an embedded process spawned by installation.jl. +@debug ENV["RCALL_DIR"] +using Preferences, UUIDs + +set_preferences!(UUID("6f49c342-dc21-5d91-9882-a32aef131414"), + "Rhome" => nothing, "libR" => nothing; force=true) + +RCall = Base.require(Main, :RCall) +if occursin(r"/conda/3/([^/]+/)?lib/R", RCall.Rhome) + exit(0) +end +println(stderr, "Wrong Conda Rhome $(rcall.Rhome)") +exit(1) diff --git a/test/installation/install_conda.jl b/test/installation/install_conda.jl new file mode 100644 index 00000000..1d5dbf26 --- /dev/null +++ b/test/installation/install_conda.jl @@ -0,0 +1,18 @@ +# Test installation of RCall when R is not present on the system and R_HOME="*", +# which leads to the autoinstallation of Conda.jl and R via Conda.jl +# +# This file is meant to be run in an embedded process spawned by installation.jl. +@debug ENV["RCALL_DIR"] + +using Pkg + +ENV["R_HOME"] = "*" +Pkg.add(;path=ENV["RCALL_DIR"]) +Pkg.build("RCall") + +RCall = Base.require(Main, :RCall) +if occursin(r"/conda/3/([^/]+/)?lib/R", RCall.Rhome) + exit(0) +end +println(stderr, "Wrong Conda Rhome $(rcall.Rhome)") +exit(1) diff --git a/test/installation/preferences_invalid_env.jl b/test/installation/preferences_invalid_env.jl new file mode 100644 index 00000000..eb0a459c --- /dev/null +++ b/test/installation/preferences_invalid_env.jl @@ -0,0 +1,39 @@ +# Test using Rhome set in Preferences, with invalid R_HOME set at build time. +# +# If run after `install_conda.jl` in the same enviroment, then it will also test +# dynamically overriding the Rhome variable from the build step via preferences. +# +# This file is meant to be run in an embedded process spawned by installation.jl. +@debug ENV["RCALL_DIR"] + +using Pkg + +Pkg.add("CondaPkg") +Pkg.add("Libdl") +Pkg.add("Preferences") +Pkg.add("UUIDs") + +using CondaPkg, Libdl, Preferences, UUIDs + +function locate_libR(Rhome) + @static if Sys.iswindows() + libR = joinpath(Rhome, "bin", Sys.WORD_SIZE==64 ? "x64" : "i386", "R.dll") + else + libR = joinpath(Rhome, "lib", "libR.$(Libdl.dlext)") + end + return libR +end + +CondaPkg.add("r") +target_rhome = joinpath(CondaPkg.envdir(), "lib", "R") +set_preferences!(UUID("6f49c342-dc21-5d91-9882-a32aef131414"), + "Rhome" => target_rhome, "libR" => locate_libR(target_rhome)) +ENV["R_HOME"] = "_" +Pkg.add(;path=ENV["RCALL_DIR"]) +Pkg.build("RCall") +RCall = Base.require(Main, :RCall) +if occursin("/.CondaPkg/env/lib/R", RCall.Rhome) + exit(0) +end +println(stderr, "Wrong RCall used $(RCall.Rhome)") +exit(1) diff --git a/test/installation/rcall_without_r.jl b/test/installation/rcall_without_r.jl new file mode 100644 index 00000000..d4dba66b --- /dev/null +++ b/test/installation/rcall_without_r.jl @@ -0,0 +1,21 @@ +# Test installation of RCall when R_HOME specifies an invalid R home. +# +# This file is meant to be run in an embedded process spawned by installation.jl. +@debug ENV["RCALL_DIR"] + +using Pkg +ENV["R_HOME"] = "_" +Pkg.add(;path=ENV["RCALL_DIR"]) +Pkg.build("RCall") + +try + Base.require(Main, :RCall) +catch e + if !(e isa LoadError) + @error "Expected LoadError when running RCall but got $e" + exit(1) + end + exit(0) +end +@error "RCall unexpectedly loaded" +exit(1) diff --git a/test/installation/swap_to_prefs_and_condapkg.jl b/test/installation/swap_to_prefs_and_condapkg.jl new file mode 100644 index 00000000..df1bbd10 --- /dev/null +++ b/test/installation/swap_to_prefs_and_condapkg.jl @@ -0,0 +1,50 @@ +# Test using Rhome set in Preferences. +# +# If run after `install_conda.jl` in the same enviroment, then it will also test +# dynamically overriding the Rhome variable from the build step via preferences. +# +# This file is meant to be run in an embedded process spawned by installation.jl. +@debug ENV["RCALL_DIR"] + +using Pkg +using Base.Filesystem: joinpath + +Pkg.add("CondaPkg") +Pkg.add("Libdl") +Pkg.add("Preferences") +Pkg.add("UUIDs") + +using CondaPkg, Libdl, Preferences, UUIDs + +const RCALL_UUID = UUID("6f49c342-dc21-5d91-9882-a32aef131414") + +function locate_libR(Rhome) + @static if Sys.iswindows() + libR = joinpath(Rhome, "bin", Sys.WORD_SIZE==64 ? "x64" : "i386", "R.dll") + else + libR = joinpath(Rhome, "lib", "libR.$(Libdl.dlext)") + end + return libR +end + +set_preferences!(CondaPkg, "verbosity" => -1) +CondaPkg.add("r") +target_rhome = joinpath(CondaPkg.envdir(), "lib", "R") +# If RCall is already present, then +# we do NOT re-add RCall here because we're testing against the version already built with Conda +if !haskey(Pkg.dependencies(), RCALL_UUID) + Pkg.add(;path=ENV["RCALL_DIR"]) +end +set_preferences!(RCALL_UUID, + "Rhome" => target_rhome, "libR" => locate_libR(target_rhome)) +RCall = nothing +RCall = CondaPkg.withenv() do + Pkg.build("RCall") + Base.require(Main, :RCall) +end +expected = joinpath("x", ".CondaPkg", "env", "lib", "R")[2:end] +if occursin(expected, RCall.Rhome) + exit(0) +end +println(stderr, "Wrong RCall used $(RCall.Rhome)") +exit(1) diff --git a/test/runtests.jl b/test/runtests.jl index cfdb8a80..4174dd26 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,6 +4,10 @@ using Test using DataStructures: OrderedDict using RCall: RClass +@testset "installation" begin + include("installation.jl") +end + # before RCall does anything const R_PPSTACKTOP_INITIAL = unsafe_load(cglobal((:R_PPStackTop, RCall.libR), Int)) @info "" R_PPSTACKTOP_INITIAL