Take project environment lock when using uv venv in a project (#19837)

## Summary

We added a global workspace lock for project-oriented uv commands
creating/re-creating venvs but the same was not done for `uv venv` when
it operates on a project environment.

This PR remedies this.

## Test Plan

There's a new test, not sure how good of an idea it is to set RUST_LOG
there....
This commit is contained in:
Tomasz Kramkowski
2026-06-17 12:39:53 +01:00
committed by GitHub
parent 1ecdc2b6c0
commit b08e2151b2
3 changed files with 98 additions and 27 deletions
+15 -13
View File
@@ -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, LockedFileError> {
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, LockedFileError> {
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<Self, ProjectError> {
// 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}");
+31 -14
View File
@@ -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,
+52
View File
@@ -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