Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}");
Expand Down
45 changes: 31 additions & 14 deletions crates/uv/src/commands/venv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Comment on lines +125 to +131

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also check if the explicit path given actually matches the project env path? e.g. uv venv .venv

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we've historically treated uv venv with an explicit path specially, it's not a project environment in that case and therefore there's not necessarily a workspace. And the locks are actually misnamed, they're workspace locks not environment locks (but I felt like fixing that was beyond the scope of this PR and would need some discsussion, I should make an issue :P)

In any case. project_environment will be none if an explicit path is passed.


// 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);

Expand Down Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions crates/uv/tests/python/venv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading