diff --git a/Cargo.lock b/Cargo.lock index 4dfe0c28ac48..5cb6c929f923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5111,6 +5111,7 @@ dependencies = [ "path-slash", "percent-encoding", "rustix", + "same-file", "schemars", "serde", "tempfile", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 6db02ac6a20c..53f7ff5db1b5 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3044,10 +3044,10 @@ pub struct SyncArgs { #[arg(long, overrides_with("inexact"), hide = true)] pub exact: bool, - /// Prefer the active virtual environment over the project's virtual environment. + /// Prefer the active virtual environment over the project or script's virtual environment. /// - /// If the project virtual environment is active or no virtual environment is active, this has - /// no effect. + /// If the project or script virtual environment is active (or no virtual environment is active), + /// this has no effect. #[arg(long, overrides_with = "no_active")] pub active: bool, @@ -3142,7 +3142,6 @@ pub struct SyncArgs { /// adherence with PEP 723. #[arg( long, - conflicts_with = "active", conflicts_with = "all_packages", conflicts_with = "package", conflicts_with = "no_install_project", diff --git a/crates/uv-fs/Cargo.toml b/crates/uv-fs/Cargo.toml index f15a1fc6beba..2860dc8a89a9 100644 --- a/crates/uv-fs/Cargo.toml +++ b/crates/uv-fs/Cargo.toml @@ -23,6 +23,7 @@ fs-err = { workspace = true } fs2 = { workspace = true } path-slash = { workspace = true } percent-encoding = { workspace = true } +same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } tempfile = { workspace = true } diff --git a/crates/uv-fs/src/lib.rs b/crates/uv-fs/src/lib.rs index aecac9eaea58..6c590001df6e 100644 --- a/crates/uv-fs/src/lib.rs +++ b/crates/uv-fs/src/lib.rs @@ -11,6 +11,31 @@ pub mod cachedir; mod path; pub mod which; +/// Attempt to check if the two paths refer to the same directory. +pub fn is_same_dir(left: &Path, right: &Path) -> Option { + // First, attempt to check directly + if let Ok(value) = same_file::is_same_file(left, right) { + return Some(value); + }; + + // Often, one of the directories won't exist yet so perform the comparison up a level + if let (Some(left_parent), Some(right_parent), Some(left_name), Some(right_name)) = ( + left.parent(), + right.parent(), + left.file_name(), + right.file_name(), + ) { + match same_file::is_same_file(left_parent, right_parent) { + Ok(true) => return Some(left_name == right_name), + Ok(false) => return Some(false), + _ => (), + } + }; + + // We couldn't determine if they're the same + None +} + /// Reads data from the path and requires that it be valid UTF-8 or UTF-16. /// /// This uses BOM sniffing to determine if the data should be transcoded diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index f8cf4053a391..750003d23a8c 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -560,7 +560,7 @@ impl Workspace { Some(workspace.install_path.join(path)) } - // Resolve the `VIRTUAL_ENV` variable, if any. + /// Resolve the `VIRTUAL_ENV` variable, if any. fn from_virtual_env_variable() -> Option { let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?; @@ -578,38 +578,13 @@ impl Workspace { Some(CWD.join(path)) } - // Attempt to check if the two paths refer to the same directory. - fn is_same_dir(left: &Path, right: &Path) -> Option { - // First, attempt to check directly - if let Ok(value) = same_file::is_same_file(left, right) { - return Some(value); - }; - - // Often, one of the directories won't exist yet so perform the comparison up a level - if let (Some(left_parent), Some(right_parent), Some(left_name), Some(right_name)) = ( - left.parent(), - right.parent(), - left.file_name(), - right.file_name(), - ) { - match same_file::is_same_file(left_parent, right_parent) { - Ok(true) => return Some(left_name == right_name), - Ok(false) => return Some(false), - _ => (), - } - }; - - // We couldn't determine if they're the same - None - } - // Determine the default value let project_env = from_project_environment_variable(self) .unwrap_or_else(|| self.install_path.join(".venv")); // Warn if it conflicts with `VIRTUAL_ENV` if let Some(from_virtual_env) = from_virtual_env_variable() { - if !is_same_dir(&from_virtual_env, &project_env).unwrap_or(false) { + if !uv_fs::is_same_dir(&from_virtual_env, &project_env).unwrap_or(false) { match active { Some(true) => { debug!( diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 34df6ab3a3b8..516ab0f762a0 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -165,6 +165,7 @@ pub(crate) async fn add( allow_insecure_host, &install_mirrors, no_config, + active, cache, printer, ) diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 6ea2a02156de..6f7032254472 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -147,6 +147,7 @@ pub(crate) async fn export( allow_insecure_host, &install_mirrors, no_config, + Some(false), cache, printer, ) diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index f613c3cebc69..a457e81ab3f3 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -137,6 +137,7 @@ pub(crate) async fn lock( allow_insecure_host, &install_mirrors, no_config, + Some(false), cache, printer, ) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 1de0d82c8ee3..8c07fe5705cb 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -40,6 +40,7 @@ use uv_resolver::{ }; use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; +use uv_static::EnvVars; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::dependency_groups::DependencyGroupError; @@ -532,30 +533,87 @@ pub(crate) enum ScriptInterpreter { impl ScriptInterpreter { /// Return the expected virtual environment path for the [`Pep723Script`]. - pub(crate) fn root(script: Pep723ItemRef<'_>, cache: &Cache) -> PathBuf { - let entry = match script { - // For local scripts, use a hash of the path to the script. - Pep723ItemRef::Script(script) => { - let digest = cache_digest(&script.path); - if let Some(file_name) = script - .path - .file_stem() - .and_then(|name| name.to_str()) - .and_then(cache_name) - { - format!("{file_name}-{digest}") - } else { - digest + /// + /// If `--active` is set, the active virtual environment will be preferred. + /// + /// See: [`Workspace::venv`]. + pub(crate) fn root(script: Pep723ItemRef<'_>, active: Option, cache: &Cache) -> PathBuf { + /// Resolve the `VIRTUAL_ENV` variable, if any. + fn from_virtual_env_variable() -> Option { + let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?; + + if value.is_empty() { + return None; + }; + + let path = PathBuf::from(value); + if path.is_absolute() { + return Some(path); + }; + + // Resolve the path relative to current directory. + Some(CWD.join(path)) + } + + let cache_env = { + let entry = match script { + // For local scripts, use a hash of the path to the script. + Pep723ItemRef::Script(script) => { + let digest = cache_digest(&script.path); + if let Some(file_name) = script + .path + .file_stem() + .and_then(|name| name.to_str()) + .and_then(cache_name) + { + format!("{file_name}-{digest}") + } else { + digest + } } - } - // For remote scripts, use a hash of the URL. - Pep723ItemRef::Remote(.., url) => cache_digest(url), - // Otherwise, use a hash of the metadata. - Pep723ItemRef::Stdin(metadata) => cache_digest(&metadata.raw), + // For remote scripts, use a hash of the URL. + Pep723ItemRef::Remote(.., url) => cache_digest(url), + // Otherwise, use a hash of the metadata. + Pep723ItemRef::Stdin(metadata) => cache_digest(&metadata.raw), + }; + + cache + .shard(CacheBucket::Environments, entry) + .into_path_buf() }; - cache - .shard(CacheBucket::Environments, entry) - .into_path_buf() + + // If `--active` is set, prefer the active virtual environment. + if let Some(from_virtual_env) = from_virtual_env_variable() { + if !uv_fs::is_same_dir(&from_virtual_env, &cache_env).unwrap_or(false) { + match active { + Some(true) => { + debug!( + "Using active virtual environment `{}` instead of script environment `{}`", + from_virtual_env.user_display(), + cache_env.user_display() + ); + return from_virtual_env; + } + Some(false) => {} + None => { + warn_user_once!( + "`VIRTUAL_ENV={}` does not match the script environment path `{}` and will be ignored; use `--active` to target the active environment instead", + from_virtual_env.user_display(), + cache_env.user_display() + ); + } + } + } + } else { + if active.unwrap_or_default() { + debug!( + "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set" + ); + } + } + + // Otherwise, use the cache root. + cache_env } /// Discover the interpreter to use for the current [`Pep723Item`]. @@ -569,6 +627,7 @@ impl ScriptInterpreter { allow_insecure_host: &[TrustedHost], install_mirrors: &PythonInstallMirrors, no_config: bool, + active: Option, cache: &Cache, printer: Printer, ) -> Result { @@ -581,7 +640,8 @@ impl ScriptInterpreter { requires_python, } = ScriptPython::from_request(python_request, workspace, script, no_config).await?; - let root = Self::root(script, cache); + let root = Self::root(script, active, cache); + match PythonEnvironment::from_root(&root, cache) { Ok(venv) => { if python_request.as_ref().map_or(true, |request| { @@ -1279,6 +1339,7 @@ impl ScriptEnvironment { allow_insecure_host: &[TrustedHost], install_mirrors: &PythonInstallMirrors, no_config: bool, + active: Option, cache: &Cache, dry_run: DryRun, printer: Printer, @@ -1296,6 +1357,7 @@ impl ScriptEnvironment { allow_insecure_host, install_mirrors, no_config, + active, cache, printer, ) @@ -1306,7 +1368,7 @@ impl ScriptEnvironment { // Otherwise, create a virtual environment with the discovered interpreter. ScriptInterpreter::Interpreter(interpreter) => { - let root = ScriptInterpreter::root(script, cache); + let root = ScriptInterpreter::root(script, active, cache); // Determine a prompt for the environment, in order of preference: // diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 70c887fb8d87..a7f17c7d5c91 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -253,6 +253,7 @@ pub(crate) async fn remove( allow_insecure_host, &install_mirrors, no_config, + active, cache, printer, ) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 46d3b6e3df3d..f64d3442d776 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -209,6 +209,7 @@ pub(crate) async fn run( allow_insecure_host, &install_mirrors, no_config, + active, cache, DryRun::Disabled, printer, @@ -329,6 +330,7 @@ pub(crate) async fn run( allow_insecure_host, &install_mirrors, no_config, + active, cache, DryRun::Disabled, printer, @@ -385,6 +387,7 @@ pub(crate) async fn run( allow_insecure_host, &install_mirrors, no_config, + active, cache, printer, ) diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 4b1ec5043e1c..5dc241b0c398 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -172,6 +172,7 @@ pub(crate) async fn sync( allow_insecure_host, &install_mirrors, no_config, + active, cache, dry_run, printer, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 57857ea6f71f..756e207ade71 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -102,6 +102,7 @@ pub(crate) async fn tree( allow_insecure_host, &install_mirrors, no_config, + Some(false), cache, printer, ) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 755ed3eeba8d..447d566efa67 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -3400,7 +3400,7 @@ fn sync_custom_environment_path() -> Result<()> { } #[test] -fn sync_active_environment() -> Result<()> { +fn sync_active_project_environment() -> Result<()> { let context = TestContext::new_with_versions(&["3.11", "3.12"]) .with_filtered_virtualenv_bin() .with_filtered_python_names(); @@ -3524,6 +3524,110 @@ fn sync_active_environment() -> Result<()> { Ok(()) } +#[test] +fn sync_active_script_environment() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_virtualenv_bin() + .with_filtered_python_names(); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio", + # ] + # /// + + import anyio + "# + })?; + + let filters = context + .filters() + .into_iter() + .chain(vec![( + r"environments-v1/script-\w+", + "environments-v1/script-[HASH]", + )]) + .collect::>(); + + // Running `uv sync --script` with `VIRTUAL_ENV` should warn + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").env(EnvVars::VIRTUAL_ENV, "foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `VIRTUAL_ENV=foo` does not match the script environment path `[CACHE_DIR]/environments-v1/script-[HASH]` and will be ignored; use `--active` to target the active environment instead + Creating script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + context + .temp_dir + .child("foo") + .assert(predicate::path::missing()); + + // Using `--active` should create the environment + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").env(EnvVars::VIRTUAL_ENV, "foo").arg("--active"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Creating script environment at: foo + Resolved 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + context + .temp_dir + .child("foo") + .assert(predicate::path::is_dir()); + + // A subsequent sync will re-use the environment + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").env(EnvVars::VIRTUAL_ENV, "foo").arg("--active"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: foo + "###); + + // Requesting another Python version will invalidate the environment + uv_snapshot!(&filters, context.sync() + .arg("--script") + .arg("script.py") + .env(EnvVars::VIRTUAL_ENV, "foo") + .arg("--active") + .arg("-p") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Recreating script environment at: foo + Resolved 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + Ok(()) +} + #[test] #[cfg(feature = "git")] fn sync_workspace_custom_environment_path() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7a4e97e7daa5..c4339e7f27eb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1486,9 +1486,9 @@ uv sync [OPTIONS]

Options

-
--active

Prefer the active virtual environment over the project’s virtual environment.

+
--active

Prefer the active virtual environment over the project or script’s virtual environment.

-

If the project virtual environment is active or no virtual environment is active, this has no effect.

+

If the project or script virtual environment is active (or no virtual environment is active), this has no effect.

--all-extras

Include all optional dependencies.