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