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