xshell-venv 1.1.0

xshell-venv manages your Python virtual environments in code.
Documentation
//! xshell-venv manages your Python virtual environments in code.
//!
//! This is an extension to [xshell], the swiss-army knife for writing cross-platform “bash” scripts in Rust.
//!
//! [xshell]: https://docs.rs/xshell/
//!
//! ## Example
//!
//! ```rust
//! use xshell_venv::{Shell, VirtualEnv};
//!
//! # fn main() -> xshell_venv::Result<()> {
//! let sh = Shell::new()?;
//! let venv = VirtualEnv::new(&sh, "py3")?;
//!
//! venv.run("print('Hello World!')")?; // "Hello World!"
//! # Ok(())
//! # }
//! ```

mod error;

use std::env;
use std::path::{Path, PathBuf};

use xshell::PushEnv;
pub use xshell::Shell;

pub use error::{Error, Result};

// xshell has no shell-wide `env_remove`, so we do it for every command.
macro_rules! cmd {
    ($sh:expr, $cmd:literal) => {{
        xshell::cmd!($sh, $cmd).env_remove("PYTHONHOME")
    }};
}

/// A Python virtual environment.
///
///
/// This creates or re-uses a virtual environment.
/// All Python invocations in this environment will have access to the environment's code,
/// including installed libraries and packages.
///
/// Use [`VirtualEnv::new`] to create a new environment.
///
/// The virtual environment gets deactivated on `Drop`.
///
/// ## Example
///
/// ```rust
/// use xshell_venv::{Shell, VirtualEnv};
///
/// # fn main() -> xshell_venv::Result<()> {
/// let sh = Shell::new()?;
/// let venv = VirtualEnv::new(&sh, "py3")?;
///
/// venv.run("print('Hello World!')")?; // "Hello World!"
/// # Ok(())
/// # }
/// ```
pub struct VirtualEnv<'a> {
    shell: &'a Shell,
    _env: Vec<PushEnv<'a>>,
}

fn guess_python(sh: &Shell) -> Result<&'static str, Error> {
    #[cfg(windows)]
    {
        if xshell::cmd!(sh, "python3.exe --version").run().is_ok() {
            return Ok("python3.exe");
        }

        if let Ok(output) = xshell::cmd!(sh, "python.exe --version").read() {
            if output.contains("Python 3.") {
                return Ok("python.exe");
            }
        }
    }

    if xshell::cmd!(sh, "python3 --version").run().is_ok() {
        return Ok("python3");
    }

    if let Ok(output) = xshell::cmd!(sh, "python --version").read() {
        if output.contains("Python 3.") {
            return Ok("python");
        }
    }

    Err("couldn't find Python 3 in $PATH".into())
}

fn create_venv(sh: &Shell, path: &Path) -> Result<(), Error> {
    let pybin = path.join("bin").join("python");
    if !pybin.exists() {
        let python = guess_python(sh)?;
        xshell::cmd!(sh, "{python} -m venv {path}").run()?;
    }
    Ok(())
}

fn find_directory(name: &str) -> PathBuf {
    #[allow(clippy::never_loop)]
    let mut venv_dir = loop {
        // May be set by the user.
        if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
            break PathBuf::from(target_dir);
        }

        // Find the `target/<arch>?/<profile> directory.`
        // `OUT_DIR` is usually something like
        // target/<arch>/debug/build/$cratename-$hash/out/,
        // so we strip out the last 3 ancestors.
        // This will be correct for plain crates, for workspaces
        // and even if the `TARGET_DIR` is not nested within the workspace.
        // Putting it there also means the venv stays available across builds.
        if let Ok(out_dir) = env::var("OUT_DIR") {
            let path = Path::new(&out_dir);
            let path = path
                .parent()
                .and_then(|p| p.parent())
                .and_then(|p| p.parent());
            if let Some(out_dir) = path {
                break PathBuf::from(out_dir);
            }
        }

        // Create a `target/$venv` path next to where the project's `Cargo.toml` is located.
        // That will create an occasional `target` directory, when none existed before,
        // but I have no idea in what case `CARGO_MANIFEST_DIR` would be set
        // but `OUT_DIR` isn't.
        if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
            let mut p = PathBuf::from(manifest_dir);
            p.push("target");
            break p;
        }

        // As a last resort we use the host's temporary directory,
        // so something like `/tmp`.
        break env::temp_dir();
    };

    let name = format!("venv-{name}");
    venv_dir.push(&name);
    venv_dir
}

impl<'a> VirtualEnv<'a> {
    /// Create a Python virtual environment with the given name.
    ///
    /// This creates a new environment or reuses an existing one.
    /// Preserves the environment across calls and makes it available for all other commands
    /// within the same [`Shell`].
    ///
    /// This will try to build a path based on the following environment variables:
    ///
    /// - `CARGO_TARGET_DIR`
    /// - `OUT_DIR` 3 levels up<sup>1</sup>
    /// - `CARGO_MANIFEST_DIR`
    ///
    /// _<sup>1</sup> should usually be the crate's/workspace's target directory._
    ///
    /// If none of these are set it will use the system's temporary directory, e.g. `/tmp`.
    ///
    /// ## Example
    ///
    /// ```
    /// # use xshell;
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    /// let venv = VirtualEnv::new(&sh, "py3")?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn new(shell: &'a Shell, name: &str) -> Result<VirtualEnv<'a>, Error> {
        let venv_dir = find_directory(name);

