Skip to main content

uv_python/
virtualenv.rs

1use std::borrow::Cow;
2use std::str::FromStr;
3use std::{
4    env, io,
5    path::{Path, PathBuf},
6};
7
8use fs_err as fs;
9use thiserror::Error;
10
11use uv_pypi_types::Scheme;
12use uv_static::EnvVars;
13
14use crate::PythonVersion;
15
16/// The layout of a virtual environment.
17#[derive(Debug)]
18pub struct VirtualEnvironment {
19    /// The absolute path to the root of the virtualenv, e.g., `/path/to/.venv`.
20    pub root: PathBuf,
21
22    /// The path to the Python interpreter inside the virtualenv, e.g., `.venv/bin/python`
23    /// (Unix, Python 3.11).
24    pub executable: PathBuf,
25
26    /// The path to the base executable for the environment, within the `home` directory.
27    pub base_executable: PathBuf,
28
29    /// The [`Scheme`] paths for the virtualenv, as returned by (e.g.) `sysconfig.get_paths()`.
30    pub scheme: Scheme,
31}
32
33/// A parsed `pyvenv.cfg`
34#[derive(Debug, Clone)]
35pub struct PyVenvConfiguration {
36    /// The `PYTHONHOME` directory containing the base Python executable.
37    pub(super) home: Option<PathBuf>,
38    /// Was the virtual environment created with the `virtualenv` package?
39    pub(super) virtualenv: bool,
40    /// Was the virtual environment created with the `uv` package?
41    pub(super) uv: bool,
42    /// Is the virtual environment relocatable?
43    pub(super) relocatable: bool,
44    /// Was the virtual environment populated with seed packages?
45    pub(super) seed: bool,
46    /// Should the virtual environment include system site packages?
47    pub(super) include_system_site_packages: bool,
48    /// The Python version the virtual environment was created with
49    pub(super) version: Option<PythonVersion>,
50}
51
52#[derive(Debug, Error)]
53pub enum Error {
54    #[error(transparent)]
55    Io(#[from] io::Error),
56    #[error("Broken virtual environment `{0}`: `pyvenv.cfg` is missing")]
57    MissingPyVenvCfg(PathBuf),
58    #[error("Broken virtual environment `{0}`: `pyvenv.cfg` could not be parsed")]
59    ParsePyVenvCfg(PathBuf, #[source] io::Error),
60}
61
62/// Locate an active virtual environment by inspecting environment variables.
63///
64/// Supports `VIRTUAL_ENV`.
65pub(crate) fn virtualenv_from_env() -> Option<PathBuf> {
66    if let Some(dir) = env::var_os(EnvVars::VIRTUAL_ENV).filter(|value| !value.is_empty()) {
67        return Some(PathBuf::from(dir));
68    }
69
70    None
71}
72
73#[derive(Debug, PartialEq, Eq, Copy, Clone)]
74pub(crate) enum CondaEnvironmentKind {
75    /// The base Conda environment; treated like a system Python environment.
76    Base,
77    /// Any other Conda environment; treated like a virtual environment.
78    Child,
79}
80
81impl CondaEnvironmentKind {
82    /// Whether the given `CONDA_PREFIX` path is the base Conda environment.
83    ///
84    /// The base environment is typically stored in a location matching the `_CONDA_ROOT` path.
85    ///
86    /// Additionally, when the base environment is active, `CONDA_DEFAULT_ENV` will be set to a
87    /// name, e.g., `base`, which does not match the `CONDA_PREFIX`, e.g., `/usr/local` instead of
88    /// `/usr/local/conda/envs/<name>`. Note the name `CONDA_DEFAULT_ENV` is misleading, it's the
89    /// active environment name, not a constant base environment name.
90    fn from_prefix_path(path: &Path) -> Self {
91        // Pixi never creates true "base" envs and names project envs "default", confusing our
92        // heuristics, so treat Pixi prefixes as child envs outright.
93        if is_pixi_environment(path) {
94            return Self::Child;
95        }
96
97        // If `_CONDA_ROOT` is set and matches `CONDA_PREFIX`, it's the base environment.
98        if let Ok(conda_root) = env::var(EnvVars::CONDA_ROOT) {
99            if path == Path::new(&conda_root) {
100                return Self::Base;
101            }
102        }
103
104        // Next, we'll use a heuristic based on `CONDA_DEFAULT_ENV`
105        let Ok(current_env) = env::var(EnvVars::CONDA_DEFAULT_ENV) else {
106            return Self::Child;
107        };
108
109        // If the `CONDA_PREFIX` equals the `CONDA_DEFAULT_ENV`, we're in an unnamed environment
110        // which is typical for environments created with `conda create -p /path/to/env`.
111        if path == Path::new(&current_env) {
112            return Self::Child;
113        }
114
115        // Use path-based logic for environment names, including `base` and `root`.
116        let Some(name) = path.file_name() else {
117            return Self::Child;
118        };
119
120        // If the environment is in a directory matching the name of the environment, it's not
121        // usually a base environment.
122        if name.to_str().is_some_and(|name| name == current_env) {
123            Self::Child
124        } else {
125            Self::Base
126        }
127    }
128}
129
130/// Detect whether the current `CONDA_PREFIX` belongs to a Pixi-managed environment.
131fn is_pixi_environment(path: &Path) -> bool {
132    path.join("conda-meta").join("pixi").is_file()
133}
134
135/// Locate an active conda environment by inspecting environment variables.
136///
137/// If `base` is true, the active environment must be the base environment or `None` is returned,
138/// and vice-versa.
139pub(crate) fn conda_environment_from_env(kind: CondaEnvironmentKind) -> Option<PathBuf> {
140    let dir = env::var_os(EnvVars::CONDA_PREFIX).filter(|value| !value.is_empty())?;
141    let path = PathBuf::from(dir);
142
143    if kind != CondaEnvironmentKind::from_prefix_path(&path) {
144        return None;
145    }
146
147    Some(path)
148}
149
150/// Locate a virtual environment by searching the file system.
151///
152/// Searches for a `.venv` directory or symlink in the current or any parent directory. If the
153/// current directory is itself a virtual environment (or a subdirectory of a virtual environment),
154/// the containing virtual environment is returned.
155pub(crate) fn virtualenv_from_working_dir() -> Result<Option<PathBuf>, Error> {
156    let current_dir = crate::current_dir()?;
157
158    for dir in current_dir.ancestors() {
159        // If we're _within_ a virtualenv, return it.
160        if uv_fs::is_virtualenv_base(dir) {
161            return Ok(Some(dir.to_path_buf()));
162        }
163
164        // Otherwise, search for a `.venv` directory.
165        let dot_venv = dir.join(".venv");
166        let metadata = match fs::symlink_metadata(&dot_venv) {
167            Ok(metadata) => metadata,
168            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
169            Err(err) => return Err(err.into()),
170        };
171        if metadata.is_dir() || metadata.file_type().is_symlink() {
172            if !uv_fs::is_virtualenv_base(&dot_venv) {
173                return Err(Error::MissingPyVenvCfg(dot_venv));
174            }
175            return Ok(Some(dot_venv));
176        }
177    }
178
179    Ok(None)
180}
181
182/// Returns the path to the `python` executable inside a virtual environment.
183pub(crate) fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
184    let venv = venv.as_ref();
185    if cfg!(windows) {
186        // Search for `python.exe` in the `Scripts` directory.
187        let default_executable = venv.join("Scripts").join("python.exe");
188        if default_executable.exists() {
189            return default_executable;
190        }
191
192        // Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout.
193        // See: https://github.com/PyO3/maturin/issues/1108
194        let executable = venv.join("bin").join("python.exe");
195        if executable.exists() {
196            return executable;
197        }
198
199        // Fallback for Conda environments.
200        let executable = venv.join("python.exe");
201        if executable.exists() {
202            return executable;
203        }
204
205        // If none of these exist, return the standard location
206        default_executable
207    } else {
208        // Prefer the unversioned `python` command exposed by virtual environments.
209        let default_executable = venv.join("bin").join("python");
210        if default_executable.exists() {
211            return default_executable;
212        }
213
214        // Fall back to `python3` for installations without an unversioned executable.
215        let executable = venv.join("bin").join("python3");
216        if executable.exists() {
217            return executable;
218        }
219
220        // If none of these exist, return the standard location
221        default_executable
222    }
223}
224
225impl PyVenvConfiguration {
226    /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`].
227    pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
228        let mut home = None;
229        let mut virtualenv = false;
230        let mut uv = false;
231        let mut relocatable = false;
232        let mut seed = false;
233        let mut include_system_site_packages = true;
234        let mut version = None;
235
236        // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a
237        // valid INI file, and is instead expected to be parsed by partitioning each line on the
238        // first equals sign.
239        let content = fs::read_to_string(&cfg)
240            .map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?;
241        for line in content.lines() {
242            let Some((key, value)) = line.split_once('=') else {
243                continue;
244            };
245            match key.trim() {
246                "home" => {
247                    home = Some(PathBuf::from(value.trim()));
248                }
249                "virtualenv" => {
250                    virtualenv = true;
251                }
252                "uv" => {
253                    uv = true;
254                }
255                "relocatable" => {
256                    relocatable = value.trim().to_lowercase() == "true";
257                }
258                "seed" => {
259                    seed = value.trim().to_lowercase() == "true";
260                }
261                "include-system-site-packages" => {
262                    include_system_site_packages = value.trim().to_lowercase() == "true";
263                }
264                "version" | "version_info" => {
265                    version = Some(
266                        PythonVersion::from_str(value.trim())
267                            .map_err(|e| io::Error::new(std::io::ErrorKind::InvalidData, e))?,
268                    );
269                }
270                _ => {}
271            }
272        }
273
274        Ok(Self {
275            home,
276            virtualenv,
277            uv,
278            relocatable,
279            seed,
280            include_system_site_packages,
281            version,
282        })
283    }
284
285    /// Returns true if the virtual environment was created with the `virtualenv` package.
286    pub fn is_virtualenv(&self) -> bool {
287        self.virtualenv
288    }
289
290    /// Returns true if the virtual environment was created with the uv package.
291    pub fn is_uv(&self) -> bool {
292        self.uv
293    }
294
295    /// Returns true if the virtual environment is relocatable.
296    pub(crate) fn is_relocatable(&self) -> bool {
297        self.relocatable
298    }
299
300    /// Returns true if the virtual environment was populated with seed packages.
301    pub fn is_seed(&self) -> bool {
302        self.seed
303    }
304
305    /// Returns true if the virtual environment should include system site packages.
306    pub fn include_system_site_packages(&self) -> bool {
307        self.include_system_site_packages
308    }
309
310    /// Set the key-value pair in the `pyvenv.cfg` file.
311    pub fn set(content: &str, key: &str, value: &str) -> String {
312        let mut lines = content.lines().map(Cow::Borrowed).collect::<Vec<_>>();
313        let mut found = false;
314        for line in &mut lines {
315            if let Some((lhs, _)) = line.split_once('=')
316                && lhs.trim() == key
317            {
318                *line = Cow::Owned(format!("{key} = {value}"));
319                found = true;
320                break;
321            }
322        }
323        if !found {
324            lines.push(Cow::Owned(format!("{key} = {value}")));
325        }
326        if lines.is_empty() {
327            String::new()
328        } else {
329            format!("{}\n", lines.join("\n"))
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use std::ffi::OsStr;
337
338    use indoc::indoc;
339    use temp_env::with_vars;
340    use tempfile::tempdir;
341
342    use super::*;
343
344    #[test]
345    fn pixi_environment_is_treated_as_child() {
346        let tempdir = tempdir().unwrap();
347        let prefix = tempdir.path();
348        let conda_meta = prefix.join("conda-meta");
349
350        fs::create_dir_all(&conda_meta).unwrap();
351        fs::write(conda_meta.join("pixi"), []).unwrap();
352
353        let vars = [
354            (EnvVars::CONDA_ROOT, None),
355            (EnvVars::CONDA_PREFIX, Some(prefix.as_os_str())),
356            (EnvVars::CONDA_DEFAULT_ENV, Some(OsStr::new("example"))),
357        ];
358
359        with_vars(vars, || {
360            assert_eq!(
361                CondaEnvironmentKind::from_prefix_path(prefix),
362                CondaEnvironmentKind::Child
363            );
364        });
365    }
366
367    #[test]
368    fn test_set_existing_key() {
369        let content = indoc! {"
370            home = /path/to/python
371            version = 3.8.0
372            include-system-site-packages = false
373        "};
374        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
375        assert_eq!(
376            result,
377            indoc! {"
378                home = /path/to/python
379                version = 3.9.0
380                include-system-site-packages = false
381            "}
382        );
383    }
384
385    #[test]
386    fn test_set_new_key() {
387        let content = indoc! {"
388            home = /path/to/python
389            version = 3.8.0
390        "};
391        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
392        assert_eq!(
393            result,
394            indoc! {"
395                home = /path/to/python
396                version = 3.8.0
397                include-system-site-packages = false
398            "}
399        );
400    }
401
402    #[test]
403    fn test_set_key_no_spaces() {
404        let content = indoc! {"
405            home=/path/to/python
406            version=3.8.0
407        "};
408        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
409        assert_eq!(
410            result,
411            indoc! {"
412                home=/path/to/python
413                version=3.8.0
414                include-system-site-packages = false
415            "}
416        );
417    }
418
419    #[test]
420    fn test_set_key_prefix() {
421        let content = indoc! {"
422            home = /path/to/python
423            home_dir = /other/path
424        "};
425        let result = PyVenvConfiguration::set(content, "home", "new/path");
426        assert_eq!(
427            result,
428            indoc! {"
429                home = new/path
430                home_dir = /other/path
431            "}
432        );
433    }
434
435    #[test]
436    fn test_set_empty_content() {
437        let content = "";
438        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
439        assert_eq!(
440            result,
441            indoc! {"
442                version = 3.9.0
443            "}
444        );
445    }
446}