Skip to content

Commit bb61fb7

Browse files
authored
Merge pull request #134 from rage/tmcprojectyml-inheritance
course refresh merges the root .tmcproject.yml with every exercise's
2 parents b520231 + e921229 commit bb61fb7

File tree

3 files changed

+157
-11
lines changed

3 files changed

+157
-11
lines changed

tmc-langs-framework/src/tmc_project_yml.rs

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
use crate::TmcError;
44
use serde::{
55
de::{Error, Visitor},
6-
Deserialize, Deserializer,
6+
Deserialize, Deserializer, Serialize,
77
};
88
use std::fmt;
9+
use std::ops::Deref;
910
use std::path::{Path, PathBuf};
10-
use tmc_langs_util::file_util;
11+
use tmc_langs_util::{file_util, FileError};
1112

1213
/// Extra data from a `.tmcproject.yml` file.
13-
#[derive(Debug, Deserialize, Default)]
14+
// NOTE: when adding fields, remember to update the merge function as well
15+
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
1416
pub struct TmcProjectYml {
1517
#[serde(default)]
1618
pub extra_student_files: Vec<PathBuf>,
@@ -36,9 +38,12 @@ pub struct TmcProjectYml {
3638
}
3739

3840
impl TmcProjectYml {
41+
fn path_in_dir(dir: &Path) -> PathBuf {
42+
dir.join(".tmcproject.yml")
43+
}
44+
3945
pub fn from(project_dir: &Path) -> Result<Self, TmcError> {
40-
let mut config_path = project_dir.to_owned();
41-
config_path.push(".tmcproject.yml");
46+
let config_path = Self::path_in_dir(project_dir);
4247

4348
if !config_path.exists() {
4449
log::trace!("no config found at {}", config_path.display());
@@ -50,11 +55,47 @@ impl TmcProjectYml {
5055
log::trace!("read {:#?}", config);
5156
Ok(config)
5257
}
58+
59+
/// Merges the contents of `with` with `self`.
60+
/// Empty or missing values in self are replaced with those from with. Other values are left unchanged.
61+
pub fn merge(&mut self, with: Self) {
62+
if self.extra_student_files.is_empty() {
63+
self.extra_student_files = with.extra_student_files;
64+
}
65+
if self.extra_exercise_files.is_empty() {
66+
self.extra_exercise_files = with.extra_exercise_files;
67+
}
68+
if self.force_update.is_empty() {
69+
self.force_update = with.force_update;
70+
}
71+
if self.tests_timeout_ms.is_none() {
72+
self.tests_timeout_ms = with.tests_timeout_ms;
73+
}
74+
if self.no_tests.is_none() {
75+
self.no_tests = with.no_tests;
76+
}
77+
if self.fail_on_valgrind_error.is_none() {
78+
self.fail_on_valgrind_error = with.fail_on_valgrind_error;
79+
}
80+
if self.minimum_python_version.is_none() {
81+
self.minimum_python_version = with.minimum_python_version;
82+
}
83+
}
84+
85+
pub fn save_to_dir(&self, dir: &Path) -> Result<(), TmcError> {
86+
let config_path = Self::path_in_dir(dir);
87+
let mut file = file_util::create_file_lock(&config_path)?;
88+
let guard = file
89+
.lock()
90+
.map_err(|e| FileError::FdLock(config_path.clone(), e))?;
91+
serde_yaml::to_writer(guard.deref(), &self)?;
92+
Ok(())
93+
}
5394
}
5495

5596
/// Minimum Python version requirement.
5697
/// TODO: if patch is Some minor is also guaranteed to be Some etc. encode this in the type system
57-
#[derive(Debug, Default)]
98+
#[derive(Debug, Default, Clone, Copy, Serialize)]
5899
pub struct PythonVer {
59100
pub major: Option<usize>,
60101
pub minor: Option<usize>,
@@ -112,7 +153,7 @@ impl<'de> Deserialize<'de> for PythonVer {
112153
}
113154

114155
/// Contents of the no-tests field.
115-
#[derive(Debug, Deserialize)]
156+
#[derive(Debug, Serialize, Deserialize, Clone)]
116157
#[serde(from = "NoTestsWrapper")]
117158
pub struct NoTests {
118159
pub flag: bool,
@@ -167,8 +208,16 @@ pub enum IntOrString {
167208
mod test {
168209
use super::*;
169210

211+
fn init() {
212+
use log::*;
213+
use simple_logger::*;
214+
let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
215+
}
216+
170217
#[test]
171218
fn deserialize_no_tests() {
219+
init();
220+
172221
let no_tests_yml = r#"no-tests:
173222
points:
174223
- 1
@@ -183,6 +232,8 @@ mod test {
183232

184233
#[test]
185234
fn deserialize_python_ver() {
235+
init();
236+
186237
let python_ver: PythonVer = serde_yaml::from_str("1.2.3").unwrap();
187238
assert_eq!(python_ver.major, Some(1));
188239
assert_eq!(python_ver.minor, Some(2));
@@ -200,4 +251,42 @@ mod test {
200251

201252
assert!(serde_yaml::from_str::<PythonVer>("asd").is_err())
202253
}
254+
255+
#[test]
256+
fn merges() {
257+
init();
258+
259+
let tpy_root = TmcProjectYml {
260+
tests_timeout_ms: Some(123),
261+
fail_on_valgrind_error: Some(true),
262+
..Default::default()
263+
};
264+
let mut tpy_exercise = TmcProjectYml {
265+
tests_timeout_ms: Some(234),
266+
..Default::default()
267+
};
268+
tpy_exercise.merge(tpy_root);
269+
assert_eq!(tpy_exercise.tests_timeout_ms, Some(234));
270+
assert_eq!(tpy_exercise.fail_on_valgrind_error, Some(true));
271+
}
272+
273+
#[test]
274+
fn saves_to_dir() {
275+
init();
276+
277+
let temp = tempfile::tempdir().unwrap();
278+
let path = TmcProjectYml::path_in_dir(temp.path());
279+
280+
assert!(!path.exists());
281+
282+
let tpy = TmcProjectYml {
283+
tests_timeout_ms: Some(1234),
284+
..Default::default()
285+
};
286+
tpy.save_to_dir(temp.path()).unwrap();
287+
288+
assert!(path.exists());
289+
let tpy = TmcProjectYml::from(temp.path()).unwrap();
290+
assert_eq!(tpy.tests_timeout_ms, Some(1234));
291+
}
203292
}

tmc-langs-util/src/file_util.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pub fn create_file<P: AsRef<Path>>(path: P) -> Result<File, FileError> {
112112
File::create(path).map_err(|e| FileError::FileCreate(path.to_path_buf(), e))
113113
}
114114

115-
/// Creates a file and immediately locks it. If a file already exists at the path, it acquires a lock on it first and then recreates it.
115+
/// Creates a file and wraps it in a lock. If a file already exists at the path, it acquires a lock on it first and then recreates it.
116116
/// Note: creates all intermediary directories if needed.
117117
pub fn create_file_lock<P: AsRef<Path>>(path: P) -> Result<FdLockWrapper, FileError> {
118118
log::debug!("locking file {}", path.as_ref().display());

tmc-langs/src/course_refresher.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use serde_yaml::Mapping;
77
use std::io::Write;
88
use std::path::{Path, PathBuf};
99
use std::time::Duration;
10-
use tmc_langs_framework::TmcCommand;
10+
use tmc_langs_framework::{TmcCommand, TmcProjectYml};
1111
use tmc_langs_util::file_util;
1212
use walkdir::WalkDir;
1313

@@ -44,7 +44,7 @@ pub fn refresh_course(
4444
cache_root: PathBuf,
4545
) -> Result<RefreshData, LangsError> {
4646
log::info!("refreshing course {}", course_name);
47-
start_stage(9, "Refreshing course");
47+
start_stage(10, "Refreshing course");
4848

4949
// create new cache path
5050
let old_version = course_cache_path
@@ -84,7 +84,13 @@ pub fn refresh_course(
8484
let exercise_dirs = super::find_exercise_directories(&new_clone_path)?
8585
.into_iter()
8686
.map(|ed| ed.strip_prefix(&new_clone_path).unwrap().to_path_buf()) // safe
87-
.collect();
87+
.collect::<Vec<_>>();
88+
89+
// merge the root config with each exercise's, if any
90+
if let Ok(root_tmcproject) = TmcProjectYml::from(&course_cache_path) {
91+
merge_tmcproject_configs(root_tmcproject, &exercise_dirs)?;
92+
}
93+
progress_stage("Merged .tmcproject.yml files in exercise directories to the root file, if any");
8894

8995
// make_solutions
9096
log::info!("preparing solutions to {}", new_solution_path.display());
@@ -215,6 +221,19 @@ fn check_directory_names(path: &Path) -> Result<(), LangsError> {
215221
Ok(())
216222
}
217223

224+
fn merge_tmcproject_configs(
225+
root_tmcproject: TmcProjectYml,
226+
exercise_dirs: &[PathBuf],
227+
) -> Result<(), LangsError> {
228+
for exercise_dir in exercise_dirs {
229+
if let Ok(mut exercise_tmcproject) = TmcProjectYml::from(exercise_dir) {
230+
exercise_tmcproject.merge(root_tmcproject.clone());
231+
exercise_tmcproject.save_to_dir(exercise_dir)?;
232+
}
233+
}
234+
Ok(())
235+
}
236+
218237
/// Checks for a course_clone_path/course_options.yml
219238
/// If found, course-specific options are merged into it and it is returned.
220239
/// Else, an empty mapping is returned.
@@ -579,4 +598,42 @@ mod test {
579598
.unwrap();
580599
assert_eq!(checksum, "6cacf02f21f9242674a876954132fb11");
581600
}
601+
602+
#[test]
603+
fn merges_tmcproject_configs() {
604+
init();
605+
606+
let temp = tempfile::tempdir().unwrap();
607+
let exap = temp.path().join("exa");
608+
file_util::create_dir(&exap).unwrap();
609+
let exbp = temp.path().join("exb");
610+
file_util::create_dir(&exbp).unwrap();
611+
612+
let root = TmcProjectYml {
613+
tests_timeout_ms: Some(1234),
614+
fail_on_valgrind_error: Some(true),
615+
..Default::default()
616+
};
617+
let tpya = TmcProjectYml {
618+
tests_timeout_ms: Some(2345),
619+
..Default::default()
620+
};
621+
tpya.save_to_dir(&exap).unwrap();
622+
let tpyb = TmcProjectYml {
623+
fail_on_valgrind_error: Some(false),
624+
..Default::default()
625+
};
626+
tpyb.save_to_dir(&exbp).unwrap();
627+
let exercise_dirs = &[exap.clone(), exbp.clone()];
628+
629+
merge_tmcproject_configs(root, exercise_dirs).unwrap();
630+
631+
let tpya = TmcProjectYml::from(&exap).unwrap();
632+
assert_eq!(tpya.tests_timeout_ms, Some(2345));
633+
assert_eq!(tpya.fail_on_valgrind_error, Some(true));
634+
635+
let tpyb = TmcProjectYml::from(&exbp).unwrap();
636+
assert_eq!(tpyb.tests_timeout_ms, Some(1234));
637+
assert_eq!(tpyb.fail_on_valgrind_error, Some(false));
638+
}
582639
}

0 commit comments

Comments
 (0)