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