Skip to content

Commit 462f94a

Browse files
authored
feat: add rscript interpreter and R test (#1586)
1 parent 32d5522 commit 462f94a

File tree

11 files changed

+247
-11
lines changed

11 files changed

+247
-11
lines changed

src/package_test/run_test.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ use crate::{
3333
env_vars,
3434
metadata::{Debug, PlatformWithVirtualPackages},
3535
recipe::parser::{
36-
CommandsTest, DownstreamTest, PerlTest, PythonTest, PythonVersion, Script, ScriptContent,
37-
TestType,
36+
CommandsTest, DownstreamTest, PerlTest, PythonTest, PythonVersion, RTest, Script,
37+
ScriptContent, TestType,
3838
},
3939
render::solver::create_environment,
4040
source::copy_dir::CopyDir,
@@ -456,6 +456,7 @@ pub async fn run_test(
456456
perl.run_test(&pkg, &package_folder, &prefix, &config)
457457
.await?
458458
}
459+
TestType::R { r } => r.run_test(&pkg, &package_folder, &prefix, &config).await?,
459460
TestType::Downstream(downstream) if downstream_package.is_none() => {
460461
downstream
461462
.run_test(&pkg, package_file, &prefix, &config)
@@ -905,3 +906,72 @@ impl DownstreamTest {
905906
Ok(())
906907
}
907908
}
909+
910+
impl RTest {
911+
/// Execute the R test
912+
pub async fn run_test(
913+
&self,
914+
pkg: &ArchiveIdentifier,
915+
path: &Path,
916+
prefix: &Path,
917+
config: &TestConfiguration,
918+
) -> Result<(), TestError> {
919+
let span = tracing::info_span!("Running R test");
920+
let _guard = span.enter();
921+
922+
let match_spec = MatchSpec::from_str(
923+
format!("{}={}={}", pkg.name, pkg.version, pkg.build_string).as_str(),
924+
ParseStrictness::Lenient,
925+
)?;
926+
927+
let dependencies = vec!["r-base".parse().unwrap(), match_spec];
928+
929+
create_environment(
930+
"test",
931+
&dependencies,
932+
config
933+
.host_platform
934+
.as_ref()
935+
.unwrap_or(&config.current_platform),
936+
prefix,
937+
&config.channels,
938+
&config.tool_configuration,
939+
config.channel_priority,
940+
config.solve_strategy,
941+
)
942+
.await
943+
.map_err(TestError::TestEnvironmentSetup)?;
944+
945+
let mut libraries = String::new();
946+
tracing::info!("Testing R libraries:\n");
947+
948+
for library in &self.libraries {
949+
writeln!(libraries, "library({})", library)?;
950+
tracing::info!(" library({})", library);
951+
}
952+
tracing::info!("\n");
953+
954+
let script = Script {
955+
content: ScriptContent::Command(libraries.clone()),
956+
interpreter: Some("rscript".into()),
957+
..Script::default()
958+
};
959+
960+
let tmp_dir = tempfile::tempdir()?;
961+
script
962+
.run_script(
963+
Default::default(),
964+
tmp_dir.path(),
965+
path,
966+
prefix,
967+
None,
968+
None,
969+
None,
970+
Debug::new(true),
971+
)
972+
.await
973+
.map_err(|e| TestError::TestFailed(e.to_string()))?;
974+
975+
Ok(())
976+
}
977+
}

src/recipe/parser.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub use self::{
4949
source::{GitRev, GitSource, GitUrl, PathSource, Source, UrlSource},
5050
test::{
5151
CommandsTest, CommandsTestFiles, CommandsTestRequirements, DownstreamTest,
52-
PackageContentsTest, PerlTest, PythonTest, PythonVersion, TestType,
52+
PackageContentsTest, PerlTest, PythonTest, PythonVersion, RTest, TestType,
5353
},
5454
};
5555

src/recipe/parser/test.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,13 @@ pub struct DownstreamTest {
137137
pub downstream: String,
138138
}
139139

140+
/// A test that checks if R libraries can be loaded
141+
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
142+
pub struct RTest {
143+
/// List of R libraries to test with library()
144+
pub libraries: Vec<String>,
145+
}
146+
140147
/// The test type enum
141148
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142149
#[serde(untagged)]
@@ -151,6 +158,11 @@ pub enum TestType {
151158
/// The modules to test
152159
perl: PerlTest,
153160
},
161+
/// An R test that will test if the R libraries can be loaded
162+
R {
163+
/// The R libraries to load and test
164+
r: RTest,
165+
},
154166
/// A test that executes multiple commands in a freshly created environment
155167
Command(CommandsTest),
156168
/// A test that runs the tests of a downstream package
@@ -247,9 +259,9 @@ impl TryConvertNode<TestType> for RenderedMappingNode {
247259
match key_str {
248260
"python" => {
249261
let python = as_mapping(value, key_str)?.try_convert(key_str)?;
250-
test = TestType::Python{ python };
262+
test = TestType::Python { python };
251263
}
252-
"script" | "requirements" | "files" => {
264+
"script" | "requirements" | "files" => {
253265
let commands = self.try_convert(key_str)?;
254266
test = TestType::Command(commands);
255267
}
@@ -265,10 +277,14 @@ impl TryConvertNode<TestType> for RenderedMappingNode {
265277
let perl = as_mapping(value, key_str)?.try_convert(key_str)?;
266278
test = TestType::Perl { perl };
267279
}
280+
"r" => {
281+
let rscript = as_mapping(value, key_str)?.try_convert(key_str)?;
282+
test = TestType::R { r: rscript };
283+
}
268284
invalid => Err(vec![_partialerror!(
269285
*key.span(),
270286
ErrorKind::InvalidField(invalid.to_string().into()),
271-
help = format!("expected fields for {name} is one of `python`, `perl`, `script`, `downstream`, `package_contents`")
287+
help = format!("expected fields for {name} is one of `python`, `perl`, `r`, `script`, `downstream`, `package_contents`")
272288
)])?
273289
}
274290
Ok(())
@@ -414,6 +430,24 @@ impl TryConvertNode<PerlTest> for RenderedMappingNode {
414430
}
415431
}
416432

433+
///////////////////////////
434+
/// R Test ///
435+
///////////////////////////
436+
impl TryConvertNode<RTest> for RenderedMappingNode {
437+
fn try_convert(&self, _name: &str) -> Result<RTest, Vec<PartialParsingError>> {
438+
let mut rtest = RTest::default();
439+
validate_keys!(rtest, self.iter(), libraries);
440+
if rtest.libraries.is_empty() {
441+
Err(vec![_partialerror!(
442+
*self.span(),
443+
ErrorKind::MissingField("libraries".into()),
444+
help = "expected field `libraries` in R test to be a list of strings."
445+
)])?;
446+
}
447+
Ok(rtest)
448+
}
449+
}
450+
417451
///////////////////////////
418452
/// Package Contents ///
419453
///////////////////////////

src/script/interpreter/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod cmd_exe;
33
mod nushell;
44
mod perl;
55
mod python;
6+
mod r;
67

78
use std::path::PathBuf;
89

@@ -11,6 +12,7 @@ pub(crate) use cmd_exe::CmdExeInterpreter;
1112
pub(crate) use nushell::NuShellInterpreter;
1213
pub(crate) use perl::PerlInterpreter;
1314
pub(crate) use python::PythonInterpreter;
15+
pub(crate) use r::RInterpreter;
1416

1517
use rattler_conda_types::Platform;
1618
use rattler_shell::{

src/script/interpreter/r.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use std::path::PathBuf;
2+
3+
use rattler_conda_types::Platform;
4+
5+
use crate::script::{ExecutionArgs, ResolvedScriptContents};
6+
7+
use super::{BashInterpreter, CmdExeInterpreter, Interpreter, find_interpreter};
8+
9+
pub(crate) struct RInterpreter;
10+
11+
// R interpreter calls either bash or cmd.exe interpreter for activation and then runs R script
12+
impl Interpreter for RInterpreter {
13+
async fn run(&self, args: ExecutionArgs) -> Result<(), std::io::Error> {
14+
let script = args.script.script();
15+
let r_script = args.work_dir.join("conda_build_script.R");
16+
tokio::fs::write(&r_script, script).await?;
17+
let r_command = format!("Rscript {:?}", r_script);
18+
19+
let args = ExecutionArgs {
20+
script: ResolvedScriptContents::Inline(r_command),
21+
..args
22+
};
23+
24+
if cfg!(windows) {
25+
CmdExeInterpreter.run(args).await
26+
} else {
27+
BashInterpreter.run(args).await
28+
}
29+
}
30+
31+
async fn find_interpreter(
32+
&self,
33+
build_prefix: Option<&PathBuf>,
34+
platform: &Platform,
35+
) -> Result<Option<PathBuf>, which::Error> {
36+
find_interpreter("Rscript", build_prefix, platform)
37+
}
38+
}

src/script/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use futures::TryStreamExt;
88
use indexmap::IndexMap;
99
use interpreter::{
1010
BASH_PREAMBLE, BashInterpreter, CMDEXE_PREAMBLE, CmdExeInterpreter, NuShellInterpreter,
11-
PerlInterpreter, PythonInterpreter,
11+
PerlInterpreter, PythonInterpreter, RInterpreter,
1212
};
1313
use itertools::Itertools;
1414
use minijinja::Value;
@@ -356,6 +356,7 @@ impl Script {
356356
"cmd" => CmdExeInterpreter.run(exec_args).await?,
357357
"python" => PythonInterpreter.run(exec_args).await?,
358358
"perl" => PerlInterpreter.run(exec_args).await?,
359+
"rscript" => RInterpreter.run(exec_args).await?,
359360
_ => {
360361
return Err(std::io::Error::new(
361362
std::io::ErrorKind::Other,

src/utils.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ pub fn remove_dir_all_force(path: &Path) -> std::io::Result<()> {
125125
#[cfg(windows)]
126126
/// Retries clean up when encountered with OS 32 and OS 5 errors on Windows
127127
fn try_remove_with_retry(path: &Path, first_err: Option<std::io::Error>) -> std::io::Result<()> {
128-
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(5);
128+
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(10);
129129
let mut current_try = if first_err.is_some() { 1 } else { 0 };
130130
let mut last_err = first_err;
131131
let request_start = SystemTime::now();
@@ -144,7 +144,7 @@ fn try_remove_with_retry(path: &Path, first_err: Option<std::io::Error>) -> std:
144144
.duration_since(SystemTime::now())
145145
.unwrap_or(Duration::ZERO);
146146

147-
tracing::info!("Retrying deletion {}/{}: {}", current_try + 1, 5, e);
147+
tracing::info!("Retrying deletion {}/{}: {}", current_try + 1, 10, e);
148148
std::thread::sleep(sleep_for);
149149
}
150150
}
@@ -243,16 +243,22 @@ mod tests {
243243
let mut guard = file_handle_clone.lock().unwrap();
244244
*guard = None;
245245
});
246-
let result = try_remove_with_retry(&dir_path, Some(locked_file_error));
246+
247+
let dir_path_clone = dir_path.clone();
248+
let remove_result = std::thread::spawn(move || {
249+
try_remove_with_retry(&dir_path_clone, Some(locked_file_error))
250+
});
247251

248252
handle.join().unwrap();
253+
let result = remove_result.join().unwrap();
254+
std::thread::sleep(Duration::from_millis(1000));
255+
249256
assert!(
250257
result.is_ok(),
251258
"Directory removal failed: {:?}",
252259
result.err()
253260
);
254261

255-
std::thread::sleep(Duration::from_millis(200));
256262
assert!(!dir_path.exists(), "Directory still exists!");
257263

258264
Ok(())
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package:
2+
name: r-test
3+
version: 0.1.0
4+
5+
build:
6+
number: 0
7+
script:
8+
interpreter: rscript
9+
content: |
10+
print("Testing R Interpreter", quote = FALSE)
11+
12+
# Create test data and output file
13+
test_data <- data.frame(
14+
name = c("package1", "package2"),
15+
version = c("1.0", "2.0")
16+
)
17+
18+
# Load and use ggplot2
19+
library(ggplot2)
20+
p <- ggplot(test_data, aes(x = name, y = as.numeric(version))) +
21+
geom_bar(stat = "identity")
22+
23+
# Create output file
24+
output_file <- file.path(Sys.getenv("PREFIX"), "r-test-output.txt")
25+
writeLines(c(
26+
"This file was created by the R interpreter in rattler-build",
27+
"R test successful",
28+
paste("R version:", R.version.string),
29+
paste("PREFIX:", Sys.getenv("PREFIX"))
30+
), output_file)
31+
32+
requirements:
33+
host:
34+
- r-base
35+
- r-ggplot2
36+
run:
37+
- r-base
38+
- r-ggplot2
39+
40+
tests:
41+
- r:
42+
libraries:
43+
- ggplot2

test/end-to-end/__snapshots__/test_tests.ambr

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@
77

88
'''
99
# ---
10+
# name: test_r_tests
11+
'''
12+
- r:
13+
libraries:
14+
- ggplot2
15+
16+
'''
17+
# ---

test/end-to-end/test_simple.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,30 @@ def test_line_breaks(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path)
14981498
assert any("done" in line for line in output_lines)
14991499

15001500

1501+
def test_r_interpreter(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
1502+
rattler_build.build(recipes / "r-test", tmp_path)
1503+
pkg = get_extracted_package(tmp_path, "r-test")
1504+
1505+
assert (pkg / "r-test-output.txt").exists()
1506+
1507+
output_content = (pkg / "r-test-output.txt").read_text()
1508+
assert (
1509+
"This file was created by the R interpreter in rattler-build" in output_content
1510+
)
1511+
assert "R version:" in output_content
1512+
assert "PREFIX:" in output_content
1513+
assert (pkg / "info/recipe/recipe.yaml").exists()
1514+
assert (pkg / "info/tests/tests.yaml").exists()
1515+
1516+
# Verify index.json exists before running test
1517+
assert (pkg / "info/index.json").exists(), "index.json file missing from package"
1518+
1519+
pkg_file = get_package(tmp_path, "r-test")
1520+
test_result = rattler_build.test(pkg_file)
1521+
assert "Running R test" in test_result
1522+
assert "all tests passed!" in test_result
1523+
1524+
15011525
def test_channel_sources(
15021526
rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, monkeypatch
15031527
):

0 commit comments

Comments
 (0)