Skip to main content

uv_python_interpreter/
virtualenv.rs

1use std::borrow::Cow;
2use std::str::FromStr;
3use std::{
4    io,
5    path::{Path, PathBuf},
6};
7
8use fs_err as fs;
9use thiserror::Error;
10
11use uv_pypi_types::Scheme;
12
13use uv_python_types::PythonVersion;
14
15/// The layout of a virtual environment.
16#[derive(Debug)]
17pub struct VirtualEnvironment {
18    /// The absolute path to the root of the virtualenv, e.g., `/path/to/.venv`.
19    pub root: PathBuf,
20
21    /// The path to the Python interpreter inside the virtualenv, e.g., `.venv/bin/python`
22    /// (Unix, Python 3.11).
23    pub executable: PathBuf,
24
25    /// The path to the base executable for the environment, within the `home` directory.
26    pub base_executable: PathBuf,
27
28    /// The [`Scheme`] paths for the virtualenv, as returned by (e.g.) `sysconfig.get_paths()`.
29    pub scheme: Scheme,
30}
31
32/// A parsed `pyvenv.cfg`
33#[derive(Debug, Clone)]
34pub struct PyVenvConfiguration {
35    /// The `PYTHONHOME` directory containing the base Python executable.
36    pub(super) home: Option<PathBuf>,
37    /// Was the virtual environment created with the `virtualenv` package?
38    pub(super) virtualenv: bool,
39    /// Was the virtual environment created with the `uv` package?
40    pub(super) uv: bool,
41    /// Is the virtual environment relocatable?
42    pub(super) relocatable: bool,
43    /// Was the virtual environment populated with seed packages?
44    pub(super) seed: bool,
45    /// Should the virtual environment include system site packages?
46    pub(super) include_system_site_packages: bool,
47    /// The Python version the virtual environment was created with
48    pub(super) version: Option<PythonVersion>,
49}
50
51#[derive(Debug, Error)]
52pub enum Error {
53    #[error(transparent)]
54    Io(#[from] io::Error),
55    #[error("Broken virtual environment `{0}`: missing `pyvenv.cfg`")]
56    MissingPyVenvCfg(PathBuf),
57    #[error("Broken virtual environment `{0}`: could not parse `pyvenv.cfg`")]
58    ParsePyVenvCfg(PathBuf, #[source] io::Error),
59}
60
61/// Returns the path to the `python` executable inside a virtual environment.
62pub fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
63    let venv = venv.as_ref();
64    if cfg!(windows) {
65        // Search for `python.exe` in the `Scripts` directory.
66        let default_executable = venv.join("Scripts").join("python.exe");
67        if default_executable.exists() {
68            return default_executable;
69        }
70
71        // Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout.
72        // See: https://github.com/PyO3/maturin/issues/1108
73        let executable = venv.join("bin").join("python.exe");
74        if executable.exists() {
75            return executable;
76        }
77
78        // Fallback for Conda environments.
79        let executable = venv.join("python.exe");
80        if executable.exists() {
81            return executable;
82        }
83
84        // If none of these exist, return the standard location
85        default_executable
86    } else {
87        // Prefer the unversioned `python` command exposed by virtual environments.
88        let default_executable = venv.join("bin").join("python");
89        if default_executable.exists() {
90            return default_executable;
91        }
92
93        // Fall back to `python3` for installations without an unversioned executable.
94        let executable = venv.join("bin").join("python3");
95        if executable.exists() {
96            return executable;
97        }
98
99        // If none of these exist, return the standard location
100        default_executable
101    }
102}
103
104impl PyVenvConfiguration {
105    /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`].
106    pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
107        let mut home = None;
108        let mut virtualenv = false;
109        let mut uv = false;
110        let mut relocatable = false;
111        let mut seed = false;
112        let mut include_system_site_packages = true;
113        let mut version = None;
114
115        // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a
116        // valid INI file, and is instead expected to be parsed by partitioning each line on the
117        // first equals sign.
118        let content = fs::read_to_string(&cfg)
119            .map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?;
120        for line in content.lines() {
121            let Some((key, value)) = line.split_once('=') else {
122                continue;
123            };
124            match key.trim() {
125                "home" => {
126                    home = Some(PathBuf::from(value.trim()));
127                }
128                "virtualenv" => {
129                    virtualenv = true;
130                }
131                "uv" => {
132                    uv = true;
133                }
134                "relocatable" => {
135                    relocatable = value.trim().to_lowercase() == "true";
136                }
137                "seed" => {
138                    seed = value.trim().to_lowercase() == "true";
139                }
140                "include-system-site-packages" => {
141                    include_system_site_packages = value.trim().to_lowercase() == "true";
142                }
143                "version" | "version_info" => {
144                    version = Some(
145                        PythonVersion::from_str(value.trim())
146                            .map_err(|e| io::Error::new(std::io::ErrorKind::InvalidData, e))?,
147                    );
148                }
149                _ => {}
150            }
151        }
152
153        Ok(Self {
154            home,
155            virtualenv,
156            uv,
157            relocatable,
158            seed,
159            include_system_site_packages,
160            version,
161        })
162    }
163
164    /// Returns true if the virtual environment was created with the `virtualenv` package.
165    pub fn is_virtualenv(&self) -> bool {
166        self.virtualenv
167    }
168
169    /// Returns true if the virtual environment was created with the uv package.
170    pub fn is_uv(&self) -> bool {
171        self.uv
172    }
173
174    /// Returns true if the virtual environment is relocatable.
175    pub(crate) fn is_relocatable(&self) -> bool {
176        self.relocatable
177    }
178
179    /// Returns true if the virtual environment was populated with seed packages.
180    pub fn is_seed(&self) -> bool {
181        self.seed
182    }
183
184    /// Returns true if the virtual environment should include system site packages.
185    pub fn include_system_site_packages(&self) -> bool {
186        self.include_system_site_packages
187    }
188
189    /// Set the key-value pair in the `pyvenv.cfg` file.
190    pub fn set(content: &str, key: &str, value: &str) -> String {
191        let mut lines = content.lines().map(Cow::Borrowed).collect::<Vec<_>>();
192        let mut found = false;
193        for line in &mut lines {
194            if let Some((lhs, _)) = line.split_once('=')
195                && lhs.trim() == key
196            {
197                *line = Cow::Owned(format!("{key} = {value}"));
198                found = true;
199                break;
200            }
201        }
202        if !found {
203            lines.push(Cow::Owned(format!("{key} = {value}")));
204        }
205        if lines.is_empty() {
206            String::new()
207        } else {
208            format!("{}\n", lines.join("\n"))
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215
216    use indoc::indoc;
217
218    use super::*;
219
220    #[test]
221    fn test_set_existing_key() {
222        let content = indoc! {"
223            home = /path/to/python
224            version = 3.8.0
225            include-system-site-packages = false
226        "};
227        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
228        assert_eq!(
229            result,
230            indoc! {"
231                home = /path/to/python
232                version = 3.9.0
233                include-system-site-packages = false
234            "}
235        );
236    }
237
238    #[test]
239    fn test_set_new_key() {
240        let content = indoc! {"
241            home = /path/to/python
242            version = 3.8.0
243        "};
244        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
245        assert_eq!(
246            result,
247            indoc! {"
248                home = /path/to/python
249                version = 3.8.0
250                include-system-site-packages = false
251            "}
252        );
253    }
254
255    #[test]
256    fn test_set_key_no_spaces() {
257        let content = indoc! {"
258            home=/path/to/python
259            version=3.8.0
260        "};
261        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
262        assert_eq!(
263            result,
264            indoc! {"
265                home=/path/to/python
266                version=3.8.0
267                include-system-site-packages = false
268            "}
269        );
270    }
271
272    #[test]
273    fn test_set_key_prefix() {
274        let content = indoc! {"
275            home = /path/to/python
276            home_dir = /other/path
277        "};
278        let result = PyVenvConfiguration::set(content, "home", "new/path");
279        assert_eq!(
280            result,
281            indoc! {"
282                home = new/path
283                home_dir = /other/path
284            "}
285        );
286    }
287
288    #[test]
289    fn test_set_empty_content() {
290        let content = "";
291        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
292        assert_eq!(
293            result,
294            indoc! {"
295                version = 3.9.0
296            "}
297        );
298    }
299}