diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index c179c082ba02a..d100ac2556780 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1167,19 +1167,21 @@ impl ProjectInterpreter { Self::Environment(venv) => venv.into_interpreter(), } } +} - /// Grab a file lock for the environment to prevent concurrent writes across processes. - async fn lock(workspace: &Workspace) -> Result { - LockedFile::acquire( - std::env::temp_dir().join(format!( - "uv-{}.lock", - cache_digest(workspace.install_path()) - )), - LockedFileMode::Exclusive, - workspace.install_path().simplified_display(), - ) - .await - } +/// Grab a file lock for the project environment to prevent concurrent writes across processes. +pub(crate) async fn lock_project_environment( + workspace: &Workspace, +) -> Result { + LockedFile::acquire( + std::env::temp_dir().join(format!( + "uv-{}.lock", + cache_digest(workspace.install_path()) + )), + LockedFileMode::Exclusive, + workspace.install_path().simplified_display(), + ) + .await } /// The source of a `Requires-Python` specifier. @@ -1449,7 +1451,7 @@ impl ProjectEnvironment { printer: Printer, ) -> Result { // Lock the project environment to avoid synchronization issues. - let _lock = ProjectInterpreter::lock(workspace) + let _lock = lock_project_environment(workspace) .await .inspect_err(|err| { warn!("Failed to acquire project environment lock: {err}"); diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 40407a5255d32..d0c7eb30d0e6e 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -6,6 +6,7 @@ use std::vec; use anyhow::Result; use owo_colors::OwoColorize; use thiserror::Error; +use tracing::warn; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; @@ -38,7 +39,9 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceEr use crate::commands::ExitStatus; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger}; use crate::commands::pip::operations::{Changelog, report_interpreter}; -use crate::commands::project::{WorkspacePython, validate_project_requires_python}; +use crate::commands::project::{ + WorkspacePython, lock_project_environment, validate_project_requires_python, +}; use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; @@ -119,19 +122,21 @@ pub(crate) async fn venv( } }; - // Determine the default path; either the virtual environment for the project or `.venv` - let path = path.unwrap_or( - project - .as_ref() - .and_then(|project| { - // Only use the project environment path if we're invoked from the root - // This isn't strictly necessary and we may want to change it later, but this - // avoids a breaking change when adding project environment support to `uv venv`. - (project.workspace().install_path() == project_dir) - .then(|| project.workspace().venv(Some(false))) - }) - .unwrap_or(PathBuf::from(".venv")), - ); + // Only use the project environment path if we're invoked from the root with no explicit path. + // This isn't strictly necessary and we may want to change it later, but this avoids a breaking + // change when adding project environment support to `uv venv`. + let project_environment = project + .as_ref() + .map(VirtualProject::workspace) + .filter(|workspace| path.is_none() && workspace.install_path() == project_dir); + + // Determine the default path; either the virtual environment for the project or `.venv`. + let path = path.unwrap_or_else(|| { + project_environment.map_or_else( + || PathBuf::from(".venv"), + |workspace| workspace.venv(Some(false)), + ) + }); let reporter = PythonDownloadReporter::single(printer); @@ -201,6 +206,18 @@ pub(crate) async fn venv( .as_ref() .is_none_or(|request| !request.includes_patch()); + // Lock the project environment to avoid synchronization issues. + let _lock = if let Some(workspace) = project_environment { + lock_project_environment(workspace) + .await + .inspect_err(|err| { + warn!("Failed to acquire project environment lock: {err}"); + }) + .ok() + } else { + None + }; + // Create the virtual environment. let venv = uv_virtualenv::create_venv( &path, diff --git a/crates/uv/tests/python/venv.rs b/crates/uv/tests/python/venv.rs index 4bbea40c17674..332cfa4f77c0d 100644 --- a/crates/uv/tests/python/venv.rs +++ b/crates/uv/tests/python/venv.rs @@ -3,6 +3,8 @@ use assert_cmd::prelude::*; use assert_fs::prelude::*; use indoc::indoc; use predicates::prelude::*; +use uv_cache_key::cache_digest; +use uv_fs::{LockedFile, LockedFileMode}; use uv_python::{PYTHON_VERSION_FILENAME, PYTHON_VERSIONS_FILENAME}; use uv_static::EnvVars; @@ -212,6 +214,56 @@ fn create_venv_project_environment() -> Result<()> { Ok(()) } +#[tokio::test] +async fn create_venv_project_environment_lock() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + // Simulate another project command holding the environment lock. + let install_path = dunce::canonicalize(context.temp_dir.path())?; + let lock_path = std::env::temp_dir().join(format!("uv-{}.lock", cache_digest(&install_path))); + let lock = LockedFile::acquire( + &lock_path, + LockedFileMode::Exclusive, + context.temp_dir.path().display(), + ) + .await?; + let context = context.with_filtered_path(&lock_path, "PROJECT_ENVIRONMENT_LOCK"); + + // A pathless invocation from the project root uses the workspace-derived lock, including when + // `UV_PROJECT_ENVIRONMENT` changes the environment path. Lock errors warn and continue. + uv_snapshot!(context.filters(), context.venv() + .env(EnvVars::UV_PROJECT_ENVIRONMENT, "foo") + .env(EnvVars::RUST_LOG, "warn") + .env(EnvVars::UV_LOCK_TIMEOUT, "1"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: foo + WARN Failed to acquire project environment lock: Timeout ([TIME]) when waiting for lock on `[TEMP_DIR]/` at `[PROJECT_ENVIRONMENT_LOCK]/`, is another uv process running? You can set `UV_LOCK_TIMEOUT` to increase the timeout. + Activate with: source foo/[BIN]/activate + "); + drop(lock); + + context + .temp_dir + .child("foo") + .assert(predicates::path::is_dir()); + + Ok(()) +} + #[test] fn virtual_empty() -> Result<()> { // testing how `uv venv` reacts to a pyproject with no `[project]` and nothing useful to it