Skip to content

Commit 78b4e73

Browse files
Auto-detect deps from PEP 723, pyproject.toml, and requirements.txt
zerostart run serve.py now works without flags by auto-detecting dependencies from: 1. PEP 723 inline script metadata (# /// script block) 2. pyproject.toml [project.dependencies] in script dir or parents 3. requirements.txt in script dir or parents -p and -r flags are additive on top of auto-detected deps. Includes 10 unit tests covering all parsers and detection logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ff0df6 commit 78b4e73

1 file changed

Lines changed: 286 additions & 6 deletions

File tree

crates/zs-fast-wheel/src/main.rs

Lines changed: 286 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -434,12 +434,120 @@ fn uv_install(venv: &std::path::Path, specs: &[String]) -> Result<()> {
434434
fn 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\nprint('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# ///\nimport 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\nnumpy\n\n-f https://url\nrequests\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]\ndependencies = [\"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\nrequests\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]\ndependencies = [\"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

Comments
 (0)