xshell_venv/
lib.rs

1//! xshell-venv manages your Python virtual environments in code.
2//!
3//! This is an extension to [xshell], the swiss-army knife for writing cross-platform “bash” scripts in Rust.
4//!
5//! [xshell]: https://docs.rs/xshell/
6//!
7//! ## Example
8//!
9//! ```rust
10//! use xshell_venv::{Shell, VirtualEnv};
11//!
12//! # fn main() -> xshell_venv::Result<()> {
13//! let sh = Shell::new()?;
14//! let venv = VirtualEnv::new(&sh, "py3")?;
15//!
16//! venv.run("print('Hello World!')")?; // "Hello World!"
17//! # Ok(())
18//! # }
19//! ```
20
21mod error;
22
23use std::env;
24use std::fs::File;
25use std::path::{Path, PathBuf};
26
27use fd_lock::RwLock;
28use xshell::PushEnv;
29pub use xshell::Shell;
30
31pub use error::{Error, Result};
32
33// xshell has no shell-wide `env_remove`, so we do it for every command.
34macro_rules! cmd {
35    ($sh:expr, $cmd:literal) => {{
36        xshell::cmd!($sh, $cmd).env_remove("PYTHONHOME")
37    }};
38}
39
40/// A Python virtual environment.
41///
42///
43/// This creates or re-uses a virtual environment.
44/// All Python invocations in this environment will have access to the environment's code,
45/// including installed libraries and packages.
46///
47/// Use [`VirtualEnv::new`] to create a new environment.
48///
49/// The virtual environment gets deactivated on `Drop`.
50///
51/// ## Example
52///
53/// ```rust
54/// use xshell_venv::{Shell, VirtualEnv};
55///
56/// # fn main() -> xshell_venv::Result<()> {
57/// let sh = Shell::new()?;
58/// let venv = VirtualEnv::new(&sh, "py3")?;
59///
60/// venv.run("print('Hello World!')")?; // "Hello World!"
61/// # Ok(())
62/// # }
63/// ```
64pub struct VirtualEnv<'a> {
65    shell: &'a Shell,
66    _env: Vec<PushEnv<'a>>,
67}
68
69fn guess_python(sh: &Shell) -> Result<&'static str, Error> {
70    #[cfg(windows)]
71    {
72        if xshell::cmd!(sh, "python3.exe --version").run().is_ok() {
73            return Ok("python3.exe");
74        }
75
76        if let Ok(output) = xshell::cmd!(sh, "python.exe --version").read() {
77            if output.contains("Python 3.") {
78                return Ok("python.exe");
79            }
80        }
81    }
82
83    if xshell::cmd!(sh, "python3 --version").run().is_ok() {
84        return Ok("python3");
85    }
86
87    if let Ok(output) = xshell::cmd!(sh, "python --version").read() {
88        if output.contains("Python 3.") {
89            return Ok("python");
90        }
91    }
92
93    Err("couldn't find Python 3 in $PATH".into())
94}
95
96fn create_venv(sh: &Shell, path: &Path) -> Result<(), Error> {
97    // First create a lock file, so that multiple runs cannot overlap.
98    let lock_path = path.join("xshell-venv.lock");
99    sh.create_dir(path)?;
100    let mut f = RwLock::new(File::create(&lock_path)?);
101    let lock = f.write()?;
102
103    let python = guess_python(sh)?;
104
105    #[cfg(windows)]
106    let pybin = path.join("Scripts").join(python);
107    #[cfg(not(windows))]
108    let pybin = path.join("bin").join(python);
109    if !pybin.exists() {
110        xshell::cmd!(sh, "{python} -m venv {path}").run()?;
111    }
112
113    // Work is done. Drop the lock.
114    sh.remove_path(lock_path)?;
115    drop(lock);
116
117    Ok(())
118}
119
120fn find_directory(name: &str) -> PathBuf {
121    #[allow(clippy::never_loop)]
122    let mut venv_dir = loop {
123        // May be set by the user.
124        if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
125            break PathBuf::from(target_dir);
126        }
127
128        // Find the `target/<arch>?/<profile> directory.`
129        // `OUT_DIR` is usually something like
130        // target/<arch>/debug/build/$cratename-$hash/out/,
131        // so we strip out the last 3 ancestors.
132        // This will be correct for plain crates, for workspaces
133        // and even if the `TARGET_DIR` is not nested within the workspace.
134        // Putting it there also means the venv stays available across builds.
135        if let Ok(out_dir) = env::var("OUT_DIR") {
136            let path = Path::new(&out_dir);
137            let path = path
138                .parent()
139                .and_then(|p| p.parent())
140                .and_then(|p| p.parent());
141            if let Some(out_dir) = path {
142                break PathBuf::from(out_dir);
143            }
144        }
145
146        // Create a `target/$venv` path next to where the project's `Cargo.toml` is located.
147        // That will create an occasional `target` directory, when none existed before,
148        // but I have no idea in what case `CARGO_MANIFEST_DIR` would be set
149        // but `OUT_DIR` isn't.
150        if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
151            let mut p = PathBuf::from(manifest_dir);
152            p.push("target");
153            break p;
154        }
155
156        // As a last resort we use the host's temporary directory,
157        // so something like `/tmp`.
158        break env::temp_dir();
159    };
160
161    let name = format!("venv-{name}");
162    venv_dir.push(&name);
163    venv_dir
164}
165
166impl<'a> VirtualEnv<'a> {
167    /// Create a Python virtual environment with the given name.
168    ///
169    /// This creates a new environment or reuses an existing one.
170    /// Preserves the environment across calls and makes it available for all other commands
171    /// within the same [`Shell`].
172    ///
173    /// This will try to build a path based on the following environment variables:
174    ///
175    /// - `CARGO_TARGET_DIR`
176    /// - `OUT_DIR` 3 levels up<sup>1</sup>
177    /// - `CARGO_MANIFEST_DIR`
178    ///
179    /// _<sup>1</sup> should usually be the crate's/workspace's target directory._
180    ///
181    /// If none of these are set it will use the system's temporary directory, e.g. `/tmp`.
182    ///
183    /// ## Example
184    ///
185    /// ```
186    /// # use xshell;
187    /// # use xshell_venv::{Shell, VirtualEnv};
188    /// # fn main() -> xshell_venv::Result<()> {
189    /// let sh = Shell::new()?;
190    /// let venv = VirtualEnv::new(&sh, "py3")?;
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn new(shell: &'a Shell, name: &str) -> Result<VirtualEnv<'a>, Error> {
195        let venv_dir = find_directory(name);
196
197        Self::with_path(shell, &venv_dir)
198    }
199
200    /// Create a Python virtual environment in the given path.
201    ///
202    /// This creates a new environment or reuses an existing one.
203    ///
204    /// ## Example
205    ///
206    /// ```rust
207    /// # use xshell_venv::{Shell, VirtualEnv};
208    /// # fn main() -> xshell_venv::Result<()> {
209    /// let sh = Shell::new()?;
210    ///
211    /// let mut dir = std::env::temp_dir();
212    /// dir.push("xshell-py3");
213    /// let venv = VirtualEnv::with_path(&sh, &dir)?;
214    ///
215    /// let output = venv.run("print('hello python')")?;
216    /// assert_eq!("hello python", output);
217    /// # Ok(())
218    /// # }
219    /// ```
220    pub fn with_path(shell: &'a Shell, venv_dir: &Path) -> Result<VirtualEnv<'a>, Error> {
221        create_venv(shell, venv_dir)?;
222
223        #[cfg(windows)]
224        const DEFAULT_PATH: &str = ""; // FIXME: Maybe actually HAVE a default path?
225        #[cfg(not(windows))]
226        const DEFAULT_PATH: &str = "/bin:/usr/bin";
227
228        #[cfg(not(windows))]
229        let bin_dir = venv_dir.join("bin");
230        #[cfg(windows)]
231        let bin_dir = venv_dir.join("Scripts");
232
233        let path = env::var("PATH").unwrap_or_else(|_| DEFAULT_PATH.to_string());
234        let path = env::split_paths(&path);
235        let path = env::join_paths([bin_dir].into_iter().chain(path)).unwrap();
236
237        let mut env = vec![];
238        env.push(shell.push_env("VIRTUAL_ENV", format!("{}", venv_dir.display())));
239        env.push(shell.push_env("PATH", path));
240
241        Ok(VirtualEnv { shell, _env: env })
242    }
243
244    /// Install a Python package in this virtual environment.
245    ///
246    /// The package can be anything `pip` accepts,
247    /// including specifying the version (`$name==1.0.0`)
248    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
249    ///
250    /// ## Example
251    ///
252    /// ```rust,ignore
253    /// # use xshell_venv::{Shell, VirtualEnv};
254    /// # fn main() -> xshell_venv::Result<()> {
255    /// let sh = Shell::new()?;
256    /// let venv = VirtualEnv::new(&sh, "py3")?;
257    ///
258    /// venv.pip_install("flake8")?;
259    /// let output = venv.run_module("flake8", &["--version"])?;
260    /// assert!(output.contains("flake"));
261    /// # Ok(())
262    /// # }
263    /// ```
264    pub fn pip_install(&self, package: &str) -> Result<()> {
265        cmd!(self.shell, "pip3 install {package}").run()?;
266        Ok(())
267    }
268
269    /// Upgrade a Python package in this virtual environment.
270    ///
271    /// The package can be anything `pip` accepts,
272    /// including specifying the version (`$name==1.0.0`)
273    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
274    ///
275    /// ## Example
276    ///
277    /// ```rust,ignore
278    /// # use xshell_venv::{Shell, VirtualEnv};
279    /// # fn main() -> xshell_venv::Result<()> {
280    /// let sh = Shell::new()?;
281    /// let venv = VirtualEnv::new(&sh, "py3")?;
282    ///
283    /// venv.pip_install("flake8==3.9.2")?;
284    /// let output = venv.run_module("flake8", &["--version"])?;
285    /// assert!(output.contains("3.9.2"), "Expected `3.9.2` in output. Got: {}", output);
286    ///
287    /// venv.pip_upgrade("flake8")?;
288    /// let output = venv.run_module("flake8", &["--version"])?;
289    /// assert!(!output.contains("3.9.2"), "Expected `3.9.2` NOT in output. Got: {}", output);
290    /// # Ok(())
291    /// # }
292    /// ```
293    pub fn pip_upgrade(&self, package: &str) -> Result<()> {
294        cmd!(self.shell, "pip3 install --upgrade {package}").run()?;
295        Ok(())
296    }
297
298    /// Run Python code in this virtual environment.
299    ///
300    /// Returns the code's output.
301    ///
302    /// ## Example
303    ///
304    /// ```
305    /// # use xshell_venv::{Shell, VirtualEnv};
306    /// # fn main() -> xshell_venv::Result<()> {
307    /// let sh = Shell::new()?;
308    /// let venv = VirtualEnv::new(&sh, "py3")?;
309    ///
310    /// let output = venv.run("print('hello python')")?;
311    /// assert_eq!("hello python", output);
312    /// # Ok(())
313    /// # }
314    /// ```
315    pub fn run(&self, code: &str) -> Result<String> {
316        let py = cmd!(self.shell, "python");
317
318        Ok(py.stdin(code).read()?)
319    }
320
321    /// Run library module as a script.
322    ///
323    /// This is `python -m $module`.
324    /// Additional arguments are passed through as is.
325    ///
326    /// ## Example
327    ///
328    /// ```
329    /// # use xshell_venv::{Shell, VirtualEnv};
330    /// # fn main() -> xshell_venv::Result<()> {
331    /// let sh = Shell::new()?;
332    /// let venv = VirtualEnv::new(&sh, "py3")?;
333    ///
334    /// let output = venv.run_module("pip", &["--version"])?;
335    /// assert!(output.contains("pip"));
336    /// # Ok(())
337    /// # }
338    /// ```
339    pub fn run_module(&self, module: &str, args: &[&str]) -> Result<String> {
340        let py = cmd!(self.shell, "python -m {module} {args...}");
341        Ok(py.read()?)
342    }
343}
344
345#[cfg(all(unix, test))]
346mod test {
347    use super::*;
348
349    #[test]
350    fn multiple_venv() {
351        let sh = Shell::new().unwrap();
352        let script = "import sys; print(sys.prefix)";
353
354        let venv1 = VirtualEnv::new(&sh, "multiple_venv-1").unwrap();
355        let out1 = venv1.run(script).unwrap();
356
357        let venv2 = VirtualEnv::new(&sh, "multiple_venv-2").unwrap();
358        let out2 = venv2.run(script).unwrap();
359
360        assert_ne!(out1, out2);
361    }
362
363    #[test]
364    fn deactivate_on_drop() {
365        let sh = Shell::new().unwrap();
366        let script = "import sys; print(sys.prefix == sys.base_prefix)";
367
368        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
369        assert_eq!("True", out);
370
371        {
372            let venv = VirtualEnv::new(&sh, "deactivate_on_drop").unwrap();
373
374            let out = venv.run(script).unwrap();
375            assert_eq!("False", out);
376        }
377
378        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
379        assert_eq!("True", out);
380    }
381}