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}