Skip to main content

pro_core/script/
runner.rs

1//! Script runner for executing Python scripts with inline dependencies
2//!
3//! Handles PEP 723 scripts by creating ephemeral virtual environments
4//! and installing the declared dependencies before execution.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9
10use crate::installer::{default_cache_dir, Installer};
11use crate::lockfile::LockedPackage;
12use crate::pep::Requirement;
13use crate::resolver::Resolver;
14use crate::venv::VenvManager;
15use crate::{Error, Result};
16
17use super::parser::{parse_script_metadata, ScriptMetadata};
18
19/// Script runner for PEP 723 scripts
20pub struct ScriptRunner {
21    /// Cache directory for script venvs
22    cache_dir: PathBuf,
23    /// Optional Python executable to use
24    python: Option<PathBuf>,
25}
26
27impl ScriptRunner {
28    /// Create a new script runner with default cache directory
29    pub fn new() -> Result<Self> {
30        let cache_dir = dirs::cache_dir()
31            .ok_or_else(|| Error::Config("cannot determine cache directory".into()))?;
32
33        Ok(Self {
34            cache_dir: cache_dir.join("rx").join("scripts"),
35            python: None,
36        })
37    }
38
39    /// Create a script runner with a specific Python executable
40    pub fn with_python(python: PathBuf) -> Result<Self> {
41        let cache_dir = dirs::cache_dir()
42            .ok_or_else(|| Error::Config("cannot determine cache directory".into()))?;
43
44        Ok(Self {
45            cache_dir: cache_dir.join("rx").join("scripts"),
46            python: Some(python),
47        })
48    }
49
50    /// Create a script runner with a custom cache directory
51    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
52        Self {
53            cache_dir,
54            python: None,
55        }
56    }
57
58    /// Run a Python script, handling PEP 723 dependencies if present
59    pub async fn run(&self, script_path: &Path, args: &[String]) -> Result<ExitStatus> {
60        // Read the script
61        let content = fs::read_to_string(script_path).map_err(|e| {
62            Error::ScriptExecutionFailed(format!(
63                "failed to read script {}: {}",
64                script_path.display(),
65                e
66            ))
67        })?;
68
69        // Parse metadata
70        let metadata = parse_script_metadata(&content)?;
71
72        if metadata.is_empty() {
73            // No dependencies, run with system/project Python
74            return self.run_simple(script_path, args);
75        }
76
77        // Has dependencies - set up environment
78        self.run_with_deps(script_path, args, &metadata).await
79    }
80
81    /// Run a script without dependencies (simple execution)
82    fn run_simple(&self, script_path: &Path, args: &[String]) -> Result<ExitStatus> {
83        let python = self
84            .python
85            .clone()
86            .unwrap_or_else(|| PathBuf::from("python3"));
87
88        let status = Command::new(&python)
89            .arg(script_path)
90            .args(args)
91            .stdin(Stdio::inherit())
92            .stdout(Stdio::inherit())
93            .stderr(Stdio::inherit())
94            .status()
95            .map_err(|e| {
96                Error::ScriptExecutionFailed(format!("failed to execute script: {}", e))
97            })?;
98
99        Ok(status)
100    }
101
102    /// Run a script with PEP 723 dependencies
103    async fn run_with_deps(
104        &self,
105        script_path: &Path,
106        args: &[String],
107        metadata: &ScriptMetadata,
108    ) -> Result<ExitStatus> {
109        // Get or create the venv for this script's dependencies
110        let venv_path = self.get_or_create_venv(metadata).await?;
111
112        // Get the Python executable from the venv
113        let python = {
114            #[cfg(unix)]
115            {
116                venv_path.join("bin").join("python")
117            }
118            #[cfg(windows)]
119            {
120                venv_path.join("Scripts").join("python.exe")
121            }
122        };
123
124        // Set up environment
125        let bin_dir = {
126            #[cfg(unix)]
127            {
128                venv_path.join("bin")
129            }
130            #[cfg(windows)]
131            {
132                venv_path.join("Scripts")
133            }
134        };
135
136        let current_path = std::env::var("PATH").unwrap_or_default();
137        let new_path = format!(
138            "{}{}{}",
139            bin_dir.display(),
140            if cfg!(windows) { ";" } else { ":" },
141            current_path
142        );
143
144        // Execute the script
145        let status = Command::new(&python)
146            .arg(script_path)
147            .args(args)
148            .env("PATH", &new_path)
149            .env("VIRTUAL_ENV", &venv_path)
150            .env_remove("PYTHONHOME")
151            .stdin(Stdio::inherit())
152            .stdout(Stdio::inherit())
153            .stderr(Stdio::inherit())
154            .status()
155            .map_err(|e| {
156                Error::ScriptExecutionFailed(format!("failed to execute script: {}", e))
157            })?;
158
159        Ok(status)
160    }
161
162    /// Get or create a virtual environment for the given dependencies
163    async fn get_or_create_venv(&self, metadata: &ScriptMetadata) -> Result<PathBuf> {
164        let hash = metadata.dependency_hash();
165        let venv_path = self.cache_dir.join(&hash);
166
167        // Check if venv already exists and is valid
168        if self.is_venv_valid(&venv_path) {
169            tracing::debug!("Using cached script environment: {}", hash);
170            return Ok(venv_path);
171        }
172
173        tracing::info!(
174            "Creating script environment for {} dependencies...",
175            metadata.dependencies.len()
176        );
177
178        // Create the venv
179        fs::create_dir_all(&self.cache_dir).map_err(Error::Io)?;
180
181        // Remove any incomplete venv
182        if venv_path.exists() {
183            fs::remove_dir_all(&venv_path).map_err(Error::Io)?;
184        }
185
186        // Create virtual environment
187        let venv = VenvManager::new(&venv_path);
188        venv.create(self.python.as_deref())?;
189
190        // Parse dependencies into Requirements
191        let requirements: Vec<Requirement> = metadata
192            .dependencies
193            .iter()
194            .filter_map(|dep| Requirement::parse(dep).ok())
195            .collect();
196
197        if requirements.is_empty() && !metadata.dependencies.is_empty() {
198            return Err(Error::ScriptMetadataError(
199                "failed to parse script dependencies".into(),
200            ));
201        }
202
203        // Resolve dependencies
204        let resolver = Resolver::new();
205        let resolution = resolver.resolve(&requirements).await?;
206
207        // Convert to LockedPackage format for installer
208        let mut packages = std::collections::HashMap::new();
209        for pkg in resolution.packages {
210            packages.insert(
211                pkg.name.clone(),
212                LockedPackage {
213                    version: pkg.version.clone(),
214                    url: if pkg.url.is_empty() {
215                        None
216                    } else {
217                        Some(pkg.url.clone())
218                    },
219                    hash: if pkg.hash.is_empty() {
220                        None
221                    } else {
222                        Some(pkg.hash.clone())
223                    },
224                    dependencies: pkg.dependencies.clone(),
225                    markers: pkg.markers.clone(),
226                    files: vec![],
227                },
228            );
229        }
230
231        // Install packages
232        let site_packages = venv.site_packages()?;
233        let installer = Installer::new(default_cache_dir());
234        let results = installer.install(&packages, &site_packages).await?;
235        tracing::debug!("Installed {} packages", results.len());
236
237        Ok(venv_path)
238    }
239
240    /// Check if a cached venv is valid
241    fn is_venv_valid(&self, venv_path: &Path) -> bool {
242        if !venv_path.exists() {
243            return false;
244        }
245
246        // Check for pyvenv.cfg
247        if !venv_path.join("pyvenv.cfg").exists() {
248            return false;
249        }
250
251        // Check for Python executable
252        #[cfg(unix)]
253        let python = venv_path.join("bin").join("python");
254        #[cfg(windows)]
255        let python = venv_path.join("Scripts").join("python.exe");
256
257        python.exists()
258    }
259
260    /// Clear the script cache
261    pub fn clear_cache(&self) -> Result<usize> {
262        if !self.cache_dir.exists() {
263            return Ok(0);
264        }
265
266        let mut count = 0;
267        for entry in fs::read_dir(&self.cache_dir).map_err(Error::Io)? {
268            let entry = entry.map_err(Error::Io)?;
269            if entry.path().is_dir() {
270                fs::remove_dir_all(entry.path()).map_err(Error::Io)?;
271                count += 1;
272            }
273        }
274
275        Ok(count)
276    }
277
278    /// List cached script environments
279    pub fn list_cached(&self) -> Result<Vec<PathBuf>> {
280        if !self.cache_dir.exists() {
281            return Ok(Vec::new());
282        }
283
284        let mut cached = Vec::new();
285        for entry in fs::read_dir(&self.cache_dir).map_err(Error::Io)? {
286            let entry = entry.map_err(Error::Io)?;
287            if entry.path().is_dir() {
288                cached.push(entry.path());
289            }
290        }
291
292        Ok(cached)
293    }
294}
295
296/// Check if a file is a Python script that might have PEP 723 metadata
297pub fn is_pep723_script(path: &Path) -> Result<bool> {
298    // Must have .py extension
299    if path.extension().map_or(true, |e| e != "py") {
300        return Ok(false);
301    }
302
303    // Read first few KB to check for metadata block
304    let content = fs::read_to_string(path).map_err(Error::Io)?;
305
306    Ok(super::parser::might_have_metadata(&content))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use tempfile::tempdir;
313
314    #[test]
315    fn test_script_runner_new() {
316        let runner = ScriptRunner::new();
317        assert!(runner.is_ok());
318    }
319
320    #[test]
321    fn test_is_venv_valid_nonexistent() {
322        let temp_dir = tempdir().unwrap();
323        let runner = ScriptRunner::with_cache_dir(temp_dir.path().to_path_buf());
324
325        assert!(!runner.is_venv_valid(&temp_dir.path().join("nonexistent")));
326    }
327
328    #[test]
329    fn test_list_cached_empty() {
330        let temp_dir = tempdir().unwrap();
331        let runner = ScriptRunner::with_cache_dir(temp_dir.path().to_path_buf());
332
333        let cached = runner.list_cached().unwrap();
334        assert!(cached.is_empty());
335    }
336}