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}