1use crate::{
4 error::PythonError, policy::Python3StudentFilePolicy, python_test_result::PythonTestResult,
5};
6use hmac::{Hmac, Mac};
7use once_cell::sync::Lazy;
8use rand::Rng;
9use sha2::Sha256;
10use std::{
11 collections::{HashMap, HashSet},
12 env,
13 ffi::OsStr,
14 io::{BufReader, Read, Seek},
15 ops::ControlFlow::{Break, Continue},
16 path::{Component, Path, PathBuf},
17 time::Duration,
18};
19use tmc_langs_framework::{
20 Archive, CommandError, ExerciseDesc, LanguagePlugin, Output, PythonVer, RunResult, RunStatus,
21 TestDesc, TestResult, TmcCommand, TmcError, TmcProjectYml,
22 nom::{IResult, Parser, bytes, character, combinator, sequence},
23 nom_language::error::VerboseError,
24};
25use tmc_langs_util::{
26 deserialize, file_util,
27 notification_reporter::{self, Notification},
28 parse_util, path_util,
29};
30use walkdir::WalkDir;
31
32pub struct Python3Plugin;
33
34impl Python3Plugin {
35 pub const fn new() -> Self {
36 Self
37 }
38
39 fn get_local_python_command() -> TmcCommand {
40 static LOCAL_PY: Lazy<LocalPy> = Lazy::new(|| {
42 if let Ok(python_exec) = env::var("TMC_LANGS_PYTHON_EXEC") {
43 log::debug!(
44 "using Python from environment variable TMC_LANGS_PYTHON_EXEC={python_exec}"
45 );
46 return LocalPy::Custom { python_exec };
47 }
48
49 if cfg!(windows) {
50 let conda = env::var("CONDA_PYTHON_EXE");
52 if let Ok(conda_path) = conda {
53 if PathBuf::from(&conda_path).exists() {
54 log::debug!("detected conda on windows");
55 return LocalPy::WindowsConda { conda_path };
56 }
57 }
58 log::debug!("detected windows");
59 LocalPy::Windows
60 } else {
61 log::debug!("detected unix");
62 LocalPy::Unix
63 }
64 });
65
66 enum LocalPy {
67 Unix,
68 Windows,
69 WindowsConda { conda_path: String },
70 Custom { python_exec: String },
71 }
72
73 match &*LOCAL_PY {
74 LocalPy::Unix => TmcCommand::piped("python3"),
75 LocalPy::Windows => TmcCommand::piped("py").with(|e| e.arg("-3")),
76 LocalPy::WindowsConda { conda_path } => TmcCommand::piped(conda_path),
77 LocalPy::Custom { python_exec } => TmcCommand::piped(python_exec),
78 }
79 }
80
81 fn get_local_python_ver() -> Result<(u32, u32, u32), PythonError> {
82 let output = Self::get_local_python_command()
83 .with(|e| e.args(&["-c", "import sys; print(sys.version_info.major); print(sys.version_info.minor); print(sys.version_info.micro);"]))
84 .output_checked()?;
85 let stdout = String::from_utf8_lossy(&output.stdout);
86 let mut lines = stdout.lines();
87 let major: u32 = lines
88 .next()
89 .ok_or_else(|| PythonError::VersionPrintError(stdout.clone().into_owned()))?
90 .trim()
91 .parse()
92 .map_err(|e| PythonError::VersionParseError(stdout.clone().into_owned(), e))?;
93 let minor: u32 = lines
94 .next()
95 .ok_or_else(|| PythonError::VersionPrintError(stdout.clone().into_owned()))?
96 .trim()
97 .parse()
98 .map_err(|e| PythonError::VersionParseError(stdout.clone().into_owned(), e))?;
99 let patch: u32 = lines
100 .next()
101 .ok_or_else(|| PythonError::VersionPrintError(stdout.clone().into_owned()))?
102 .trim()
103 .parse()
104 .map_err(|e| PythonError::VersionParseError(stdout.clone().into_owned(), e))?;
105
106 Ok((major, minor, patch))
107 }
108
109 fn run_tmc_command(
110 path: &Path,
111 extra_args: &[&str],
112 timeout: Option<Duration>,
113 stdin: Option<String>,
114 ) -> Result<Output, PythonError> {
115 let minimum = TmcProjectYml::load_or_default(path)?
116 .minimum_python_version
117 .unwrap_or_default()
118 .min();
119 let recommended = PythonVer::recommended();
120 let local = Self::get_local_python_ver()?;
121
122 if local < recommended {
123 notification_reporter::notify(Notification::warning(format!(
124 "Your Python is out of date. Minimum maintained release is {}.{}.{},\
125 your Python version was detected as {}.{}.{}. Updating to a newer release is recommended.",
126 minimum.0, minimum.1, minimum.2, local.0, local.1, local.2
127 )));
128 }
129 if local < minimum {
130 return Err(PythonError::OldPythonVersion {
131 found: format!("{}.{}.{}", local.0, local.1, local.2),
132 minimum_required: format!("{}.{}.{}", minimum.0, minimum.1, minimum.2),
133 });
134 }
135
136 let path = dunce::canonicalize(path)
137 .map_err(|e| PythonError::Canonicalize(path.to_path_buf(), e))?;
138 log::debug!("running tmc command at {}", path.display());
139 let common_args = ["-m", "tmc"];
140
141 let command = Self::get_local_python_command();
142 let command = command.with(|e| e.args(&common_args).args(extra_args).cwd(path));
143 let command = if let Some(stdin) = stdin {
144 command.set_stdin_data(stdin)
145 } else {
146 command
147 };
148
149 let output = if let Some(timeout) = timeout {
150 command.output_with_timeout(timeout)?
151 } else {
152 command.output()?
153 };
154
155 log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout));
156 log::debug!("stderr: {}", String::from_utf8_lossy(&output.stderr));
157 Ok(output)
158 }
159
160 fn parse_exercise_description(
162 available_points_json: &Path,
163 ) -> Result<Vec<TestDesc>, PythonError> {
164 let mut test_descs = vec![];
165 let file = file_util::open_file(available_points_json)?;
166 let json: HashMap<String, Vec<String>> =
168 deserialize::json_from_reader(BufReader::new(file))
169 .map_err(|e| PythonError::Deserialize(available_points_json.to_path_buf(), e))?;
170 for (key, value) in json {
171 test_descs.push(TestDesc::new(key, value));
172 }
173 Ok(test_descs)
174 }
175
176 fn parse_and_verify_test_result(
178 test_results_json: &Path,
179 hmac_data: Option<(String, String)>,
180 ) -> Result<(RunStatus, Vec<TestResult>), PythonError> {
181 let results = file_util::read_file_to_string(test_results_json)?;
182
183 if let Some((hmac_secret, test_runner_hmac_hex)) = hmac_data {
185 let mut mac = Hmac::<Sha256>::new_from_slice(hmac_secret.as_bytes())
186 .expect("HMAC can take key of any size");
187 mac.update(results.as_bytes());
188 let bytes =
189 hex::decode(test_runner_hmac_hex).map_err(|_| PythonError::UnexpectedHmac)?;
190 mac.verify_slice(&bytes)
191 .map_err(|_| PythonError::InvalidHmac)?;
192 }
193
194 let test_results: Vec<PythonTestResult> = deserialize::json_from_str(&results)
195 .map_err(|e| PythonError::Deserialize(test_results_json.to_path_buf(), e))?;
196
197 let mut status = RunStatus::Passed;
198 let mut failed_points = HashSet::new();
199 for result in &test_results {
200 if !result.passed {
201 status = RunStatus::TestsFailed;
202 failed_points.extend(result.points.iter().cloned());
203 }
204 }
205
206 let test_results: Vec<TestResult> = test_results
207 .into_iter()
208 .map(|r| r.into_test_result(&failed_points))
209 .collect();
210 Ok((status, test_results))
211 }
212}
213
214impl Default for Python3Plugin {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl LanguagePlugin for Python3Plugin {
225 const PLUGIN_NAME: &'static str = "python3";
226 const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-python:latest";
227 const LINE_COMMENT: &'static str = "#";
228 const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("\"\"\"", "\"\"\""));
229 type StudentFilePolicy = Python3StudentFilePolicy;
230
231 fn scan_exercise(
232 &self,
233 exercise_directory: &Path,
234 exercise_name: String,
235 ) -> Result<ExerciseDesc, TmcError> {
236 let available_points_json = exercise_directory.join(".available_points.json");
237 if available_points_json.exists() {
239 file_util::remove_file(&available_points_json)?;
240 }
241
242 if let Err(error) =
243 Self::run_tmc_command(exercise_directory, &["available_points"], None, None)
244 {
245 log::error!("Failed to scan exercise. {error}");
246 }
247
248 let test_descs_res = Self::parse_exercise_description(&available_points_json);
249 if available_points_json.exists() {
251 file_util::remove_file(&available_points_json)?;
252 }
253 let tests = test_descs_res?;
254 Ok(ExerciseDesc::new(exercise_name, tests))
255 }
256
257 fn run_tests_with_timeout(
258 &self,
259 exercise_directory: &Path,
260 timeout: Option<Duration>,
261 ) -> Result<RunResult, TmcError> {
262 let test_results_json = exercise_directory.join(".tmc_test_results.json");
263 if test_results_json.exists() {
265 file_util::remove_file(&test_results_json)?;
266 }
267
268 let (output, random_string) = if exercise_directory.join("tmc/hmac_writer.py").exists() {
269 let random_string: String = rand::rng()
271 .sample_iter(rand::distr::Alphanumeric)
272 .take(32)
273 .map(char::from)
274 .collect();
275 let output = Self::run_tmc_command(
276 exercise_directory,
277 &["--wait-for-secret"],
278 timeout,
279 Some(random_string.clone()),
280 );
281 (output, Some(random_string))
282 } else {
283 let output = Self::run_tmc_command(exercise_directory, &[], timeout, None);
284 (output, None)
285 };
286
287 match output {
288 Ok(output) => {
289 let stdout = String::from_utf8_lossy(&output.stdout);
290 let stderr = String::from_utf8_lossy(&output.stderr);
291 log::trace!("stdout: {stdout}");
292 log::debug!("stderr: {stderr}");
293
294 let hmac_data = if let Some(random_string) = random_string {
297 let hmac_result_path = exercise_directory.join(".tmc_test_results.hmac.sha256");
298 let test_runner_hmac = file_util::read_file_to_string(hmac_result_path)?;
299 Some((random_string, test_runner_hmac))
300 } else {
301 None
302 };
303
304 if !test_results_json.exists() {
305 return Err(PythonError::MissingTestResults {
306 path: test_results_json,
307 stdout: stdout.into_owned(),
308 stderr: stderr.into_owned(),
309 }
310 .into());
311 }
312 let (status, mut test_results) =
313 Self::parse_and_verify_test_result(&test_results_json, hmac_data)?;
314 file_util::remove_file(&test_results_json)?;
315
316 let mut failed_points = HashSet::new();
318 for test_result in &test_results {
319 if !test_result.successful {
320 failed_points.extend(test_result.points.iter().cloned());
321 }
322 }
323 for test_result in &mut test_results {
324 test_result.points.retain(|p| !failed_points.contains(p));
325 }
326
327 let mut logs = HashMap::new();
328 logs.insert("stdout".to_string(), stdout.into_owned());
329 logs.insert("stderr".to_string(), stderr.into_owned());
330 Ok(RunResult {
331 status,
332 test_results,
333 logs,
334 })
335 }
336 Err(PythonError::Tmc(TmcError::Command(CommandError::TimeOut {
337 stdout,
338 stderr,
339 ..
340 }))) => {
341 let mut logs = HashMap::new();
342 logs.insert("stdout".to_string(), stdout);
343 logs.insert("stderr".to_string(), stderr);
344 Ok(RunResult {
345 status: RunStatus::TestsFailed,
346 test_results: vec![TestResult {
347 name: "Timeout test".to_string(),
348 successful: false,
349 points: vec![],
350 message:
351 "Tests timed out.\nMake sure you don't have an infinite loop in your code."
352 .to_string(),
353 exception: vec![],
354 }],
355 logs,
356 })
357 }
358 Err(error) => Err(error.into()),
359 }
360 }
361
362 fn find_project_dir_in_archive<R: Read + Seek>(
363 archive: &mut Archive<R>,
364 ) -> Result<PathBuf, TmcError> {
365 let mut iter = archive.iter()?;
366 let mut shallowest_ipynb_path: Option<PathBuf> = None;
367
368 let project_dir = loop {
369 let next = iter.with_next(|file| {
370 let file_path = file.path()?;
372
373 if file_path
375 .components()
376 .map(Component::as_os_str)
377 .flat_map(OsStr::to_str)
378 .any(|c| c == "__pycache__")
379 {
380 return Ok(Continue(()));
381 }
382
383 if file.is_file() {
384 if file_path
386 .extension()
387 .and_then(OsStr::to_str)
388 .map(|ext| ext == "py")
389 .unwrap_or_default()
390 {
391 if let Some(parent) = file_path.parent() {
393 if let Some(parent) = path_util::get_parent_of_named(parent, "src") {
394 return Ok(Break(Some(parent)));
395 }
396 }
397 }
398 if let Some(parent) = path_util::get_parent_of_named(&file_path, "setup.py") {
399 return Ok(Break(Some(parent)));
400 }
401 if let Some(parent) =
402 path_util::get_parent_of_named(&file_path, "requirements.txt")
403 {
404 return Ok(Break(Some(parent)));
405 }
406 if let Some(parent) = path_util::get_parent_of_named(&file_path, "__init__.py")
407 {
408 if let Some(parent) = path_util::get_parent_of_named(&parent, "test") {
409 return Ok(Break(Some(parent)));
410 }
411 }
412 if let Some(parent) = path_util::get_parent_of_named(&file_path, "__main__.py")
413 {
414 if let Some(parent) = path_util::get_parent_of_named(&parent, "tmc") {
415 return Ok(Break(Some(parent)));
416 }
417 }
418 if file_path.extension() == Some(OsStr::new("ipynb"))
420 && !file_path.components().any(|c| c.as_os_str() == "__MACOSX")
421 {
422 if let Some(ipynb_path) = shallowest_ipynb_path.as_mut() {
423 if ipynb_path.components().count() > file_path.components().count() {
425 *ipynb_path = file_path
426 .parent()
427 .map(PathBuf::from)
428 .unwrap_or_else(|| PathBuf::from(""));
429 }
430 } else {
431 shallowest_ipynb_path = Some(
432 file_path
433 .parent()
434 .map(PathBuf::from)
435 .unwrap_or_else(|| PathBuf::from("")),
436 );
437 }
438 }
439 }
440 Ok(Continue(()))
441 });
442 match next? {
443 Continue(_) => continue,
444 Break(project_dir) => break project_dir,
445 }
446 };
447
448 match project_dir {
449 Some(project_dir) => Ok(project_dir),
450 None => {
451 if let Some(ipynb_path) = shallowest_ipynb_path {
453 Ok(ipynb_path)
454 } else {
455 Err(TmcError::NoProjectDirInArchive)
456 }
457 }
458 }
459 }
460
461 fn is_exercise_type_correct(path: &Path) -> bool {
462 let setup = path.join("setup.py");
463 let requirements = path.join("requirements.txt");
464 let test = path.join("test").join("__init__.py");
465 let tmc = path.join("tmc").join("__main__.py");
466
467 setup.exists() || requirements.exists() || test.exists() || tmc.exists()
468 }
469
470 fn clean(&self, exercise_path: &Path) -> Result<(), TmcError> {
471 for entry in WalkDir::new(exercise_path)
472 .into_iter()
473 .filter_map(|e| e.ok())
474 {
475 if entry.file_name() == ".available_points.json"
476 || entry.file_name() == ".tmc_test_results.json"
477 || entry.file_name() == "__pycache__"
478 {
479 if entry.path().is_file() {
480 file_util::remove_file(entry.path())?;
481 } else {
482 file_util::remove_dir_all(entry.path())?;
483 }
484 }
485 }
486 Ok(())
487 }
488
489 fn get_default_student_file_paths() -> Vec<PathBuf> {
490 vec![PathBuf::from("src")]
491 }
492
493 fn get_default_exercise_file_paths() -> Vec<PathBuf> {
494 vec![PathBuf::from("test"), PathBuf::from("tmc")]
495 }
496
497 fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
498 combinator::map(
499 sequence::delimited(
500 (
501 character::complete::char('@'),
502 character::complete::multispace0,
503 bytes::complete::tag_no_case("points"),
504 character::complete::multispace0,
505 character::complete::char('('),
506 character::complete::multispace0,
507 ),
508 parse_util::comma_separated_strings_either,
509 (
510 character::complete::multispace0,
511 character::complete::char(')'),
512 ),
513 ),
514 |points| {
516 points
517 .into_iter()
518 .flat_map(|p| p.split_whitespace())
519 .collect()
520 },
521 )
522 .parse(i)
523 }
524}
525
526#[cfg(test)]
527#[allow(clippy::unwrap_used)]
528mod test {
529 use super::*;
530 use std::{
531 io::Write,
532 path::{Path, PathBuf},
533 };
534 use tmc_langs_framework::{LanguagePlugin, RunStatus};
535 use zip::write::SimpleFileOptions;
536
537 fn init() {
538 use log::*;
539 use simple_logger::*;
540 let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
541 }
542
543 fn file_to(
544 target_dir: impl AsRef<std::path::Path>,
545 target_relative: impl AsRef<std::path::Path>,
546 contents: impl AsRef<[u8]>,
547 ) -> PathBuf {
548 let target = target_dir.as_ref().join(target_relative);
549 if let Some(parent) = target.parent() {
550 std::fs::create_dir_all(parent).unwrap();
551 }
552 std::fs::write(&target, contents.as_ref()).unwrap();
553 target
554 }
555
556 fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
557 let mut target = vec![];
558 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
559
560 for entry in walkdir::WalkDir::new(&source_dir)
561 .min_depth(1)
562 .sort_by(|a, b| a.path().cmp(b.path()))
563 {
564 let entry = entry.unwrap();
565 let rela = entry
566 .path()
567 .strip_prefix(&source_dir)
568 .unwrap()
569 .to_str()
570 .unwrap();
571 if entry.path().is_dir() {
572 zip.add_directory(rela, SimpleFileOptions::default())
573 .unwrap();
574 } else if entry.path().is_file() {
575 zip.start_file(rela, SimpleFileOptions::default()).unwrap();
576 let bytes = std::fs::read(entry.path()).unwrap();
577 zip.write_all(&bytes).unwrap();
578 }
579 }
580
581 zip.finish().unwrap();
582 target
583 }
584
585 fn temp_with_tmc() -> tempfile::TempDir {
586 let temp = tempfile::TempDir::new().unwrap();
587 for entry in walkdir::WalkDir::new("tests/data/tmc") {
588 let entry = entry.unwrap();
589 let rela = entry.path().strip_prefix("tests/data").unwrap();
590 let target = temp.path().join(rela);
591 if entry.path().is_dir() {
592 std::fs::create_dir(target).unwrap();
593 } else if entry.path().is_file() {
594 std::fs::copy(entry.path(), target).unwrap();
595 }
596 }
597 temp
598 }
599
600 #[test]
601 fn gets_local_python_command() {
602 init();
603
604 let _cmd = Python3Plugin::get_local_python_command();
605 }
606
607 #[test]
608 fn gets_local_python_ver() {
609 init();
610
611 let (_major, _minor, _patch) = Python3Plugin::get_local_python_ver().unwrap();
612 }
613
614 #[test]
615 fn parses_test_result() {
616 init();
617 }
618
619 #[test]
620 fn scans_exercise() {
621 init();
622
623 let temp_dir = temp_with_tmc();
624 file_to(&temp_dir, "test/__init__.py", "");
625 file_to(
626 &temp_dir,
627 "test/test_file.py",
628 r#"
629import unittest
630from tmc import points
631
632@points('1.1')
633class TestClass(unittest.TestCase):
634 @points('1.2', '2.2')
635 def test_func(self):
636 pass
637"#,
638 );
639
640 let plugin = Python3Plugin::new();
641 let ex_desc = plugin.scan_exercise(temp_dir.path(), "ex".into()).unwrap();
642 assert_eq!(ex_desc.name, "ex");
643 assert_eq!(&ex_desc.tests[0].name, "test.test_file.TestClass.test_func");
644 assert!(ex_desc.tests[0].points.contains(&"1.1".into()));
645 assert!(ex_desc.tests[0].points.contains(&"1.2".into()));
646 assert!(ex_desc.tests[0].points.contains(&"2.2".into()));
647 assert_eq!(ex_desc.tests[0].points.len(), 3);
648 }
649
650 #[test]
651 fn runs_tests_successful() {
652 init();
653
654 let temp_dir = temp_with_tmc();
655 file_to(&temp_dir, "test/__init__.py", "");
656 file_to(
657 &temp_dir,
658 "test/test_file.py",
659 r#"
660import unittest
661from tmc import points
662
663@points('1.1')
664class TestPassing(unittest.TestCase):
665 def test_func(self):
666 self.assertEqual("a", "a")
667"#,
668 );
669
670 let plugin = Python3Plugin::new();
671 let run_result = plugin.run_tests(temp_dir.path()).unwrap();
672 assert_eq!(run_result.status, RunStatus::Passed);
673 assert_eq!(run_result.test_results[0].name, "TestPassing: test_func");
674 assert!(run_result.test_results[0].successful);
675 assert!(run_result.test_results[0].points.contains(&"1.1".into()));
676 assert_eq!(run_result.test_results[0].points.len(), 1);
677 assert!(run_result.test_results[0].message.is_empty());
678 assert!(run_result.test_results[0].exception.is_empty());
679 assert_eq!(run_result.test_results.len(), 1);
680 }
681
682 #[test]
683 fn runs_tests_failure() {
684 init();
685
686 let temp_dir = temp_with_tmc();
687 file_to(&temp_dir, "test/__init__.py", "");
688 file_to(
689 &temp_dir,
690 "test/test_file.py",
691 r#"
692import unittest
693from tmc import points
694
695@points('1.1')
696class TestFailing(unittest.TestCase):
697 def test_func(self):
698 self.assertEqual("a", "b")
699"#,
700 );
701
702 let plugin = Python3Plugin::new();
703 let run_result = plugin.run_tests(temp_dir.path()).unwrap();
704 log::debug!("{run_result:#?}");
705 assert_eq!(run_result.status, RunStatus::TestsFailed);
706 assert_eq!(run_result.test_results[0].name, "TestFailing: test_func");
707 assert!(!run_result.test_results[0].successful);
708 assert!(run_result.test_results[0].message.starts_with("'a' != 'b'"));
709 assert!(!run_result.test_results[0].exception.is_empty());
710 assert_eq!(run_result.test_results.len(), 1);
711 }
712
713 #[test]
714 fn runs_tests_erroring() {
715 init();
716
717 let temp_dir = temp_with_tmc();
718 file_to(&temp_dir, "test/__init__.py", "");
719 file_to(
720 &temp_dir,
721 "test/test_file.py",
722 r#"
723import unittest
724from tmc import points
725
726@points('1.1')
727class TestErroring(unittest.TestCase):
728 def test_func(self):
729 doSomethingIllegal()
730"#,
731 );
732
733 let plugin = Python3Plugin::new();
734 let run_result = plugin.run_tests(temp_dir.path()).unwrap();
735 log::debug!("{run_result:#?}");
736 assert_eq!(run_result.status, RunStatus::TestsFailed);
737 assert_eq!(run_result.test_results[0].name, "TestErroring: test_func");
738 assert!(!run_result.test_results[0].successful);
739 assert_eq!(
740 run_result.test_results[0].message,
741 "name 'doSomethingIllegal' is not defined"
742 );
743 assert!(!run_result.test_results[0].exception.is_empty());
744 assert_eq!(run_result.test_results.len(), 1);
745 }
746
747 #[test]
748 fn runs_tests_timeout() {
749 init();
750
751 let temp_dir = temp_with_tmc();
752 file_to(&temp_dir, "test/__init__.py", "");
753 file_to(
754 &temp_dir,
755 "test/test_file.py",
756 r#"
757import unittest
758
759class TestErroring(unittest.TestCase):
760 pass
761"#,
762 );
763
764 let plugin = Python3Plugin::new();
765 let run_result = plugin
766 .run_tests_with_timeout(temp_dir.path(), Some(std::time::Duration::from_nanos(1)))
767 .unwrap();
768 assert_eq!(run_result.status, RunStatus::TestsFailed);
769 assert_eq!(run_result.test_results[0].name, "Timeout test");
770 assert!(
771 run_result.test_results[0]
772 .message
773 .starts_with("Tests timed out.")
774 );
775 }
776
777 #[test]
778 fn exercise_type_is_correct() {
779 init();
780
781 let temp_dir = tempfile::tempdir().unwrap();
782 file_to(&temp_dir, "setup.py", "");
783 assert!(Python3Plugin::is_exercise_type_correct(temp_dir.path()));
784
785 let temp_dir = tempfile::tempdir().unwrap();
786 file_to(&temp_dir, "requirements.txt", "");
787 assert!(Python3Plugin::is_exercise_type_correct(temp_dir.path()));
788
789 let temp_dir = tempfile::tempdir().unwrap();
790 file_to(&temp_dir, "test/__init__.py", "");
791 assert!(Python3Plugin::is_exercise_type_correct(temp_dir.path()));
792
793 let temp_dir = tempfile::tempdir().unwrap();
794 file_to(&temp_dir, "tmc/__main__.py", "");
795 assert!(Python3Plugin::is_exercise_type_correct(temp_dir.path()));
796 }
797
798 #[test]
799 fn exercise_type_is_not_correct() {
800 init();
801
802 let temp_dir = tempfile::tempdir().unwrap();
803 file_to(&temp_dir, "setup", "");
804 file_to(&temp_dir, "requirements.tt", "");
805 file_to(&temp_dir, "dir/setup.py", "");
806 file_to(&temp_dir, "dir/requirements.txt", "");
807 file_to(&temp_dir, "dir/test/__init__.py", "");
808 file_to(&temp_dir, "tmc/main.py", "");
809 assert!(!Python3Plugin::is_exercise_type_correct(temp_dir.path()));
810 }
811
812 #[test]
813 fn cleans() {
814 init();
815
816 let temp_dir = tempfile::tempdir().unwrap();
817 let f1 = file_to(&temp_dir, ".available_points.json", "");
818 let f2 = file_to(&temp_dir, "dir/.tmc_test_results.json", "");
819 let f3 = file_to(&temp_dir, "__pycache__/cachefile", "");
820 let f4 = file_to(&temp_dir, "leave", "");
821
822 assert!(f1.exists());
823 assert!(f2.exists());
824 assert!(f3.exists());
825 assert!(f4.exists());
826
827 Python3Plugin::new().clean(temp_dir.path()).unwrap();
828
829 assert!(!f1.exists());
830 assert!(!f2.exists());
831 assert!(!f3.exists());
832 assert!(f4.exists());
833 }
834
835 #[test]
836 fn parses_points() {
837 assert_eq!(
838 Python3Plugin::points_parser("@points('p1')").unwrap().1,
839 &["p1"]
840 );
841 assert_eq!(
842 Python3Plugin::points_parser("@ pOiNtS ( ' p2 ' ) ")
843 .unwrap()
844 .1,
845 &["p2"]
846 );
847 assert_eq!(
848 Python3Plugin::points_parser(r#"@points("p3")"#).unwrap().1,
849 &["p3"]
850 );
851 assert_eq!(
852 Python3Plugin::points_parser(r#"@points("p3", 'p4', "p5", "p6 p7")"#)
853 .unwrap()
854 .1,
855 &["p3", "p4", "p5", "p6", "p7"]
856 );
857 assert!(Python3Plugin::points_parser(r#"@points("p3')"#).is_err());
858 }
859
860 #[test]
861 fn finds_project_dir_in_zip() {
862 init();
863
864 let temp_dir = tempfile::tempdir().unwrap();
865 file_to(&temp_dir, "Outer/Inner/project/setup.py", "");
866
867 let bytes = dir_to_zip(&temp_dir);
868 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
869 let dir = Python3Plugin::find_project_dir_in_archive(&mut zip).unwrap();
870 assert_eq!(dir, Path::new("Outer/Inner/project"));
871 }
872
873 #[test]
874 fn doesnt_find_project_dir_in_zip() {
875 init();
876
877 let temp_dir = tempfile::tempdir().unwrap();
878 file_to(&temp_dir, "Outer/Inner/project/srcb/main.py", "");
879
880 let bytes = dir_to_zip(&temp_dir);
881 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
882 let res = Python3Plugin::find_project_dir_in_archive(&mut zip);
883 assert!(res.is_err());
884 }
885
886 #[test]
887 fn finds_project_dir_from_ipynb() {
888 init();
889
890 let temp_dir = tempfile::tempdir().unwrap();
891 file_to(&temp_dir, "inner/file.ipynb", "");
892 file_to(&temp_dir, "file.ipynb", "");
893
894 let bytes = dir_to_zip(&temp_dir);
895 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
896 let dir = Python3Plugin::find_project_dir_in_archive(&mut zip).unwrap();
897 assert_eq!(dir, Path::new(""));
898
899 let temp_dir = tempfile::tempdir().unwrap();
900 file_to(&temp_dir, "dir/inner/file.ipynb", "");
901 file_to(&temp_dir, "dir/file.ipynb", "");
902
903 let bytes = dir_to_zip(&temp_dir);
904 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
905 let dir = Python3Plugin::find_project_dir_in_archive(&mut zip).unwrap();
906 assert_eq!(dir, Path::new("dir"));
907 }
908
909 #[test]
910 fn doesnt_give_points_unless_all_relevant_exercises_pass() {
911 init();
912
913 let temp_dir = temp_with_tmc();
914 file_to(&temp_dir, "test/__init__.py", "");
915 file_to(
916 &temp_dir,
917 "test/test_file.py",
918 r#"
919import unittest
920from tmc import points
921
922@points('1')
923class TestClass(unittest.TestCase):
924 @points('1.1', '1.2')
925 def test_func1(self):
926 self.assertTrue(False)
927
928 @points('1.1', '1.3')
929 def test_func2(self):
930 self.assertTrue(True)
931"#,
932 );
933
934 let plugin = Python3Plugin::new();
935 let results = plugin.run_tests(temp_dir.path()).unwrap();
936 assert_eq!(results.status, RunStatus::TestsFailed);
937 let mut got_point = false;
938 for test in results.test_results {
939 got_point = got_point || test.points.contains(&"1.3".to_string());
940 assert!(!test.points.contains(&"1".to_string()));
941 assert!(!test.points.contains(&"1.1".to_string()));
942 assert!(!test.points.contains(&"1.2".to_string()));
943 }
944 assert!(got_point);
945 }
946
947 #[test]
948 fn verifies_test_results_success() {
949 init();
950
951 let mut temp = tempfile::NamedTempFile::new().unwrap();
952 temp.write_all(br#"[{"name": "test.test_hello_world.HelloWorld.test_first", "status": "passed", "message": "", "passed": true, "points": ["p01-01.1"], "backtrace": []}]"#).unwrap();
953
954 let hmac_secret = "047QzQx8RAYLR3lf0UfB75WX5EFnx7AV".to_string();
955 let test_runner_hmac =
956 "b379817c66cc7b1610d03ac263f02fa11f7b0153e6aeff3262ecc0598bf0be21".to_string();
957 Python3Plugin::parse_and_verify_test_result(
958 temp.path(),
959 Some((hmac_secret, test_runner_hmac)),
960 )
961 .unwrap();
962 }
963
964 #[test]
965 fn verifies_test_results_failure() {
966 init();
967
968 let mut temp = tempfile::NamedTempFile::new().unwrap();
969 temp.write_all(br#"[{"name": "test.test_hello_world.HelloWorld.test_first", "status": "passed", "message": "", "passed": true, "points": ["p01-01.1"], "backtrace": []}]"#).unwrap();
970
971 let hmac_secret = "047QzQx8RAYLR3lf0UfB75WX5EFnx7AV".to_string();
972 let test_runner_hmac =
973 "b379817c66cc7b1610d03ac263f02fa11f7b0153e6aeff3262ecc0598bf0be22".to_string();
974 let res = Python3Plugin::parse_and_verify_test_result(
975 temp.path(),
976 Some((hmac_secret, test_runner_hmac)),
977 );
978 assert!(res.is_err());
979 }
980}