tmc_langs_python3/
plugin.rs

1//! Contains the Python3Plugin struct
2
3use 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        // the correct python command is platform-dependent
41        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                // Check for Conda
51                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    /// Parse exercise description file
161    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        // TODO: deserialize directly into Vec<TestDesc>?
167        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    /// Parse test result file
177    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        // verify test results
184        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
220/// Project directory:
221/// Contains setup.py, requirements.txt, test/__init__.py, or tmc/__main__.py
222/// OR
223/// Contains an .ipynb file. This is given lower priority than the prior rule, and if there are multiple .ipynb files, the shallowest directory is returned.
224impl 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        // remove any existing points json
238        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        // remove file regardless of parse success
250        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        // remove any existing results json
264        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            // has hmac writer
270            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                // TODO: is it OK to not check output.status.success()?
295
296                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                // remove points associated with any failed tests
317                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                // archives don't necessarily contain entries for intermediate directories
371                let file_path = file.path()?;
372
373                // skip pycache paths
374                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                    // for all .py files...
385                    if file_path
386                        .extension()
387                        .and_then(OsStr::to_str)
388                        .map(|ext| ext == "py")
389                        .unwrap_or_default()
390                    {
391                        // check if the parent is src and return src's parent dir if so
392                        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                    // check for .ipynb file, ignore __MACOSX
419                    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                            // make sure we maintain the shallowest ipynb path in the archive
424                            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                // no src found, use shallowest ipynb path if any
452                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            // splits each point by whitespace
515            |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}