diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index 04cae0df9..311f0ee1a 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -270,6 +270,16 @@ impl PythonEnvironment { Ok(PyVenvConfiguration::parse(self.0.root.join("pyvenv.cfg"))?) } + /// Set a key-value pair in the `pyvenv.cfg` file. + pub fn set_pyvenv_cfg(&self, key: &str, value: &str) -> Result<(), Error> { + let content = fs_err::read_to_string(self.0.root.join("pyvenv.cfg"))?; + fs_err::write( + self.0.root.join("pyvenv.cfg"), + PyVenvConfiguration::set(&content, key, value), + )?; + Ok(()) + } + /// Returns `true` if the environment is "relocatable". pub fn relocatable(&self) -> bool { self.cfg().is_ok_and(|cfg| cfg.is_relocatable()) diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index 2463d1a87..75e4e10d8 100644 --- a/crates/uv-python/src/virtualenv.rs +++ b/crates/uv-python/src/virtualenv.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::{ env, io, path::{Path, PathBuf}, @@ -5,6 +6,7 @@ use std::{ use fs_err as fs; use thiserror::Error; + use uv_pypi_types::Scheme; use uv_static::EnvVars; @@ -37,6 +39,8 @@ pub struct PyVenvConfiguration { pub(crate) relocatable: bool, /// Was the virtual environment populated with seed packages? pub(crate) seed: bool, + /// Should the virtual environment include system site packages? + pub(crate) include_system_side_packages: bool, } #[derive(Debug, Error)] @@ -188,6 +192,7 @@ impl PyVenvConfiguration { let mut uv = false; let mut relocatable = false; let mut seed = false; + let mut include_system_side_packages = false; // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a // valid INI file, and is instead expected to be parsed by partitioning each line on the @@ -211,6 +216,9 @@ impl PyVenvConfiguration { "seed" => { seed = value.trim().to_lowercase() == "true"; } + "include-system-site-packages" => { + include_system_side_packages = value.trim().to_lowercase() == "true"; + } _ => {} } } @@ -220,6 +228,7 @@ impl PyVenvConfiguration { uv, relocatable, seed, + include_system_side_packages, }) } @@ -242,4 +251,119 @@ impl PyVenvConfiguration { pub fn is_seed(&self) -> bool { self.seed } + + /// Returns true if the virtual environment should include system site packages. + pub fn include_system_side_packages(&self) -> bool { + self.include_system_side_packages + } + + /// Set the key-value pair in the `pyvenv.cfg` file. + pub fn set(content: &str, key: &str, value: &str) -> String { + let mut lines = content.lines().map(Cow::Borrowed).collect::>(); + let mut found = false; + for line in &mut lines { + if let Some((lhs, _)) = line.split_once('=') { + if lhs.trim() == key { + *line = Cow::Owned(format!("{key} = {value}")); + found = true; + break; + } + } + } + if !found { + lines.push(Cow::Owned(format!("{key} = {value}"))); + } + if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + } + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use super::*; + + #[test] + fn test_set_existing_key() { + let content = indoc! {" + home = /path/to/python + version = 3.8.0 + include-system-site-packages = false + "}; + let result = PyVenvConfiguration::set(content, "version", "3.9.0"); + assert_eq!( + result, + indoc! {" + home = /path/to/python + version = 3.9.0 + include-system-site-packages = false + "} + ); + } + + #[test] + fn test_set_new_key() { + let content = indoc! {" + home = /path/to/python + version = 3.8.0 + "}; + let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false"); + assert_eq!( + result, + indoc! {" + home = /path/to/python + version = 3.8.0 + include-system-site-packages = false + "} + ); + } + + #[test] + fn test_set_key_no_spaces() { + let content = indoc! {" + home=/path/to/python + version=3.8.0 + "}; + let result = PyVenvConfiguration::set(content, "include-system-site-packages", "false"); + assert_eq!( + result, + indoc! {" + home=/path/to/python + version=3.8.0 + include-system-site-packages = false + "} + ); + } + + #[test] + fn test_set_key_prefix() { + let content = indoc! {" + home = /path/to/python + home_dir = /other/path + "}; + let result = PyVenvConfiguration::set(content, "home", "new/path"); + assert_eq!( + result, + indoc! {" + home = new/path + home_dir = /other/path + "} + ); + } + + #[test] + fn test_set_empty_content() { + let content = ""; + let result = PyVenvConfiguration::set(content, "version", "3.9.0"); + assert_eq!( + result, + indoc! {" + version = 3.9.0 + "} + ); + } } diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index a8c4110f0..ee4e9213f 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -150,6 +150,22 @@ impl CachedEnvironment { Ok(()) } + /// Enable system site packages for a Python environment. + #[allow(clippy::result_large_err)] + pub(crate) fn set_system_site_packages(&self) -> Result<(), ProjectError> { + self.0 + .set_pyvenv_cfg("include-system-site-packages", "true")?; + Ok(()) + } + + /// Disable system site packages for a Python environment. + #[allow(clippy::result_large_err)] + pub(crate) fn clear_system_site_packages(&self) -> Result<(), ProjectError> { + self.0 + .set_pyvenv_cfg("include-system-site-packages", "false")?; + Ok(()) + } + /// Return the [`Interpreter`] to use for the cached environment, based on a given /// [`Interpreter`]. /// diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 9b77bfe08..6248634ab 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -25,8 +25,9 @@ use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ - EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions, + EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, + PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, + VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -923,6 +924,21 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl "import site; site.addsitedir(\"{}\")", site_packages.escape_for_python() ))?; + + // If `--system-site-packages` is enabled, add the system site packages to the ephemeral + // environment. + if base_interpreter + .is_virtualenv() + .then(|| { + PyVenvConfiguration::parse(base_interpreter.sys_prefix().join("pyvenv.cfg")) + .is_ok_and(|cfg| cfg.include_system_side_packages()) + }) + .unwrap_or(false) + { + ephemeral_env.set_system_site_packages()?; + } else { + ephemeral_env.clear_system_site_packages()?; + } } // Cast from `CachedEnvironment` to `PythonEnvironment`. diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 6a057f5d7..e14ac1a9f 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -801,6 +801,7 @@ async fn get_or_create_environment( // Clear any existing overlay. environment.clear_overlay()?; + environment.clear_system_site_packages()?; Ok((from, environment.into())) }