@@ -434,12 +434,120 @@ fn uv_install(venv: &std::path::Path, specs: &[String]) -> Result<()> {
434434fn parse_requirements_file ( path : & str ) -> Result < Vec < String > > {
435435 let content = std:: fs:: read_to_string ( path)
436436 . with_context ( || format ! ( "failed to read requirements file: {path}" ) ) ?;
437- Ok ( content
437+ Ok ( parse_requirements_text ( & content) )
438+ }
439+
440+ /// Parse requirements from text (requirements.txt format).
441+ fn parse_requirements_text ( content : & str ) -> Vec < String > {
442+ content
438443 . lines ( )
439444 . map ( |l| l. trim ( ) )
440445 . filter ( |l| !l. is_empty ( ) && !l. starts_with ( '#' ) && !l. starts_with ( '-' ) )
441446 . map ( |l| l. to_string ( ) )
442- . collect ( ) )
447+ . collect ( )
448+ }
449+
450+ /// Parse PEP 723 inline script metadata from a Python script.
451+ ///
452+ /// Looks for:
453+ /// # /// script
454+ /// # dependencies = ["torch", "numpy"]
455+ /// # ///
456+ fn parse_pep723_deps ( script_path : & std:: path:: Path ) -> Vec < String > {
457+ let content = match std:: fs:: read_to_string ( script_path) {
458+ Ok ( c) => c,
459+ Err ( _) => return Vec :: new ( ) ,
460+ } ;
461+
462+ // Extract the script metadata block
463+ let mut in_block = false ;
464+ let mut metadata = String :: new ( ) ;
465+ for line in content. lines ( ) {
466+ let trimmed = line. trim ( ) ;
467+ if trimmed == "# /// script" {
468+ in_block = true ;
469+ continue ;
470+ }
471+ if in_block {
472+ if trimmed == "# ///" {
473+ break ;
474+ }
475+ // Strip leading "# " prefix
476+ if let Some ( rest) = trimmed. strip_prefix ( "# " ) {
477+ metadata. push_str ( rest) ;
478+ } else if let Some ( rest) = trimmed. strip_prefix ( "#" ) {
479+ metadata. push_str ( rest) ;
480+ }
481+ metadata. push ( '\n' ) ;
482+ }
483+ }
484+
485+ if metadata. is_empty ( ) {
486+ return Vec :: new ( ) ;
487+ }
488+
489+ // Parse as TOML and extract dependencies
490+ if let Ok ( table) = metadata. parse :: < toml:: Value > ( ) {
491+ if let Some ( deps) = table. get ( "dependencies" ) . and_then ( |d| d. as_array ( ) ) {
492+ return deps
493+ . iter ( )
494+ . filter_map ( |v| v. as_str ( ) . map ( |s| s. to_string ( ) ) )
495+ . collect ( ) ;
496+ }
497+ }
498+
499+ Vec :: new ( )
500+ }
501+
502+ /// Auto-detect dependencies from the script's directory.
503+ ///
504+ /// Searches for pyproject.toml or requirements.txt in the script's directory
505+ /// and parent directories (up to 3 levels).
506+ fn auto_detect_deps ( script_path : & std:: path:: Path ) -> Vec < String > {
507+ let dir = script_path
508+ . parent ( )
509+ . unwrap_or_else ( || std:: path:: Path :: new ( "." ) ) ;
510+
511+ // Walk up to 3 parent directories
512+ let mut search_dir = dir. to_path_buf ( ) ;
513+ for _ in 0 ..4 {
514+ // Check pyproject.toml
515+ let pyproject = search_dir. join ( "pyproject.toml" ) ;
516+ if pyproject. exists ( ) {
517+ if let Ok ( content) = std:: fs:: read_to_string ( & pyproject) {
518+ if let Ok ( deps) = resolve:: parse_pyproject_dependencies ( & content) {
519+ if !deps. is_empty ( ) {
520+ tracing:: info!(
521+ "Auto-detected deps from {}" ,
522+ pyproject. display( )
523+ ) ;
524+ return deps;
525+ }
526+ }
527+ }
528+ }
529+
530+ // Check requirements.txt
531+ let reqs_txt = search_dir. join ( "requirements.txt" ) ;
532+ if reqs_txt. exists ( ) {
533+ if let Ok ( content) = std:: fs:: read_to_string ( & reqs_txt) {
534+ let deps = parse_requirements_text ( & content) ;
535+ if !deps. is_empty ( ) {
536+ tracing:: info!(
537+ "Auto-detected deps from {}" ,
538+ reqs_txt. display( )
539+ ) ;
540+ return deps;
541+ }
542+ }
543+ }
544+
545+ if !search_dir. pop ( ) {
546+ break ;
547+ }
548+ }
549+
550+ Vec :: new ( )
443551}
444552
445553/// Shared wheel cache directory: `$ZEROSTART_CACHE/shared_wheels/{name}-{version}/`
@@ -691,22 +799,43 @@ async fn main() -> Result<()> {
691799 } => {
692800 let python = find_python ( ) ?;
693801
694- // Build requirements list
802+ // Build requirements list from auto-detection + explicit flags
695803 let mut reqs: Vec < String > = Vec :: new ( ) ;
696- let is_script = target . ends_with ( ".py" )
697- || std :: path :: Path :: new ( & target ) . is_file ( ) ;
804+ let target_path = std :: path :: Path :: new ( & target ) ;
805+ let is_script = target . ends_with ( ".py" ) || target_path . is_file ( ) ;
698806
699807 if !is_script {
808+ // Target is a package name (e.g. "zerostart run torch")
700809 reqs. push ( target. clone ( ) ) ;
810+ } else {
811+ // Target is a script — auto-detect deps
812+ // Priority: PEP 723 inline metadata > pyproject.toml > requirements.txt
813+ let pep723 = parse_pep723_deps ( target_path) ;
814+ if !pep723. is_empty ( ) {
815+ if verbose {
816+ eprintln ! ( "Found PEP 723 deps: {}" , pep723. join( ", " ) ) ;
817+ }
818+ reqs. extend ( pep723) ;
819+ } else {
820+ let auto = auto_detect_deps ( target_path) ;
821+ if !auto. is_empty ( ) {
822+ if verbose {
823+ eprintln ! ( "Auto-detected deps: {}" , auto. join( ", " ) ) ;
824+ }
825+ reqs. extend ( auto) ;
826+ }
827+ }
701828 }
829+
830+ // Explicit flags are additive
702831 reqs. extend ( packages. clone ( ) ) ;
703832
704833 if let Some ( ref req_file) = requirements {
705834 reqs. extend ( parse_requirements_file ( req_file) ?) ;
706835 }
707836
708837 if reqs. is_empty ( ) {
709- // No deps — just exec the script directly
838+ // No deps found anywhere — just exec the script directly
710839 exec_python ( & python, std:: path:: Path :: new ( "." ) , & target, & target_args) ;
711840 }
712841
@@ -1109,3 +1238,154 @@ async fn run_engine(
11091238
11101239 Ok ( ( ) )
11111240}
1241+
1242+ #[ cfg( test) ]
1243+ mod tests {
1244+ use super :: * ;
1245+
1246+ #[ test]
1247+ fn test_parse_pep723_basic ( ) {
1248+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1249+ let script = dir. path ( ) . join ( "test.py" ) ;
1250+ std:: fs:: write (
1251+ & script,
1252+ r#"# /// script
1253+ # dependencies = ["torch", "numpy>=1.24"]
1254+ # ///
1255+
1256+ import torch
1257+ "# ,
1258+ )
1259+ . unwrap ( ) ;
1260+
1261+ let deps = parse_pep723_deps ( & script) ;
1262+ assert_eq ! ( deps, vec![ "torch" , "numpy>=1.24" ] ) ;
1263+ }
1264+
1265+ #[ test]
1266+ fn test_parse_pep723_no_block ( ) {
1267+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1268+ let script = dir. path ( ) . join ( "test.py" ) ;
1269+ std:: fs:: write ( & script, "import torch\n print('hello')\n " ) . unwrap ( ) ;
1270+
1271+ let deps = parse_pep723_deps ( & script) ;
1272+ assert ! ( deps. is_empty( ) ) ;
1273+ }
1274+
1275+ #[ test]
1276+ fn test_parse_pep723_empty_deps ( ) {
1277+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1278+ let script = dir. path ( ) . join ( "test.py" ) ;
1279+ std:: fs:: write (
1280+ & script,
1281+ "# /// script\n # dependencies = []\n # ///\n import os\n " ,
1282+ )
1283+ . unwrap ( ) ;
1284+
1285+ let deps = parse_pep723_deps ( & script) ;
1286+ assert ! ( deps. is_empty( ) ) ;
1287+ }
1288+
1289+ #[ test]
1290+ fn test_parse_pep723_multiline ( ) {
1291+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1292+ let script = dir. path ( ) . join ( "test.py" ) ;
1293+ std:: fs:: write (
1294+ & script,
1295+ r#"# /// script
1296+ # dependencies = [
1297+ # "torch>=2.0",
1298+ # "transformers",
1299+ # "safetensors",
1300+ # ]
1301+ # ///
1302+
1303+ import torch
1304+ "# ,
1305+ )
1306+ . unwrap ( ) ;
1307+
1308+ let deps = parse_pep723_deps ( & script) ;
1309+ assert_eq ! ( deps, vec![ "torch>=2.0" , "transformers" , "safetensors" ] ) ;
1310+ }
1311+
1312+ #[ test]
1313+ fn test_parse_requirements_text ( ) {
1314+ let text = "torch>=2.0\n # comment\n numpy\n \n -f https://url\n requests\n " ;
1315+ let deps = parse_requirements_text ( text) ;
1316+ assert_eq ! ( deps, vec![ "torch>=2.0" , "numpy" , "requests" ] ) ;
1317+ }
1318+
1319+ #[ test]
1320+ fn test_auto_detect_pyproject ( ) {
1321+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1322+ std:: fs:: write (
1323+ dir. path ( ) . join ( "pyproject.toml" ) ,
1324+ "[project]\n dependencies = [\" numpy\" , \" requests\" ]\n " ,
1325+ )
1326+ . unwrap ( ) ;
1327+ let script = dir. path ( ) . join ( "app.py" ) ;
1328+ std:: fs:: write ( & script, "import numpy\n " ) . unwrap ( ) ;
1329+
1330+ let deps = auto_detect_deps ( & script) ;
1331+ assert_eq ! ( deps, vec![ "numpy" , "requests" ] ) ;
1332+ }
1333+
1334+ #[ test]
1335+ fn test_auto_detect_requirements_txt ( ) {
1336+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1337+ std:: fs:: write (
1338+ dir. path ( ) . join ( "requirements.txt" ) ,
1339+ "numpy\n requests\n " ,
1340+ )
1341+ . unwrap ( ) ;
1342+ let script = dir. path ( ) . join ( "app.py" ) ;
1343+ std:: fs:: write ( & script, "import numpy\n " ) . unwrap ( ) ;
1344+
1345+ let deps = auto_detect_deps ( & script) ;
1346+ assert_eq ! ( deps, vec![ "numpy" , "requests" ] ) ;
1347+ }
1348+
1349+ #[ test]
1350+ fn test_auto_detect_prefers_pyproject_over_requirements ( ) {
1351+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1352+ std:: fs:: write (
1353+ dir. path ( ) . join ( "pyproject.toml" ) ,
1354+ "[project]\n dependencies = [\" from-pyproject\" ]\n " ,
1355+ )
1356+ . unwrap ( ) ;
1357+ std:: fs:: write (
1358+ dir. path ( ) . join ( "requirements.txt" ) ,
1359+ "from-requirements\n " ,
1360+ )
1361+ . unwrap ( ) ;
1362+ let script = dir. path ( ) . join ( "app.py" ) ;
1363+ std:: fs:: write ( & script, "pass\n " ) . unwrap ( ) ;
1364+
1365+ let deps = auto_detect_deps ( & script) ;
1366+ assert_eq ! ( deps, vec![ "from-pyproject" ] ) ;
1367+ }
1368+
1369+ #[ test]
1370+ fn test_auto_detect_walks_parent_dirs ( ) {
1371+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1372+ let subdir = dir. path ( ) . join ( "src" ) ;
1373+ std:: fs:: create_dir_all ( & subdir) . unwrap ( ) ;
1374+ std:: fs:: write ( dir. path ( ) . join ( "requirements.txt" ) , "numpy\n " ) . unwrap ( ) ;
1375+ let script = subdir. join ( "app.py" ) ;
1376+ std:: fs:: write ( & script, "import numpy\n " ) . unwrap ( ) ;
1377+
1378+ let deps = auto_detect_deps ( & script) ;
1379+ assert_eq ! ( deps, vec![ "numpy" ] ) ;
1380+ }
1381+
1382+ #[ test]
1383+ fn test_auto_detect_no_deps_found ( ) {
1384+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1385+ let script = dir. path ( ) . join ( "app.py" ) ;
1386+ std:: fs:: write ( & script, "print('hello')\n " ) . unwrap ( ) ;
1387+
1388+ let deps = auto_detect_deps ( & script) ;
1389+ assert ! ( deps. is_empty( ) ) ;
1390+ }
1391+ }
0 commit comments