py_env/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{io::Write, path::PathBuf};
4
5/// Error type.
6#[derive(Debug)]
7pub struct Error(Box<dyn std::error::Error + Send + Sync>);
8
9impl std::fmt::Display for Error {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        self.0.fmt(f)
12    }
13}
14
15impl std::error::Error for Error {
16    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
17        self.0.source()
18    }
19
20    fn description(&self) -> &str {
21        #![allow(deprecated)]
22        self.0.description()
23    }
24
25    fn cause(&self) -> Option<&dyn std::error::Error> {
26        #![allow(deprecated)]
27        self.0.cause()
28    }
29}
30
31/// Result type with a Boxed error type, for easy chaining of errors in the PyEnv struct
32pub type PyResult<T> = Result<T, Error>;
33
34/// A Python environment that can install packages and execute code.
35pub struct PyEnv {
36    path: PathBuf,
37    std_out: Box<dyn Fn(&str)>,
38    std_err: Box<dyn Fn(&str)>,
39    persistent: bool,
40} 
41
42impl Drop for PyEnv {
43    fn drop(&mut self) {
44        if !self.persistent {
45            if let Err(e) = std::fs::remove_dir_all(&self.path) {
46                eprintln!("Error deleting PyEnv at {}, cause: {}", self.path.display(), e);
47            }
48        }
49    }
50}
51
52impl PyEnv {
53    /// Constructor for piping stdout and stderr to a custom stream.
54    /// Use `at()` if you want to inherit the streams.
55    pub fn new(
56        path: impl Into<PathBuf>, 
57        std_out: impl Fn(&str) + 'static,
58        std_err: impl Fn(&str) + 'static,
59    ) -> Self {
60        let path = path.into();
61        let persistent = true;
62        let std_out = Box::new(std_out) as Box<dyn Fn(&str)>;
63        let std_err = Box::new(std_err) as Box<dyn Fn(&str)>;
64        Self { path, std_out, std_err, persistent }
65    }
66
67    /// Constructor inheriting default stdout and stderr; use `new()` to customize the streams.
68    pub fn at(path: impl Into<PathBuf>) -> Self {
69        let std_out = |line: &str| std::io::stdout().write_all((line.to_string() + "\n").as_bytes())
70            .expect("Error writing line to stdout");
71        let std_err = |line: &str| std::io::stdout().write_all((line.to_string() + "\n").as_bytes())
72            .expect("Error writing line to stderr");
73        Self::new(path, std_out, std_err)
74    }
75
76    fn stream_command(&self, command: &mut std::process::Command) -> PyResult<bool> {
77        use std::io::{BufReader, BufRead};
78
79        let mut command = command
80            .stdout(std::process::Stdio::piped())
81            .stderr(std::process::Stdio::piped())
82            .spawn()
83            .map_err(|e| Error(Box::new(e)))?;
84
85        command.stdout.as_mut().map(|stdout| {
86            let reader = BufReader::new(stdout);
87            reader.lines().for_each(|line| {
88                if let Ok(line) = line {
89                    (self.std_out)(&line);
90                }
91            });
92        });
93        command.stderr.as_mut().map(|stderr| {
94            let reader = BufReader::new(stderr);
95            reader.lines().for_each(|line| {
96                if let Ok(line) = line {
97                    (self.std_err)(&line);
98                }
99            });
100        });
101
102        let status = command.wait().map_err(|e| Error(Box::new(e)))?;
103        Ok(status.success())
104    }
105
106    /// Installs a package in the PyEnv, returning itself to easily chain dependencies.
107    pub fn install(&self, package_name: &str) -> PyResult<&Self> {
108        self.stream_command(std::process::Command::new("python")
109            .args([
110                "-m", 
111                "pip", 
112                "install",
113                package_name,
114                "--target",
115                self.path
116                    .join("site-packages")
117                    .as_os_str()
118                    .to_str()
119                    .ok_or_else(|| Error("Invalid path".into()))?])
120        )?;
121        Ok(&self)
122    }
123
124    // Panicking here should only happen upon failure to spawn or await the shell commands (code 
125    // execution failure returns `Ok(false)`). The implication of that is that these wrappers 
126    // should only panic upon being unable to execute commands in general, which is undefined 
127    // behaviour in the context of this lib.
128    //
129    // As such these wrappers will work for non-release code generally, but should be swapped
130    // for the PyResult versions in production code for proper error handling; it's just not
131    // necessary to do so for 99.9% of use cases.
132
133    /// An unwrapped `install()` run, which panics upon failure. See `install()` for the version
134    /// which returns a PyResult.
135    pub fn try_install(&self, package_name: &str) -> &Self {
136        self.stream_command(std::process::Command::new("python")
137            .args([
138                "-m", 
139                "pip", 
140                "install",
141                package_name,
142                "--target",
143                self.path
144                    .join("site-packages")
145                    .as_os_str()
146                    .to_str()
147                    .expect("Invalid path")])
148            ).unwrap();
149        &self
150    }
151    
152    /// Executes arbitrary code in the PyEnv, returning itself to easily chain runs.
153    pub fn execute(&self, code: &str) -> PyResult<&Self> {
154        std::env::set_var("PYTHONPATH", self.path.join("site-packages"));
155        self.stream_command(
156            std::process::Command::new("python")
157            .args(["-c", code])
158        )?;
159        Ok(&self)
160    }
161
162    /// An unwrapped `execute()` run, which panics upon failure. See `execute()` for the version
163    /// which returns a PyResult.
164    pub fn try_execute(&self, code: &str) -> &Self {
165        std::env::set_var("PYTHONPATH", self.path.join("site-packages"));
166        self.stream_command(
167            std::process::Command::new("python")
168            .args(["-c", code])
169        ).expect("Error executing code");
170        &self
171    }
172
173    /// Makes the environment impersistent beyond the PyEnv, deleting it upon dropping
174    pub fn persistent(&mut self, persistent: bool) -> &Self {
175        self.persistent = persistent;
176        self
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_install() -> PyResult<()> {
186        PyEnv::at("./py_test/install")
187            .install("faker")?;
188        Ok(())
189    }
190
191    #[test]
192    fn test_run() -> PyResult<()> {
193        PyEnv::at("./py_test/run")
194            .execute("print('hello world')")?;
195        Ok(())
196    }
197
198    #[test]
199    fn test_install_run() -> PyResult<()> {
200        PyEnv::at("./py_test/install_run")
201            .install("faker")?
202            .execute("import faker; print(faker.Faker().name())")?;
203        Ok(())
204    }
205
206    #[test]
207    fn test_impersistence() -> PyResult<()> {
208        PyEnv::at("./py_test/impersistence")
209            .persistent(false)
210            .install("faker")?;
211        Ok(())
212    }
213
214    #[test]
215    fn test_unwrapped_funcs() {
216        PyEnv::at("./py_test/unwrapped_funcs")
217            .try_install("faker")
218            .try_execute("import faker; print(faker.Faker().name())");
219    }
220
221    #[test]
222    fn test_fail_unwrapped_funcs() {
223        // Failure here doesn't panic; not really sure how to make it panic intentionally yet
224        // TODO: Add a #[should_panic] attribute and make this try wrapper panic for the test
225        PyEnv::at("./py_test/unwrapped_funcs")
226            .try_install(". .'] / .")
227            .try_execute("qb  fesaf af vv");
228    }
229}