        Self::with_path(shell, &venv_dir)
    }

    /// Create a Python virtual environment in the given path.
    ///
    /// This creates a new environment or reuses an existing one.
    ///
    /// ## Example
    ///
    /// ```rust
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    ///
    /// let mut dir = std::env::temp_dir();
    /// dir.push("xshell-py3");
    /// let venv = VirtualEnv::with_path(&sh, &dir)?;
    ///
    /// let output = venv.run("print('hello python')")?;
    /// assert_eq!("hello python", output);
    /// # Ok(())
    /// # }
    /// ```
    pub fn with_path(shell: &'a Shell, venv_dir: &Path) -> Result<VirtualEnv<'a>, Error> {
        create_venv(shell, venv_dir)?;

        let path = env::var("PATH").unwrap_or_else(|_| "/bin:/usr/bin".to_string());
        let path = format!("{}/bin:{}", venv_dir.display(), path);

        let mut env = vec![];
        env.push(shell.push_env("VIRTUAL_ENV", format!("{}", venv_dir.display())));
        env.push(shell.push_env("PATH", path));

        Ok(VirtualEnv { shell, _env: env })
    }

    /// Install a Python package in this virtual environment.
    ///
    /// The package can be anything `pip` accepts,
    /// including specifying the version (`$name==1.0.0`)
    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
    ///
    /// ## Example
    ///
    /// ```rust,ignore
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    /// let venv = VirtualEnv::new(&sh, "py3")?;
    ///
    /// venv.pip_install("flake8")?;
    /// let output = venv.run_module("flake8", &["--version"])?;
    /// assert!(output.contains("flake"));
    /// # Ok(())
    /// # }
    /// ```
    pub fn pip_install(&self, package: &str) -> Result<()> {
        cmd!(self.shell, "pip3 install {package}").run()?;
        Ok(())
    }

    /// Upgrade a Python package in this virtual environment.
    ///
    /// The package can be anything `pip` accepts,
    /// including specifying the version (`$name==1.0.0`)
    /// or repositories (`git+https://github.com/$name/$repo@branch#egg=$name`).
    ///
    /// ## Example
    ///
    /// ```rust,ignore
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    /// let venv = VirtualEnv::new(&sh, "py3")?;
    ///
    /// venv.pip_install("flake8==3.9.2")?;
    /// let output = venv.run_module("flake8", &["--version"])?;
    /// assert!(output.contains("3.9.2"), "Expected `3.9.2` in output. Got: {}", output);
    ///
    /// venv.pip_upgrade("flake8")?;
    /// let output = venv.run_module("flake8", &["--version"])?;
    /// assert!(!output.contains("3.9.2"), "Expected `3.9.2` NOT in output. Got: {}", output);
    /// # Ok(())
    /// # }
    /// ```
    pub fn pip_upgrade(&self, package: &str) -> Result<()> {
        cmd!(self.shell, "pip3 install --upgrade {package}").run()?;
        Ok(())
    }

    /// Run Python code in this virtual environment.
    ///
    /// Returns the code's output.
    ///
    /// ## Example
    ///
    /// ```
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    /// let venv = VirtualEnv::new(&sh, "py3")?;
    ///
    /// let output = venv.run("print('hello python')")?;
    /// assert_eq!("hello python", output);
    /// # Ok(())
    /// # }
    /// ```
    pub fn run(&self, code: &str) -> Result<String> {
        let py = cmd!(self.shell, "python");

        Ok(py.stdin(code).read()?)
    }

    /// Run library module as a script.
    ///
    /// This is `python -m $module`.
    /// Additional arguments are passed through as is.
    ///
    /// ## Example
    ///
    /// ```
    /// # use xshell_venv::{Shell, VirtualEnv};
    /// # fn main() -> xshell_venv::Result<()> {
    /// let sh = Shell::new()?;
    /// let venv = VirtualEnv::new(&sh, "py3")?;
    ///
    /// let output = venv.run_module("pip", &["--version"])?;
    /// assert!(output.contains("pip"));
    /// # Ok(())
    /// # }
    /// ```
    pub fn run_module(&self, module: &str, args: &[&str]) -> Result<String> {
        let py = cmd!(self.shell, "python -m {module} {args...}");
        Ok(py.read()?)
    }
}

#[cfg(all(unix, test))]
mod test {
    use super::*;

    #[test]
    fn multiple_venv() {
        let sh = Shell::new().unwrap();
        let script = "import sys; print(sys.prefix)";

        let venv1 = VirtualEnv::new(&sh, "multiple_venv-1").unwrap();
        let out1 = venv1.run(script).unwrap();

        let venv2 = VirtualEnv::new(&sh, "multiple_venv-2").unwrap();
        let out2 = venv2.run(script).unwrap();

        assert_ne!(out1, out2);
    }

    #[test]
    fn deactivate_on_drop() {
        let sh = Shell::new().unwrap();
        let script = "import sys; print(sys.prefix == sys.base_prefix)";

        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
        assert_eq!("True", out);

        {
            let venv = VirtualEnv::new(&sh, "deactivate_on_drop").unwrap();

            let out = venv.run(script).unwrap();
            assert_eq!("False", out);
        }

        let out = cmd!(sh, "python3 -c {script}").read().unwrap();
        assert_eq!("True", out);
    }
}