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        // Check for both `python3` over `python`, preferring the more specific one
209        let default_executable = venv.join("bin").join("python3");
210        if default_executable.exists() {
211            return default_executable;
212        }
213
214        let executable = venv.join("bin").join("python");
215        if executable.exists() {
216            return executable;
217        }
218
219        // If none of these exist, return the standard location
220        default_executable
221    }
222}
223
224impl PyVenvConfiguration {
225    /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`].
226    pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
227        let mut home = None;
228        let mut virtualenv = false;
229        let mut uv = false;
230        let mut relocatable = false;
231        let mut seed = false;
232        let mut include_system_site_packages = true;
233        let mut version = None;
234
235        // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a
236        // valid INI file, and is instead expected to be parsed by partitioning each line on the
237        // first equals sign.
238        let content = fs::read_to_string(&cfg)
239            .map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?;
240        for line in content.lines() {
241            let Some((key, value)) = line.split_once('=') else {
242                continue;
243            };
244            match key.trim() {
245                "home" => {
246                    home = Some(PathBuf::from(value.trim()));
247                }
248                "virtualenv" => {
249                    virtualenv = true;
250                }
251                "uv" => {
252                    uv = true;
253                }
254                "relocatable" => {
255                    relocatable = value.trim().to_lowercase() == "true";
256                }
257                "seed" => {
258                    seed = value.trim().to_lowercase() == "true";
259                }
260                "include-system-site-packages" => {
261                    include_system_site_packages = value.trim().to_lowercase() == "true";
262                }
263                "version" | "version_info" => {
264                    version = Some(
265                        PythonVersion::from_str(value.trim())
266                            .map_err(|e| io::Error::new(std::io::ErrorKind::InvalidData, e))?,
267                    );
268                }
269                _ => {}
270            }
271        }
272
273        Ok(Self {
274            home,
275            virtualenv,
276            uv,
277            relocatable,
278            seed,
279            include_system_site_packages,
280            version,
281        })
282    }
283
284    /// Returns true if the virtual environment was created with the `virtualenv` package.
285    pub fn is_virtualenv(&self) -> bool {
286        self.virtualenv
287    }
288
289    /// Returns true if the virtual environment was created with the uv package.
290    pub fn is_uv(&self) -> bool {
291        self.uv
292    }
293
294    /// Returns true if the virtual environment is relocatable.
295    pub(crate) fn is_relocatable(&self) -> bool {
296        self.relocatable
297    }
298
299    /// Returns true if the virtual environment was populated with seed packages.
300    pub fn is_seed(&self) -> bool {
301        self.seed
302    }
303
304    /// Returns true if the virtual environment should include system site packages.
305    pub fn include_system_site_packages(&self) -> bool {
306        self.include_system_site_packages
307    }
308
309    /// Set the key-value pair in the `pyvenv.cfg` file.
310    pub fn set(content: &str, key: &str, value: &str) -> String {
311        let mut lines = content.lines().map(Cow::Borrowed).collect::<Vec<_>>();
312        let mut found = false;
313        for line in &mut lines {
314            if let Some((lhs, _)) = line.split_once('=')
315                && lhs.trim() == key
316            {
317                *line = Cow::Owned(format!("{key} = {value}"));
318                found = true;
319                break;
320            }
321        }
322        if !found {
323            lines.push(Cow::Owned(format!("{key} = {value}")));
324        }
325        if lines.is_empty() {
326            String::new()
327        } else {
328            format!("{}\n", lines.join("\n"))
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use std::ffi::OsStr;
336
337    use indoc::indoc;
338    use temp_env::with_vars;
339    use tempfile::tempdir;
340
341    use super::*;
342
343    #[test]
344    fn pixi_environment_is_treated_as_child() {
345        let tempdir = tempdir().unwrap();
346        let prefix = tempdir.path();
347        let conda_meta = prefix.join("conda-meta");
348
349        fs::create_dir_all(&conda_meta).unwrap();
350        fs::write(conda_meta.join("pixi"), []).unwrap();
351
352        let vars = [
353            (EnvVars::CONDA_ROOT, None),
354            (EnvVars::CONDA_PREFIX, Some(prefix.as_os_str())),
355            (EnvVars::CONDA_DEFAULT_ENV, Some(OsStr::new("example"))),
356        ];
357
358        with_vars(vars, || {
359            assert_eq!(
360                CondaEnvironmentKind::from_prefix_path(prefix),
361                CondaEnvironmentKind::Child
362            );
363        });
364    }
365
366    #[test]
367    fn test_set_existing_key() {
368        let content = indoc! {"
369            home = /path/to/python
370            version = 3.8.0
371            include-system-site-packages = false
372        "};
373        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
374        assert_eq!(
375            result,
376            indoc! {"
377                home = /path/to/python
378                version = 3.9.0
379                include-system-site-packages = false
380            "}
381        );
382    }
383
384    #[test]
385    fn test_set_new_key() {
386        let content = indoc! {"
387            home = /path/to/python
388            version = 3.8.0
389        "};
390        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
391        assert_eq!(
392            result,
393            indoc! {"
394                home = /path/to/python
395                version = 3.8.0
396                include-system-site-packages = false
397            "}
398        );
399    }
400
401    #[test]
402    fn test_set_key_no_spaces() {
403        let content = indoc! {"
404            home=/path/to/python
405            version=3.8.0
406        "};
407        let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false");
408        assert_eq!(
409            result,
410            indoc! {"
411                home=/path/to/python
412                version=3.8.0
413                include-system-site-packages = false
414            "}
415        );
416    }
417
418    #[test]
419    fn test_set_key_prefix() {
420        let content = indoc! {"
421            home = /path/to/python
422            home_dir = /other/path
423        "};
424        let result = PyVenvConfiguration::set(content, "home", "new/path");
425        assert_eq!(
426            result,
427            indoc! {"
428                home = new/path
429                home_dir = /other/path
430            "}
431        );
432    }
433
434    #[test]
435    fn test_set_empty_content() {
436        let content = "";
437        let result = PyVenvConfiguration::set(content, "version", "3.9.0");
438        assert_eq!(
439            result,
440            indoc! {"
441                version = 3.9.0
442            "}
443        );
444    }
445}