@@ -9,7 +9,8 @@ use rand::Rng;
99use sha2:: Sha256 ;
1010use std:: collections:: { HashMap , HashSet } ;
1111use std:: env;
12- use std:: io:: BufReader ;
12+ use std:: ffi:: OsStr ;
13+ use std:: io:: { BufReader , Read , Seek } ;
1314use std:: path:: { Path , PathBuf } ;
1415use std:: time:: Duration ;
1516use tmc_langs_framework:: {
@@ -23,6 +24,7 @@ use tmc_langs_util::{
2324 parse_util,
2425} ;
2526use walkdir:: WalkDir ;
27+ use zip:: ZipArchive ;
2628
2729pub struct Python3Plugin { }
2830
@@ -343,6 +345,60 @@ impl LanguagePlugin for Python3Plugin {
343345 }
344346 }
345347
348+ /// Searches the zip for a valid project directory.
349+ /// Note that the returned path may not actually have an entry in the zip.
350+ /// Searches for either a src directory, or the most shallow directory containing an .ipynb file.
351+ /// Ignores everything in a __MACOSX directory.
352+ fn find_project_dir_in_zip < R : Read + Seek > (
353+ zip_archive : & mut ZipArchive < R > ,
354+ ) -> Result < PathBuf , TmcError > {
355+ let mut shallowest_ipynb_path: Option < PathBuf > = None ;
356+
357+ for i in 0 ..zip_archive. len ( ) {
358+ // zips don't necessarily contain entries for intermediate directories,
359+ // so we need to check every path for src
360+ let file = zip_archive. by_index ( i) ?;
361+ let file_path = Path :: new ( file. name ( ) ) ;
362+
363+ // todo: do in one pass somehow
364+ if file_path. components ( ) . any ( |c| c. as_os_str ( ) == "src" ) {
365+ let path: PathBuf = file_path
366+ . components ( )
367+ . take_while ( |c| c. as_os_str ( ) != "src" )
368+ . collect ( ) ;
369+
370+ if path. components ( ) . any ( |p| p. as_os_str ( ) == "__MACOSX" ) {
371+ continue ;
372+ }
373+ return Ok ( path) ;
374+ }
375+ if file_path. extension ( ) == Some ( OsStr :: new ( "ipynb" ) ) {
376+ if let Some ( ipynb_path) = shallowest_ipynb_path. as_mut ( ) {
377+ // make sure we maintain the shallowest ipynb path in the archive
378+ if ipynb_path. components ( ) . count ( ) > file_path. components ( ) . count ( ) {
379+ * ipynb_path = file_path
380+ . parent ( )
381+ . map ( PathBuf :: from)
382+ . unwrap_or_else ( || PathBuf :: from ( "" ) ) ;
383+ }
384+ } else {
385+ shallowest_ipynb_path = Some (
386+ file_path
387+ . parent ( )
388+ . map ( PathBuf :: from)
389+ . unwrap_or_else ( || PathBuf :: from ( "" ) ) ,
390+ ) ;
391+ }
392+ }
393+ }
394+ if let Some ( ipynb_path) = shallowest_ipynb_path {
395+ // no src found, use shallowest ipynb path
396+ Ok ( ipynb_path)
397+ } else {
398+ Err ( TmcError :: NoProjectDirInZip )
399+ }
400+ }
401+
346402 /// Checks if the directory has one of setup.py, requirements.txt., test/__init__.py, or tmc/__main__.py
347403 fn is_exercise_type_correct ( path : & Path ) -> bool {
348404 let setup = path. join ( "setup.py" ) ;
@@ -769,6 +825,29 @@ class TestErroring(unittest.TestCase):
769825 assert ! ( res. is_err( ) ) ;
770826 }
771827
828+ #[ test]
829+ fn finds_project_dir_from_ipynb ( ) {
830+ init ( ) ;
831+
832+ let temp_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
833+ file_to ( & temp_dir, "inner/file.ipynb" , "" ) ;
834+ file_to ( & temp_dir, "file.ipynb" , "" ) ;
835+
836+ let bytes = dir_to_zip ( & temp_dir) ;
837+ let mut zip = ZipArchive :: new ( std:: io:: Cursor :: new ( bytes) ) . unwrap ( ) ;
838+ let dir = Python3Plugin :: find_project_dir_in_zip ( & mut zip) . unwrap ( ) ;
839+ assert_eq ! ( dir, Path :: new( "" ) ) ;
840+
841+ let temp_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
842+ file_to ( & temp_dir, "dir/inner/file.ipynb" , "" ) ;
843+ file_to ( & temp_dir, "dir/file.ipynb" , "" ) ;
844+
845+ let bytes = dir_to_zip ( & temp_dir) ;
846+ let mut zip = ZipArchive :: new ( std:: io:: Cursor :: new ( bytes) ) . unwrap ( ) ;
847+ let dir = Python3Plugin :: find_project_dir_in_zip ( & mut zip) . unwrap ( ) ;
848+ assert_eq ! ( dir, Path :: new( "dir" ) ) ;
849+ }
850+
772851 #[ test]
773852 fn doesnt_give_points_unless_all_relevant_exercises_pass ( ) {
774853 init ( ) ;
0 commit comments