Skip to main content

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::io;
26use std::path::{Path, PathBuf};
27
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    dir: PathBuf,
67    _env: Vec<PushEnv<'a>>,
68}
69
70fn guess_python(sh: &Shell) -> Result<&'static str, Error> {
71    #[cfg(windows)]
72    {
73        if xshell::cmd!(sh, "python3.exe --version").read().is_ok() {
74            return Ok("python3.exe");
75        }
76
77        if let Ok(output) = xshell::cmd!(sh, "python.exe --version").read() {
78            if output.contains("Python 3.") {
79                return Ok("python.exe");
80            }
81        }
82    }
83
84    if xshell::cmd!(sh, "python3 --version").read().is_ok() {
85        return Ok("python3");
86    }
87
88    if let Ok(output) = xshell::cmd!(sh, "python --version").read() {
89        if output.contains("Python 3.") {
90            return Ok("python");
91        }
92    }
93
94    Err("couldn't find Python 3 in $PATH".into())
95}
96
97fn create_venv(sh: &Shell, path: &Path) -> Result<(), Error> {
98    // First create a lock file, so that multiple runs cannot overlap.
99    let lock_path = path.join("xshell-venv.lock");
100    sh.create_dir(path)?;
101    let mut f = FileLock::new(File::create(&lock_path)?);
102    let lock = f.write()?;
103
104    let python = guess_python(sh)?;
105
106    #[cfg(windows)]
107    let pybin = path.join("Scripts").join(python);
108    #[cfg(not(windows))]
109    let pybin = path.join("bin").join(python);
110    if !pybin.exists() {
111        xshell::cmd!(sh, "{python} -m venv {path}").run()?;
112    }
113
114    // Work is done. Drop the lock.
115    drop(lock);
116    sh.remove_path(lock_path)?;
117
118    Ok(())
119}
120
121fn find_directory(name: &str) -> PathBuf {
122    #[allow(clippy::never_loop)]
123    let mut venv_dir = loop {
124        // xshell-venv wants to be a good citizen,
125        // so by default it now writes into the folder it's supposed to write: `OUT_DIR`.
126        //
127        // This way different crates can depend on same-named venvs, that are entirely separate, as
128        // they should be.
129        // No more trying to find the directory and sharing that across multiple crates.
130        if let Ok(out_dir) = env::var("OUT_DIR") {
131            break PathBuf::from(out_dir);
132        }
133
134        // Create a `target/$venv` path next to where the project's `Cargo.toml` is located.
135        // That will create an occasional `target` directory, when none existed before,
136        // but I have no idea in what case `CARGO_MANIFEST_DIR` would be set
137        // but `OUT_DIR` isn't.
138        if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
139            let mut p = PathBuf::from(manifest_dir);
140            p.push("target");
141            break p;
142        }
143
144        // May be set by the user.
145        if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
146            break PathBuf::from(target_dir);
147        }
148
149        // As a last resort we use the host's temporary directory,
150        // so something like `/tmp`.
151        break env::temp_dir();
152    };
153
154    let name = format!("venv-{name}");
155    venv_dir.push(&name);
156    venv_dir
157}
158
159impl<'a> VirtualEnv<'a> {
160    /// Create a Python virtual environment with the given name.
161    ///
162    /// This creates a new environment or reuses an existing one.
163    /// Preserves the environment across calls and makes it available for all other commands
164    /// within the same [`Shell`].
165    ///
166    /// This will try to build a path based on the following environment variables:
167    ///
168    /// - `CARGO_TARGET_DIR`
169    /// - `OUT_DIR` 3 levels up<sup>1</sup>
170    /// - `CARGO_MANIFEST_DIR`
171    ///
172    /// _<sup>1</sup> should usually be the crate's/workspace's target directory._
173    ///
174    /// If none of these are set it will use the system's temporary directory, e.g. `/tmp`.
175    ///
176    /// ## Example
177    ///
178    /// ```
179    /// # use xshell;
180    /// # use xshell_venv::{Shell, VirtualEnv};
181    /// # fn main() -> xshell_venv::Result<()> {
182    /// let sh = Shell::new()?;
183    /// let venv = VirtualEnv::new(&sh, "py3")?;
184    /// # Ok(())
185    /// # }
186    /// ```
187    pub fn new(shell: &'a Shell, name: &str) -> Result<VirtualEnv<'a>, Error> {
188        let venv_dir = find_directory(name);
189
190        Self::with_path(shell, &venv_dir)
191    }
192
193    /// Create a Python virtual environment in the given path.
194    ///
195    /// This creates a new environment or reuses an existing one.
196    ///
197    /// ## Example
198    ///
199    /// ```rust
200    /// # use xshell_venv::{Shell, VirtualEnv};
201    /// # fn main() -> xshell_venv::Result<()> {
202    /// let sh = Shell::new()?;
203    ///
204    /// let mut dir = std::env::temp_dir();
205    /// dir.push("xshell-py3");
206    /// let venv = VirtualEnv::with_path(&sh, &dir)?;
207    ///
208    /// let output = venv.run("print('hello python')")?;
209    /// assert_eq!("hello python", output);
210    /// # Ok(())
211    /// # }
212    /// ```
213    pub fn with_path(shell: &'a Shell, venv_dir: &Path) -> Result<VirtualEnv<'a>, Error> {
214        create_venv(shell, venv_dir)?;
215
216        #[cfg(windows)]
217        const DEFAULT_PATH: &str = ""; // FIXME: Maybe actually HAVE a default path?
218        #[cfg(not(windows))]
219        const DEFAULT_PATH: &str = "/bin:/usr/bin";
220
221        #[cfg(not(windows))]
222        let bin_dir = venv_dir.join("bin");
223        #[cfg(windows)]
224        let bin_dir = venv_dir.join("Scripts");
225
226        let path = env::var("PATH").unwrap_or_else(|_| DEFAULT_PATH.to_string());
227        let path = env::split_paths(&path);
228        let path = env::join_paths([bin_dir].into_iter().chain(path)).unwrap();
229
230        let mut env = vec![];
231        env.push(shell.push_env("VIRTUAL_ENV", format!("{}", venv_dir.display())));
232        env.push(shell.push_env("PATH", path));
233
234        Ok(VirtualEnv {
235            shell,
236            dir: venv_dir.to_path_buf(),
237            _env: env,
238        })
239    }
240
241    /// Get the path of virtual environment directory.
242    pub fn dir(&self) -> &Path {
243        &self.dir
244    }
245
246    /// Install a Python package in this virtual environment.
247    ///
248    /// The package can be anything `pip` accepts,
249    /// including specifying the version (`$name==1.0.0`)
250    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
251    ///
252    /// ## Example
253    ///
254    /// ```rust,ignore
255    /// # use xshell_venv::{Shell, VirtualEnv};
256    /// # fn main() -> xshell_venv::Result<()> {
257    /// let sh = Shell::new()?;
258    /// let venv = VirtualEnv::new(&sh, "py3")?;
259    ///
260    /// venv.pip_install("ty")?;
261    /// let output = venv.run_module("ty", &["--version"])?;
262    /// assert!(output.contains("ty"));
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub fn pip_install(&self, package: &str) -> Result<()> {
267        cmd!(self.shell, "pip3 install {package}").run()?;
268        Ok(())
269    }
270
271    /// Upgrade a Python package in this virtual environment.
272    ///
273    /// The package can be anything `pip` accepts,
274    /// including specifying the version (`$name==1.0.0`)
275    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
276    ///
277    /// ## Example
278    ///
279    /// ```rust,ignore
280    /// # use xshell_venv::{Shell, VirtualEnv};
281    /// # fn main() -> xshell_venv::Result<()> {
282    /// let sh = Shell::new()?;
283    /// let venv = VirtualEnv::new(&sh, "py3")?;
284    ///
285    /// venv.pip_install("ty==0.0.64")?;
286    /// let output = venv.run_module("ty", &["--version"])?;
287    /// assert!(output.contains("0.0.64"), "Expected `0.0.64` in output. Got: {}", output);
288    ///
289    /// venv.pip_upgrade("ty")?;
290    /// let output = venv.run_module("ty", &["--version"])?;
291    /// assert!(!output.contains("0.0.64"), "Expected `0.0.64` NOT in output. Got: {}", output);
292    /// # Ok(())
293    /// # }
294    /// ```
295    pub fn pip_upgrade(&self, package: &str) -> Result<()> {
296        cmd!(self.shell, "pip3 install --upgrade {package}").run()?;
297        Ok(())
298    }
299
300    /// Run Python code in this virtual environment.
301    ///
302    /// Returns the code's output.
303    ///
304    /// ## Example
305    ///
306    /// ```
307    /// # use xshell_venv::{Shell, VirtualEnv};
308    /// # fn main() -> xshell_venv::Result<()> {
309    /// let sh = Shell::new()?;
310    /// let venv = VirtualEnv::new(&sh, "py3")?;
311    ///
312    /// let output = venv.run("print('hello python')")?;
313    /// assert_eq!("hello python", output);
314    /// # Ok(())
315    /// # }
316    /// ```
317    pub fn run(&self, code: &str) -> Result<String> {
318        let py = cmd!(self.shell, "python");
319
320        Ok(py.stdin(code).read()?)
321    }
322
323    /// Run library module as a script.
324    ///
325    /// This is `python -m $module`.
326    /// Additional arguments are passed through as is.
327    ///
328    /// ## Example
329    ///
330    /// ```
331    /// # use xshell_venv::{Shell, VirtualEnv};
332    /// # fn main() -> xshell_venv::Result<()> {
333    /// let sh = Shell::new()?;
334    /// let venv = VirtualEnv::new(&sh, "py3")?;
335    ///
336    /// let output = venv.run_module("pip", &["--version"])?;
337    /// assert!(output.contains("pip"));
338    /// # Ok(())
339    /// # }
340    /// ```
341    pub fn run_module(&self, module: &str, args: &[&str]) -> Result<String> {
342        let py = cmd!(self.shell, "python -m {module} {args...}");
343        Ok(py.read()?)
344    }
345}
346
347/// Advisory writer lock for files.
348///
349/// Wrapper around [`File::lock`], that calls [`File::unlock`] on drop.
350pub struct FileLock {
351    file: File,
352}
353
354impl FileLock {
355    pub fn new(file: File) -> Self {
356        FileLock { file }
357    }
358
359    pub fn write(&mut self) -> io::Result<FileLockWriteGuard<'_>> {
360        self.file.lock()?;
361        Ok(FileLockWriteGuard::new(&mut self.file))
362    }
363}
364
365pub struct FileLockWriteGuard<'lock> {
366    guard: &'lock mut File,
367}
368
369impl<'lock> FileLockWriteGuard<'lock> {
370    fn new(guard: &'lock mut File) -> Self {
371        FileLockWriteGuard { guard }
372    }
373}
374
375impl Drop for FileLockWriteGuard<'_> {
376    #[inline]
377    fn drop(&mut self) {
378        let _ = self.guard.unlock().ok();
379    }
380}
381
382#[cfg(all(unix, test))]
383mod test {
384    use super::*;
385
386    #[test]
387    fn multiple_venv() {
388        let sh = Shell::new().unwrap();
389        let script = "import sys; print(sys.prefix)";
390
391        let venv1 = VirtualEnv::new(&sh, "multiple_venv-1").unwrap();
392        let out1 = venv1.run(script).unwrap();
393
394        let venv2 = VirtualEnv::new(&sh, "multiple_venv-2").unwrap();
395        let out2 = venv2.run(script).unwrap();
396
397        assert_ne!(out1, out2);
398    }
399
400    #[test]
401    fn deactivate_on_drop() {
402        let sh = Shell::new().unwrap();
403        let script = "import sys; print(sys.prefix == sys.base_prefix)";
404
405        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
406        assert_eq!("True", out);
407
408        {
409            let venv = VirtualEnv::new(&sh, "deactivate_on_drop").unwrap();
410
411            let out = venv.run(script).unwrap();
412            assert_eq!("False", out);
413        }
414
415        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
416        assert_eq!("True", out);
417    }
418}