Add centralised environments in preview

This commit is contained in:
Tomasz (Tom) Kramkowski
2026-06-05 14:55:01 +01:00
parent 63543b961d
commit 3fcef30d38
31 changed files with 1391 additions and 105 deletions
+5 -5
View File
@@ -581,7 +581,7 @@ impl Cache {
Ok(summary)
}
/// Run the garbage collector on the cache, removing any dangling entries.
/// Prune dangling cache entries and cached environments.
pub fn prune(&self, ci: bool) -> Result<Removal, io::Error> {
let mut summary = Removal::default();
@@ -614,14 +614,14 @@ impl Cache {
}
}
// Second, remove any cached environments. These are never referenced by symlinks, so we can
// remove them directly.
// Second, remove all cached environments. Centralized project environments can be
// referenced by `.venv` links, but are recreated when next needed.
match fs_err::read_dir(self.bucket(CacheBucket::Environments)) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
let path = entry.path();
debug!("Removing dangling cache environment: {}", path.display());
debug!("Removing cached environment: {}", path.display());
summary += rm_rf(path)?;
}
}
@@ -1177,7 +1177,7 @@ pub enum CacheBucket {
Archive,
/// Ephemeral virtual environments used to execute PEP 517 builds and other operations.
Builds,
/// Reusable virtual environments used to invoke Python tools.
/// Reusable virtual environments for Python tools and projects.
Environments,
/// Cached Python downloads
Python,
+1 -1
View File
@@ -889,7 +889,7 @@ pub enum CacheCommand {
/// Clear the cache, removing all entries or those linked to specific packages.
#[command(alias = "clear")]
Clean(CleanArgs),
/// Prune all unreachable objects from the cache.
/// Prune dangling cache entries and cached environments.
Prune(PruneArgs),
/// Show the cache directory.
///
+7
View File
@@ -258,6 +258,7 @@ pub enum PreviewFeature {
VenvSafeClear = 1 << 32,
Check = 1 << 33,
PackagedInit = 1 << 34,
CentralizedProjectEnvs = 1 << 35,
}
impl PreviewFeature {
@@ -299,6 +300,7 @@ impl PreviewFeature {
Self::VenvSafeClear => "venv-safe-clear",
Self::Check => "check-command",
Self::PackagedInit => "packaged-init",
Self::CentralizedProjectEnvs => "centralized-project-envs",
}
}
}
@@ -353,6 +355,7 @@ impl FromStr for PreviewFeature {
"venv-safe-clear" => Self::VenvSafeClear,
"check" | "check-command" => Self::Check,
"packaged-init" => Self::PackagedInit,
"centralized-project-envs" => Self::CentralizedProjectEnvs,
_ => return Err(PreviewFeatureParseError),
})
}
@@ -644,6 +647,10 @@ mod tests {
assert_eq!(PreviewFeature::VenvSafeClear.as_str(), "venv-safe-clear");
assert_eq!(PreviewFeature::Audit.as_str(), "audit-command");
assert_eq!(PreviewFeature::Check.as_str(), "check-command");
assert_eq!(
PreviewFeature::CentralizedProjectEnvs.as_str(),
"centralized-project-envs"
);
}
#[test]
+2 -2
View File
@@ -29,7 +29,7 @@ pub enum LenientImplementationName {
impl ImplementationName {
/// Return the full implementation name.
pub(crate) const fn long_name(self) -> &'static str {
pub const fn long_name(self) -> &'static str {
match self {
Self::CPython => "cpython",
Self::PyPy => "pypy",
@@ -39,7 +39,7 @@ impl ImplementationName {
}
/// Return the abbreviated implementation name, if one exists.
pub(crate) const fn short_name(self) -> Option<&'static str> {
pub const fn short_name(self) -> Option<&'static str> {
match self {
Self::CPython => Some("cp"),
Self::PyPy => Some("pp"),
+13
View File
@@ -11,6 +11,7 @@ use uv_fs::Simplified;
use uv_warnings::warn_user;
use uv_cache::Cache;
use uv_cache_key::{CacheKey, CacheKeyHasher};
use uv_client::{BaseClient, BaseClientBuilder};
use uv_pep440::{Prerelease, Version};
use uv_platform::{Arch, Libc, Os, Platform};
@@ -715,6 +716,12 @@ impl fmt::Display for PythonInstallationKey {
}
}
impl CacheKey for PythonInstallationKey {
fn cache_key(&self, state: &mut CacheKeyHasher) {
self.hash(state);
}
}
impl FromStr for PythonInstallationKey {
type Err = PythonInstallationKeyError;
@@ -888,6 +895,12 @@ impl Hash for PythonInstallationMinorVersionKey {
}
}
impl CacheKey for PythonInstallationMinorVersionKey {
fn cache_key(&self, state: &mut CacheKeyHasher) {
self.hash(state);
}
}
impl From<PythonInstallationKey> for PythonInstallationMinorVersionKey {
fn from(key: PythonInstallationKey) -> Self {
Self(key)
+1 -1
View File
@@ -202,7 +202,7 @@ impl Interpreter {
}
/// Returns the [`PythonInstallationKey`] for this interpreter.
pub(crate) fn key(&self) -> PythonInstallationKey {
pub fn key(&self) -> PythonInstallationKey {
PythonInstallationKey::new(
LenientImplementationName::from(self.implementation_name()),
self.python_major(),
+12 -2
View File
@@ -234,6 +234,16 @@ impl TestContext {
self
}
/// Filter hashes from backticked centralized environment cache entry names.
#[must_use]
pub fn with_filtered_centralized_environment_hashes(mut self) -> Self {
self.filters.push((
r"`([\w.\[\]-]+)-[a-f0-9]{16}`".to_string(),
"`$1-[HASH]`".to_string(),
));
self
}
/// Add extra standard filtering for Windows-compatible missing file errors.
#[must_use]
pub fn with_filtered_missing_file_error(mut self) -> Self {
@@ -1053,9 +1063,9 @@ impl TestContext {
),
r#"requires = ["uv_build>=[CURRENT_VERSION],<[NEXT_BREAKING]"]"#.to_string(),
));
// Filter script environment hashes
// Filter environment cache entry hashes
filters.push((
r"environments-v(\d+)[\\/](\w+)-[a-z0-9]+".to_string(),
r"environments-v(\d+)[\\/]([\w.\[\]-]+)-[a-f0-9]{16}".to_string(),
"environments-v$1/$2-[HASH]".to_string(),
));
// Filter archive hashes
-9
View File
@@ -849,8 +849,6 @@ impl Workspace {
/// The workspace project environment selection.
///
/// Uses `.venv` in the install path directory by default.
///
/// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
/// it is resolved relative to the install path.
///
@@ -937,13 +935,6 @@ impl Workspace {
selection
}
/// The path to the workspace virtual environment.
pub fn venv(&self, active: Option<bool>) -> PathBuf {
self.environment_selection(active)
.explicit_path()
.map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf)
}
/// The members of the workspace.
pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
&self.packages
+1 -1
View File
@@ -10,7 +10,7 @@ use uv_fs::Simplified;
use crate::commands::{ExitStatus, human_readable_bytes};
use crate::printer::Printer;
/// Prune all unreachable objects from the cache.
/// Prune dangling cache entries and cached environments.
pub(crate) async fn cache_prune(
ci: bool,
force: bool,
+4 -2
View File
@@ -54,8 +54,9 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
PlatformState, ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptInterpreter,
UniversalState, WorkspacePython, default_dependency_groups, init_script_python_requirement,
LinkErrorReporting, PlatformState, ProjectEnvironment, ProjectError, ProjectInterpreter,
ScriptInterpreter, UniversalState, WorkspacePython, default_dependency_groups,
init_script_python_requirement,
};
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{ExitStatus, ScriptPath, diagnostics, project};
@@ -318,6 +319,7 @@ pub(crate) async fn add(
active,
cache,
DryRun::Disabled,
LinkErrorReporting::User,
printer,
)
.await?
+3 -2
View File
@@ -24,8 +24,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectEnvironment, ProjectError, UniversalState, WorkspacePython, default_dependency_groups,
validate_project_requires_python,
LinkErrorReporting, ProjectEnvironment, ProjectError, UniversalState, WorkspacePython,
default_dependency_groups, validate_project_requires_python,
};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{ExitStatus, diagnostics, project};
@@ -224,6 +224,7 @@ pub(crate) async fn check(
None,
cache,
DryRun::Disabled,
LinkErrorReporting::User,
printer,
)
.await?
+315 -60
View File
@@ -22,18 +22,20 @@ use uv_distribution_types::{
ExtraBuildRequirement, ExtraBuildRequires, Index, IndexCredentialsError, Requirement,
RequiresPython, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification,
};
use uv_fs::{CWD, LockedFile, LockedFileError, LockedFileMode, Simplified};
use uv_fs::{CWD, LockedFile, LockedFileError, LockedFileMode, Simplified, verbatim_path};
use uv_git::ResolvedRepositoryReference;
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName};
use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers};
use uv_pep508::MarkerTreeContents;
use uv_preview::Preview;
use uv_preview::{Preview, PreviewFeature};
use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts};
use uv_python::managed::{ManagedPythonInstallation, PythonMinorVersionLink};
use uv_python::{
BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource,
PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest,
BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind,
LenientImplementationName, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonSource, PythonVariant, PythonVersionFile,
VersionFileDiscoveryOptions, VersionRequest,
};
use uv_requirements::{
LockedRequirements, NamedRequirementsResolver, RequirementsSpecification,
@@ -51,7 +53,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, SourceTreeE
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::pyproject::{ExtraBuildDependency, PyProjectToml};
use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache};
use uv_workspace::{ProjectEnvironmentSelection, RequiresPythonSources, Workspace, WorkspaceCache};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications};
@@ -937,7 +939,7 @@ enum EnvironmentIncompatibilityError {
PythonPreference(EnvironmentKind, PythonPreference),
}
/// Whether an environment is usable for a project or script, i.e., if it matches the requirements.
/// Check whether an environment satisfies the requested Python constraints.
fn check_environment_compatibility(
environment: &PythonEnvironment,
kind: EnvironmentKind,
@@ -996,12 +998,14 @@ fn check_environment_compatibility(
Ok(())
}
/// Discover a compatible project environment at `root`.
fn discover_project_environment(
root: &Path,
python_request: Option<&PythonRequest>,
python_preference: PythonPreference,
requires_python: Option<&RequiresPython>,
keep_incompatible: bool,
centralized: bool,
cache: &Cache,
) -> Result<Option<PythonEnvironment>, ProjectError> {
let environment = match PythonEnvironment::from_root(root, cache) {
@@ -1016,7 +1020,9 @@ fn discover_project_environment(
));
}
InvalidEnvironmentKind::MissingExecutable(_) => {
if fs_err::read_dir(root).is_ok_and(|mut dir| dir.next().is_some()) {
if !centralized
&& fs_err::read_dir(root).is_ok_and(|mut dir| dir.next().is_some())
{
if !root.join("pyvenv.cfg").try_exists().unwrap_or_default() {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root.to_path_buf(),
@@ -1066,10 +1072,21 @@ fn discover_project_environment(
) {
Ok(()) => Ok(Some(environment)),
Err(err) if keep_incompatible => {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
environment.root().user_display().cyan(),
);
if centralized {
let root = environment.root();
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
root.file_name()
.unwrap_or(root.as_os_str())
.to_string_lossy()
.cyan(),
);
} else {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
environment.root().user_display().cyan(),
);
}
Ok(Some(environment))
}
Err(err) => {
@@ -1079,6 +1096,148 @@ fn discover_project_environment(
}
}
/// Return whether to use centralized project environments for this invocation.
fn centralized_environments_enabled(
selection: &ProjectEnvironmentSelection,
cache: &Cache,
) -> bool {
if !selection.is_default() || !uv_preview::is_enabled(PreviewFeature::CentralizedProjectEnvs) {
return false;
}
if cache.is_temporary() {
warn_user_once!(
"The `centralized-project-envs` feature has no effect when `--no-cache` is enabled"
);
return false;
}
true
}
/// Return whether `path` is a link into the current cache's environment bucket.
fn is_centralized_environment_link(path: &Path, cache: &Cache) -> bool {
let Ok(target) = fs_err::read_link(path) else {
return false;
};
let Ok(environments) = std::path::absolute(cache.bucket(CacheBucket::Environments)) else {
// If we can't resolve the cache directory, the environment can't be in the cache.
return false;
};
// Compare Windows paths in the verbatim namespace so long targets returned with `\\?\` match
// the cache root.
let starts_with =
|path: &Path, base: &Path| verbatim_path(path).starts_with(verbatim_path(base).as_ref());
if starts_with(&target, &environments) {
return true;
}
// Resolve existing relative or indirect links; only lexical targets can be dangling.
fs_err::canonicalize(path).is_ok_and(|target| {
fs_err::canonicalize(&environments)
.is_ok_and(|environments| starts_with(&target, &environments))
})
}
/// Return the centralized environment path for a given workspace and interpreter.
fn centralized_environment_root(
workspace: &Workspace,
interpreter: &Interpreter,
upgradeable: bool,
cache: &Cache,
) -> PathBuf {
let interpreter_key = interpreter.key();
// Use the workspace path to isolate projects and the interpreter key to maximize intra-project
// environment re-use while avoiding clashes with incompatible environments. Ignoring the patch
// version allows upgradeable managed environments to be re-used after an upgrade.
let (digest, python_version) = if upgradeable
&& let Some(installation) = ManagedPythonInstallation::try_from_interpreter(interpreter)
&& PythonMinorVersionLink::from_installation(&installation)
.is_some_and(|link| link.exists())
{
(
cache_digest(&(workspace.install_path(), installation.minor_version_key())),
interpreter.python_minor_version(),
)
} else {
(
cache_digest(&(workspace.install_path(), &interpreter_key)),
interpreter.python_version().clone(),
)
};
let name = workspace
.pyproject_toml()
.project
.as_ref()
.and_then(|project| cache_name(project.name.as_ref(), Some(100)))
.or_else(|| {
workspace
.install_path()
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| cache_name(name, Some(100)))
});
let implementation = interpreter_key.implementation();
let implementation = match implementation.as_ref() {
LenientImplementationName::Known(implementation) => implementation
.short_name()
.unwrap_or_else(|| implementation.long_name()),
LenientImplementationName::Unknown(implementation) => implementation,
};
let entry = name.map_or_else(
// A virtual workspace can be nameless if its directory has no cache-safe characters.
|| format!("{implementation}{python_version}-{digest}"),
|name| format!("{name}-{implementation}{python_version}-{digest}"),
);
cache
.shard(CacheBucket::Environments, entry)
.into_path_buf()
}
/// How to report failures updating `.venv`.
#[derive(Clone, Copy)]
pub(crate) enum LinkErrorReporting {
/// Report failures to the user.
User,
/// Log failures at warning level.
Log,
}
/// Point the workspace's `.venv` to the centralized environment.
fn update_project_environment_link(
environment: &PythonEnvironment,
workspace: &Workspace,
link_error_reporting: LinkErrorReporting,
) {
let link = workspace.install_path().join(".venv");
let report_error = |message: &str, err: &std::io::Error| match link_error_reporting {
LinkErrorReporting::User => {
warn_user_once!("{message} at `{}`: {err}", link.user_display());
}
LinkErrorReporting::Log => warn!("{message} at `{}`: {err}", link.user_display()),
};
if fs_err::symlink_metadata(&link).is_ok_and(|metadata| metadata.is_dir()) {
if uv_fs::is_virtualenv_base(&link) {
if let Err(err) = uv_fs::remove_virtualenv(&link) {
report_error("Failed to remove existing local virtual environment", &err);
return;
}
} else {
// On Windows, copying a junction can produce an empty directory.
#[cfg(windows)]
if let Err(err) = fs_err::remove_dir(&link) {
report_error("Failed to create link to project environment", &err);
return;
}
}
}
// TODO(tk): When directory links are unavailable, write `.venv` as a file containing the
// environment path.
if let Err(err) = uv_fs::replace_symlink(environment.root(), &link) {
report_error("Failed to create link to project environment", &err);
}
}
/// An interpreter suitable for the project.
#[derive(Debug)]
#[expect(clippy::large_enum_variant)]
@@ -1110,14 +1269,44 @@ impl ProjectInterpreter {
requires_python,
} = workspace_python;
// Read from the virtual environment first.
let root = workspace.venv(active);
if let Some(environment) = discover_project_environment(
&root,
let environment_selection = workspace.environment_selection(active);
let centralized = centralized_environments_enabled(&environment_selection, cache);
let upgradeable = python_request
.as_ref()
.is_none_or(|request| !request.includes_patch());
// Prefer `.venv`'s interpreter to keep its compatible cached environment selected; derive
// the cache root instead of trusting the link target.
if centralized {
let project_environment_path = workspace.install_path().join(".venv");
if let Ok(candidate) = PythonEnvironment::from_root(&project_environment_path, cache) {
let root = centralized_environment_root(
workspace,
candidate.interpreter(),
upgradeable,
cache,
);
if let Some(environment) = discover_project_environment(
&root,
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
keep_incompatible,
centralized,
cache,
)? {
return Ok(Self::Environment(environment));
}
}
} else if let Some(environment) = discover_project_environment(
&environment_selection
.explicit_path()
.map_or_else(|| workspace.install_path().join(".venv"), Path::to_path_buf),
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
keep_incompatible,
centralized,
cache,
)? {
return Ok(Self::Environment(environment));
@@ -1140,6 +1329,22 @@ impl ProjectInterpreter {
)
.await?;
if centralized {
let root =
centralized_environment_root(workspace, python.interpreter(), upgradeable, cache);
if let Some(environment) = discover_project_environment(
&root,
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
keep_incompatible,
centralized,
cache,
)? {
return Ok(Self::Environment(environment));
}
}
let managed = python.source().is_managed();
let implementation = python.implementation();
let interpreter = python.into_interpreter();
@@ -1180,7 +1385,7 @@ impl ProjectInterpreter {
pub(crate) fn into_interpreter(self) -> Interpreter {
match self {
Self::Interpreter(interpreter) => interpreter,
Self::Environment(venv) => venv.into_interpreter(),
Self::Environment(environment) => environment.into_interpreter(),
}
}
}
@@ -1464,8 +1669,12 @@ impl ProjectEnvironment {
active: Option<bool>,
cache: &Cache,
dry_run: DryRun,
link_error_reporting: LinkErrorReporting,
printer: Printer,
) -> Result<Self, ProjectError> {
let environment_selection = workspace.environment_selection(active);
let centralized = centralized_environments_enabled(&environment_selection, cache);
// Lock the project environment to avoid synchronization issues.
let _lock = lock_project_environment(workspace)
.await
@@ -1482,7 +1691,6 @@ impl ProjectEnvironment {
no_config,
)
.await?;
let upgradeable = workspace_python
.python_request
.as_ref()
@@ -1504,39 +1712,60 @@ impl ProjectEnvironment {
.await?
{
// If we found an existing, compatible environment, use it.
ProjectInterpreter::Environment(environment) => Ok(Self::Existing(environment)),
ProjectInterpreter::Environment(environment) => {
if centralized && !dry_run.enabled() {
update_project_environment_link(&environment, workspace, link_error_reporting);
}
Ok(Self::Existing(environment))
}
// Otherwise, create a virtual environment with the discovered interpreter.
ProjectInterpreter::Interpreter(interpreter) => {
let root = workspace.venv(active);
let root = if centralized {
centralized_environment_root(workspace, &interpreter, upgradeable, cache)
} else {
environment_selection
.explicit_path()
.map_or_else(|| workspace.install_path().join(".venv"), Path::to_path_buf)
};
let centralized_environment_link =
!centralized && is_centralized_environment_link(&root, cache);
// Avoid removing things that are not virtual environments
let replace = match (root.try_exists(), root.join("pyvenv.cfg").try_exists()) {
// It's a virtual environment we can remove it
(_, Ok(true)) => true,
// It doesn't exist at all, we should use it without deleting it to avoid TOCTOU bugs
(Ok(false), Ok(false)) => false,
// If it's not a virtual environment, bail
(Ok(true), Ok(false)) => {
// Unless it's empty, in which case we just ignore it
if root.read_dir().is_ok_and(|mut dir| dir.next().is_none()) {
false
} else {
// Avoid removing things that are not virtual environments and are outside the
// environment cache.
let replace_environment = if centralized_environment_link {
true
} else {
match (root.try_exists(), root.join("pyvenv.cfg").try_exists()) {
// It's a virtual environment we can remove it
(_, Ok(true)) => true,
// It doesn't exist at all, we should use it without deleting it to avoid TOCTOU bugs
(Ok(false), Ok(false)) => false,
// If it's not a virtual environment, bail
(Ok(true), Ok(false)) => {
// Unless it's empty, in which case we just ignore it
if root.read_dir().is_ok_and(|mut dir| dir.next().is_none()) {
false
} else if centralized {
// Unless it's the derived cache entry, which is uv-owned and safe to replace
true
} else {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root,
"it is not a compatible environment but cannot be recreated because it is not a virtual environment".to_string(),
));
}
}
// Similarly, if we can't _tell_ if it exists we should bail
(_, Err(err)) | (Err(err), _) => {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root,
"it is not a compatible environment but cannot be recreated because it is not a virtual environment".to_string(),
format!(
"it is not a compatible environment but cannot be recreated because uv cannot determine if it is a virtual environment: {err}"
),
));
}
}
// Similarly, if we can't _tell_ if it exists we should bail
(_, Err(err)) | (Err(err), _) => {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root,
format!(
"it is not a compatible environment but cannot be recreated because uv cannot determine if it is a virtual environment: {err}"
),
));
}
};
// Determine a prompt for the environment, in order of preference:
@@ -1573,33 +1802,55 @@ impl ProjectEnvironment {
false,
upgradeable,
)?;
return Ok(if replace {
return Ok(if replace_environment {
Self::WouldReplace(root, environment, temp_dir)
} else {
Self::WouldCreate(root, environment, temp_dir)
});
}
// Remove the existing virtual environment if it doesn't meet the requirements.
if replace {
match uv_fs::remove_virtualenv(&root) {
Ok(()) => {
writeln!(
printer.stderr(),
"Removed virtual environment at: {}",
root.user_display().cyan()
)?;
if replace_environment {
// `clear_virtualenv` follows directory links, so unlink centralized links
// directly to preserve their cached targets.
let removed = if centralized_environment_link {
match uv_fs::remove_virtualenv(&root) {
Ok(()) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
Err(err) => return Err(uv_virtualenv::Error::from(err).into()),
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(uv_virtualenv::Error::from(err).into()),
} else {
uv_fs::clear_virtualenv(&root).map_err(uv_virtualenv::Error::from)?
};
if removed {
let removed_entry = if centralized_environment_link {
"link to project environment"
} else {
"virtual environment"
};
writeln!(
printer.stderr(),
"Removed {removed_entry} at: {}",
root.user_display().cyan()
)?;
}
}
writeln!(
printer.stderr(),
"Creating virtual environment at: {}",
root.user_display().cyan()
)?;
if centralized {
writeln!(
printer.stderr(),
"Creating virtual environment `{}`",
root.file_name()
.unwrap_or(root.as_os_str())
.to_string_lossy()
.cyan(),
)?;
} else {
writeln!(
printer.stderr(),
"Creating virtual environment at: {}",
root.user_display().cyan()
)?;
}
let environment = uv_virtualenv::create_venv(
&root,
@@ -1614,7 +1865,11 @@ impl ProjectEnvironment {
upgradeable,
)?;
if replace {
if centralized {
update_project_environment_link(&environment, workspace, link_error_reporting);
}
if replace_environment {
Ok(Self::Replaced(environment))
} else {
Ok(Self::Created(environment))
+3 -2
View File
@@ -31,8 +31,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState,
WorkspacePython, default_dependency_groups,
LinkErrorReporting, ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptInterpreter,
UniversalState, WorkspacePython, default_dependency_groups,
};
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
@@ -265,6 +265,7 @@ pub(crate) async fn remove(
active,
cache,
DryRun::Disabled,
LinkErrorReporting::User,
printer,
)
.await?
+3 -2
View File
@@ -68,8 +68,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
EnvironmentSpecification, PreferenceLocation, ProjectEnvironment, ProjectError,
ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython,
EnvironmentSpecification, LinkErrorReporting, PreferenceLocation, ProjectEnvironment,
ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython,
default_dependency_groups, script_extra_build_requires, script_specification,
update_environment, validate_project_requires_python,
};
@@ -712,6 +712,7 @@ pub(crate) async fn run(
active,
&cache,
DryRun::Disabled,
LinkErrorReporting::Log,
printer,
)
.await?
+3 -2
View File
@@ -46,8 +46,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::{LockMode, LockOperation, LockResult};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
EnvironmentUpdate, MalwareFindings, PlatformState, ProjectEnvironment, ProjectError,
ScriptEnvironment, UniversalState, default_dependency_groups, detect_conflicts,
EnvironmentUpdate, LinkErrorReporting, MalwareFindings, PlatformState, ProjectEnvironment,
ProjectError, ScriptEnvironment, UniversalState, default_dependency_groups, detect_conflicts,
script_extra_build_requires, script_specification, update_environment,
};
use crate::commands::{ExitStatus, diagnostics};
@@ -170,6 +170,7 @@ pub(crate) async fn sync(
active,
cache,
dry_run,
LinkErrorReporting::User,
printer,
)
.await?,
+3 -2
View File
@@ -36,8 +36,8 @@ use crate::commands::project::add::{AddTarget, PythonTarget};
use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::{
ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython,
default_dependency_groups,
LinkErrorReporting, ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState,
WorkspacePython, default_dependency_groups,
};
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
@@ -652,6 +652,7 @@ async fn lock_and_sync(
active,
cache,
DryRun::Disabled,
LinkErrorReporting::User,
printer,
)
.await?
+9 -2
View File
@@ -130,11 +130,18 @@ pub(crate) async fn venv(
.map(VirtualProject::workspace)
.filter(|workspace| path.is_none() && workspace.install_path() == project_dir);
let project_environment_selection =
project_environment.map(|workspace| workspace.environment_selection(Some(false)));
// Determine the default path; either the virtual environment for the project or `.venv`.
let path = path.unwrap_or_else(|| {
project_environment.map_or_else(
project_environment_selection.map_or_else(
|| PathBuf::from(".venv"),
|workspace| workspace.venv(Some(false)),
|selection| {
selection
.explicit_path()
.map_or_else(|| project_dir.join(".venv"), Path::to_path_buf)
},
)
});
+3 -1
View File
@@ -21,7 +21,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::{LockMode, LockOperation};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython,
LinkErrorReporting, ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState,
WorkspacePython,
};
use crate::commands::{ExitStatus, diagnostics};
use crate::printer::Printer;
@@ -147,6 +148,7 @@ pub(crate) async fn metadata(
Some(false),
cache,
DryRun::Disabled,
LinkErrorReporting::User,
printer,
)
.await?;
+1 -1
View File
@@ -163,7 +163,7 @@ fn prune_cached_env() {
DEBUG Searching for user configuration in: `[UV_USER_CONFIG_DIR]/uv.toml`
DEBUG uv [VERSION] ([COMMIT] DATE)
Pruning cache at: [CACHE_DIR]/
DEBUG Removing dangling cache environment: [CACHE_DIR]/environments-v2/[ENTRY]
DEBUG Removing cached environment: [CACHE_DIR]/environments-v2/[ENTRY]
DEBUG Removing dangling cache archive: [CACHE_DIR]/archive-v0/[ENTRY]
Removed [N] files ([SIZE])
");
+44
View File
@@ -7088,3 +7088,47 @@ async fn run_malware_detected() {
error: Malware detected in one or more dependencies that would be installed; aborting sync. Set `UV_MALWARE_CHECK=0` to bypass this check.
");
}
#[test]
fn run_centralized_environment_no_sync_uses_incompatible_python() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.11", "3.12"])
.with_filtered_centralized_environment_hashes();
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
"#})?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
// `--no-sync` reuses the existing environment despite the Python request.
uv_snapshot!(context.filters(), context.run()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--no-sync")
.arg("--python")
.arg("3.11")
.arg("python")
.arg("-c")
.arg("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"), @r#"
success: true
exit_code: 0
----- stdout -----
3.12
----- stderr -----
warning: Using incompatible environment (`project-cp3.12.[X]-[HASH]`) due to `--no-sync` (The project environment's Python version does not satisfy the request: `Python 3.11`)
"#);
Ok(())
}
+34
View File
@@ -8,6 +8,40 @@ use url::Url;
use uv_static::EnvVars;
use uv_test::uv_snapshot;
#[test]
fn tree_centralized_environment_no_cache() -> Result<()> {
let context = uv_test::test_context!("3.12");
fs_err::remove_dir_all(&context.venv)?;
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
uv_snapshot!(context.filters(), context.tree()
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-project-envs"), @"
success: true
exit_code: 0
----- stdout -----
project v0.1.0
----- stderr -----
warning: The `centralized-project-envs` feature has no effect when `--no-cache` is enabled
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 1 package in [TIME]
");
assert!(!context.venv.exists());
Ok(())
}
#[test]
fn nested_dependencies() -> Result<()> {
let context = uv_test::test_context!("3.12");
@@ -0,0 +1,855 @@
use anyhow::Result;
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use insta::assert_snapshot;
use serde_json::json;
use std::process::Command;
use uv_fs::Simplified;
use uv_static::EnvVars;
#[cfg(unix)]
use uv_test::ReadOnlyDirectoryGuard;
use uv_test::{TestContext, uv_snapshot};
fn write_project(
context: &TestContext,
requires_python: &str,
dependencies: &[&str],
) -> Result<()> {
let pyproject_toml = toml::to_string(&json!({
"project": {
"name": "project",
"version": "0.1.0",
"requires-python": requires_python,
"dependencies": dependencies,
}
}))?;
context
.temp_dir
.child("pyproject.toml")
.write_str(&pyproject_toml)?;
Ok(())
}
#[test]
fn sync_centralized_env() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"])
.with_filtered_centralized_environment_hashes();
write_project(&context, ">=3.12", &["iniconfig"])?;
// Creates the environment in the centralized store.
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment `project-cp3.12.[X]-[HASH]`
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"#);
let link = context.temp_dir.child(".venv");
let target = fs_err::read_link(link.path())?;
// The project link points into the cache.
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
fs_err::remove_dir_all(link.path())?;
// Reuses the cache entry without recreating `.venv`.
uv_snapshot!(context.filters(), context.sync()
.arg("--dry-run")
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Would use project environment at: [CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]
Resolved 2 packages in [TIME]
Found up-to-date lockfile at: uv.lock
Checked 1 package in [TIME]
Would make no changes
"#);
// Only the cached environment remains.
assert!(!link.exists());
assert!(target.is_dir());
Ok(())
}
#[test]
fn sync_centralized_env_switch_python() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]);
write_project(&context, ">=3.11", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
let link_312 = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(link_312.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.11")
.assert()
.success();
let link_311 = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(link_311.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.11.[X]-[HASH]");
});
// The original environment is reused, not recreated.
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
Ok(())
}
#[test]
#[cfg(feature = "test-python-managed")]
fn sync_centralized_env_distinguishes_python_patch() -> Result<()> {
let context = uv_test::test_context_with_versions!(&[])
.with_managed_python_dirs()
.with_python_download_cache();
context
.python_install()
.arg("3.12.9")
.arg("3.12.11")
.assert()
.success();
write_project(&context, ">=3.12", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12.9")
.assert()
.success();
let first = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(first.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.9-[HASH]");
});
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12.11")
.assert()
.success();
let second = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(second.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.11-[HASH]");
});
Ok(())
}
#[test]
#[cfg(feature = "test-python-managed")]
fn sync_centralized_env_survives_python_patch_upgrade() -> Result<()> {
let context = uv_test::test_context_with_versions!(&[])
.with_managed_python_dirs()
.with_python_download_cache();
context.python_install().arg("3.12.9").assert().success();
write_project(&context, ">=3.12", &[])?;
// Create an upgradeable environment.
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
let first = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(first.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12-[HASH]");
});
// The transparent upgrade retargets the environment to a different interpreter.
let python = if cfg!(windows) {
first.join("Scripts/python.exe")
} else {
first.join("bin/python")
};
context.python_install().arg("3.12.11").assert().success();
uv_snapshot!(context.filters(), Command::new(&python).arg("--version"), @r#"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"#);
// Should reuse the same environment without re-creating after the transparent upgrade.
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
let second = fs_err::read_link(context.temp_dir.child(".venv").path())?;
assert_eq!(first, second);
Ok(())
}
#[test]
fn sync_centralized_env_avoids_project_name_collisions() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
let project_a = context.temp_dir.child("project-a");
let project_b = context.temp_dir.child("project-b");
for project in [&project_a, &project_b] {
project.create_dir_all()?;
project.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#,
)?;
}
for project in [&project_a, &project_b] {
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--project")
.arg(project.path())
.assert()
.success();
}
let target_a = fs_err::read_link(project_a.child(".venv").path())?;
let target_b = fs_err::read_link(project_b.child(".venv").path())?;
// Projects with the same name use different environments.
assert_ne!(target_a, target_b);
Ok(())
}
#[test]
fn sync_centralized_env_respects_explicit_environments() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "override")
.assert()
.success();
// `UV_PROJECT_ENVIRONMENT` bypasses centralized environments.
assert!(context.temp_dir.child("override").is_dir());
assert!(!context.temp_dir.child(".venv").exists());
let active = context.temp_dir.child("active");
context.venv().arg(active.path()).assert().success();
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--active")
.env_remove(EnvVars::UV_PROJECT_ENVIRONMENT)
.env(EnvVars::VIRTUAL_ENV, active.path())
.assert()
.success();
// `--active` uses `VIRTUAL_ENV` directly.
assert!(active.is_dir());
assert!(!context.temp_dir.child(".venv").exists());
Ok(())
}
#[test]
fn sync_centralized_env_respects_active_default_environment() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
let environment = context.temp_dir.child(".venv");
context.venv().arg(environment.path()).assert().success();
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--active")
.env(EnvVars::VIRTUAL_ENV, environment.path()), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert!(!context.cache_dir.child("environments-v2").exists());
Ok(())
}
#[test]
fn sync_centralized_env_virtual_workspace() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
context.temp_dir.child("pyproject.toml").write_str(
r#"
[tool.uv.workspace]
members = ["member"]
"#,
)?;
let member = context.temp_dir.child("member");
member.create_dir_all()?;
member.child("pyproject.toml").write_str(
r#"
[project]
name = "member"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#,
)?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
// The workspace root owns the centralized environment.
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/temp-cp3.12.[X]-[HASH]");
});
// The workspace member does not get its own environment.
assert!(!member.child(".venv").exists());
Ok(())
}
#[test]
fn sync_centralized_env_dry_run() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &["iniconfig"])?;
// Reports the persistent centralized environment path.
uv_snapshot!(context.filters(), context.sync()
.arg("--dry-run")
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Would create project environment at: [CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]
Resolved 2 packages in [TIME]
Would create lockfile at: uv.lock
Would download 1 package
Would install 1 package
+ iniconfig==2.0.0
"#);
// Dry-run creates neither the persistent environment nor its project link.
assert!(!context.temp_dir.child(".venv").exists());
assert!(!context.cache_dir.child("environments-v2").exists());
Ok(())
}
#[test]
fn cache_prune_removes_and_recreates_centralized_environment() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
let cache_dir = "cache";
context
.sync()
.env(EnvVars::UV_CACHE_DIR, cache_dir)
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let link = context.temp_dir.child(".venv");
let target = fs_err::read_link(link.path())?;
context
.prune()
.env(EnvVars::UV_CACHE_DIR, cache_dir)
.assert()
.success();
assert!(!target.exists());
assert_eq!(target, fs_err::read_link(link.path())?);
// Without the preview, uv replaces the dangling cache link with a local environment.
context
.sync()
.env(EnvVars::UV_CACHE_DIR, cache_dir)
.assert()
.success();
assert!(link.is_dir());
assert!(fs_err::read_link(link.path()).is_err());
context
.sync()
.env(EnvVars::UV_CACHE_DIR, cache_dir)
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
// The recreated environment uses the same cache entry.
assert_eq!(target, fs_err::read_link(link.path())?);
// The dangling target is recreated.
assert!(target.is_dir());
Ok(())
}
#[test]
fn sync_recovers_incomplete_centralized_environment() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let link = context.temp_dir.child(".venv");
let target = fs_err::read_link(link.path())?;
// Simulate a mangled environment (e.g., due to interruption).
uv_fs::remove_virtualenv(link.path())?;
uv_fs::remove_virtualenv(&target)?;
fs_err::create_dir(&target)?;
uv_fs::cachedir::ensure_tag(&target)?;
fs_err::write(target.join(".gitignore"), "*")?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
assert_eq!(target, fs_err::read_link(link.path())?);
assert!(target.join("pyvenv.cfg").is_file());
Ok(())
}
#[test]
fn sync_centralized_env_no_cache_uses_dot_venv() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"])
.with_filtered_centralized_environment_hashes();
write_project(&context, ">=3.12", &[])?;
uv_snapshot!(context.filters(), context.sync()
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `centralized-project-envs` feature has no effect when `--no-cache` is enabled
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
let environment = context.temp_dir.child(".venv");
assert!(environment.is_dir());
assert!(fs_err::read_link(environment.path()).is_err());
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment `project-cp3.12.[X]-[HASH]`
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
let target = fs_err::read_link(environment.path())?;
// A later cached invocation replaces the local environment with a centralized one.
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
// With `--no-cache`, uv follows the existing `.venv` link without recreating the environment.
uv_snapshot!(context.filters(), context.sync()
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `centralized-project-envs` feature has no effect when `--no-cache` is enabled
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert_eq!(fs_err::read_link(environment.path())?, target);
Ok(())
}
#[test]
fn sync_centralized_env_replaces_existing_directory_link() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
let environment = context.temp_dir.child(".venv");
let previous_target = context.temp_dir.child("previous-target");
previous_target.create_dir_all()?;
let marker = previous_target.child("marker");
marker.touch()?;
uv_fs::create_symlink(previous_target.path(), environment.path())?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let target = fs_err::read_link(environment.path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
assert!(marker.is_file());
Ok(())
}
#[test]
fn sync_centralized_env_with_existing_file() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
let environment = context.temp_dir.child(".venv");
environment.write_str("user-data")?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
#[cfg(unix)]
{
let target = fs_err::read_link(environment.path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
}
#[cfg(windows)]
{
// TODO(tk): This changes once `.venv` can store an environment path.
assert_eq!(fs_err::read_to_string(environment.path())?, "user-data");
assert!(context.cache_dir.child("environments-v2").is_dir());
}
Ok(())
}
#[cfg(windows)]
#[test]
fn sync_centralized_env_replaces_existing_empty_directory() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
context.temp_dir.child(".venv").create_dir_all()?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]");
});
Ok(())
}
#[test]
fn run_and_sync_link_failure_reporting() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"])
.with_filtered_centralized_environment_hashes()
.with_filter((
r"(?m)^(warning: Failed to create link to project environment at `[^`]+`): .*$",
"$1: [ERR]",
));
write_project(&context, ">=3.12", &["iniconfig"])?;
let environment = context.temp_dir.child(".venv");
environment.create_dir_all()?;
environment.child("keep").touch()?;
// `uv run` uses the centralized environment without reporting a link update failure.
uv_snapshot!(context.filters(), context.run()
.current_dir(&context.home_dir)
.arg("--project")
.arg(context.temp_dir.path())
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("python")
.arg("-c")
.arg("import iniconfig"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment `project-cp3.12.[X]-[HASH]`
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"#);
assert!(environment.child("keep").is_file());
// `uv sync` reports the same link update failure to the user.
uv_snapshot!(context.filters(), context.sync()
.current_dir(&context.home_dir)
.arg("--project")
.arg(context.temp_dir.path())
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Failed to create link to project environment at `[VENV]/`: [ERR]
Resolved 2 packages in [TIME]
Checked 1 package in [TIME]
"#);
assert!(environment.child("keep").is_file());
Ok(())
}
#[cfg(unix)]
#[test]
fn sync_centralized_env_local_environment_removal_failure_is_not_fatal() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"])
.with_filtered_centralized_environment_hashes();
write_project(&context, ">=3.12", &[])?;
let environment = context.temp_dir.child(".venv");
environment.create_dir_all()?;
let pyvenv_cfg = environment.child("pyvenv.cfg");
pyvenv_cfg.touch()?;
// Prevent uv from removing `pyvenv.cfg` while replacing the local environment.
let _guard = ReadOnlyDirectoryGuard::new(environment.path())?;
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment `project-cp3.12.[X]-[HASH]`
warning: Failed to remove existing local virtual environment at `.venv`: failed to remove file `[VENV]/pyvenv.cfg`: Permission denied (os error 13)
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert!(pyvenv_cfg.is_file());
assert!(context.cache_dir.child("environments-v2").is_dir());
Ok(())
}
#[cfg(unix)]
#[test]
fn sync_centralized_env_link_creation_failure_preserves_cached_target() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"])
.with_filter((r"\.tmp[a-zA-Z0-9]+", "[TMP]"));
write_project(&context, ">=3.12", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.assert()
.success();
let environment = context.temp_dir.child(".venv");
// Record the cache target to verify the failed link update leaves it selected.
let target = fs_err::read_link(environment.path())?;
let _guard = ReadOnlyDirectoryGuard::new(context.temp_dir.path())?;
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Failed to create link to project environment at `.venv`: Permission denied (os error 13) at path "[TEMP_DIR]/[TMP]"
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert_eq!(target, fs_err::read_link(environment.path())?);
assert!(target.join("pyvenv.cfg").is_file());
Ok(())
}
#[test]
fn sync_replaces_environment_links_without_removing_cached_targets() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]);
write_project(&context, ">=3.11", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
// Record the cache target so the final sync can verify it remains reusable.
let cache_target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
let override_environment = context.temp_dir.child("override");
uv_fs::create_symlink(&cache_target, override_environment.path())?;
context
.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.11")
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "override")
.assert()
.success();
// An explicit environment path is local, but replacing it does not remove the cached target.
assert!(override_environment.is_dir());
assert!(fs_err::read_link(override_environment.path()).is_err());
let environment = context.temp_dir.child(".venv");
let intermediate = context.temp_dir.child("intermediate");
uv_fs::create_symlink(&cache_target, intermediate.path())?;
uv_fs::replace_symlink(intermediate.path(), environment.path())?;
uv_snapshot!(context.filters(), context
.sync()
.arg("--python")
.arg("3.11"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Removed link to project environment at: .venv
Creating virtual environment at: .venv
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
// Without the preview, uv replaces the indirect cache link with a local environment.
assert!(environment.is_dir());
assert!(fs_err::read_link(environment.path()).is_err());
// uv rebuilds the linked environment without replacing the link.
let target = context.temp_dir.child("environment");
fs_err::rename(environment.path(), target.path())?;
uv_fs::create_symlink(target.path(), environment.path())?;
uv_snapshot!(context.filters(), context.sync()
.arg("--python")
.arg("3.12"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert_eq!(fs_err::read_link(environment.path())?, target.path());
// The link still points to the rebuilt Python 3.12 environment.
let python = if cfg!(windows) {
target.join("Scripts/python.exe")
} else {
target.join("bin/python")
};
uv_snapshot!(context.filters(), Command::new(python).arg("--version"), @r#"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
----- stderr -----
"#);
// uv reuses the cached environment without recreating it.
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-project-envs")
.arg("--python")
.arg("3.12"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Checked in [TIME]
"#);
assert_eq!(fs_err::read_link(environment.path())?, cache_target);
Ok(())
}
+3
View File
@@ -1,5 +1,8 @@
//! Integration tests for uv synchronization and settings.
#[cfg(all(feature = "test-python", feature = "test-pypi"))]
mod centralized_project_envs;
#[cfg(all(feature = "test-python", feature = "test-pypi"))]
mod show_settings;
+1
View File
@@ -3053,6 +3053,7 @@ fn preview_features() {
+ VenvSafeClear,
+ Check,
+ PackagedInit,
+ CentralizedProjectEnvs,
+ ],
},
python_preference: Managed,
@@ -102,6 +102,41 @@ fn workspace_metadata_simple() {
);
}
#[test]
fn workspace_metadata_sync_centralized_environment() -> 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"
dependencies = []
"#,
)?;
let assert = context
.workspace_metadata()
.arg("--sync")
.arg("--preview-features")
.arg("workspace-metadata,centralized-project-envs")
.assert()
.success();
let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?;
let target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
assert_eq!(
metadata["environment"]["root"].as_str().map(Path::new),
Some(target.as_path())
);
assert_eq!(
target.parent(),
Some(context.cache_dir.child("environments-v2").path())
);
Ok(())
}
#[test]
fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> {
let context = uv_test::test_context!("3.12");
+4 -3
View File
@@ -140,9 +140,10 @@ uv provides a few different mechanisms for removing entries from the cache:
- `uv cache clean` removes _all_ cache entries from the cache directory, clearing it out entirely.
- `uv cache clean ruff` removes all cache entries for the `ruff` package, useful for invalidating
the cache for a single or finite set of packages.
- `uv cache prune` removes all _unused_ cache entries. For example, the cache directory may contain
entries created in previous uv versions that are no longer necessary and can be safely removed.
`uv cache prune` is safe to run periodically, to keep the cache directory clean.
- `uv cache prune` removes all _unused_ cache entries and all centralized project environments. For
example, the cache directory may contain entries created in previous uv versions that are no
longer necessary and can be safely removed. Centralized project environments are recreated as
needed. `uv cache prune` is safe to run periodically, to keep the cache directory clean.
uv blocks cache-modifying operations while other uv commands are running. By default, those
`uv cache` commands have a 5 min timeout waiting for other uv processes to terminate to avoid
+3
View File
@@ -70,6 +70,9 @@ The following preview features are available:
- `add-bounds`: Allows configuring the
[default bounds for `uv add`](../reference/settings.md#add-bounds) invocations.
- `centralized-project-envs`: Stores
[project virtual environments](./projects/layout.md#centralized-project-environments) in the uv
cache.
- `json-output`: Allows `--output-format json` for various uv commands.
- `package-conflicts`: Allows defining workspace conflicts at the package level.
- `pylock`: Allows installing from `pylock.toml` files.
+14 -3
View File
@@ -31,9 +31,9 @@ Additional project metadata and configuration includes:
When working on a project with uv, uv will create a virtual environment as needed. While some uv
commands will create a temporary environment (e.g., `uv run --isolated`), uv also manages a
persistent environment with the project and its dependencies in a `.venv` directory next to the
`pyproject.toml`. It is stored inside the project to make it easy for editors to find — they need
the environment to give code completions and type hints. It is not recommended to include the
`.venv` directory in version control; it is automatically excluded from `git` with an internal
`pyproject.toml`. By default, it is stored inside the project to make it easy for editors to find —
they need the environment to give code completions and type hints. It is not recommended to include
the `.venv` directory in version control; it is automatically excluded from `git` with an internal
`.gitignore` file.
To run a command in the project environment, use `uv run`. Alternatively the project environment can
@@ -58,6 +58,17 @@ use [`uvx`](../../guides/tools.md) or
managed = false
```
### Centralized project environments
With the [`centralized-project-envs` preview feature](../preview.md), uv stores the default project
environment in its cache. uv attempts to maintain a `.venv` directory link to the cached environment
so existing activation and editor workflows can continue to use the usual path. If link creation
fails, uv continues using the cached environment directly, but tools relying on `.venv` may not
discover it. Switching interpreters selects separate cached environments and can reuse them later.
Explicit project environment paths, including `UV_PROJECT_ENVIRONMENT` and environments selected
with `--active`, are not centralized. The feature has no effect when `--no-cache` is enabled.
## The lockfile
uv creates a `uv.lock` file next to the `pyproject.toml`.
+1 -1
View File
@@ -96,7 +96,7 @@ Managing and inspecting uv's state, such as the cache, storage directories, or p
self-update:
- `uv cache clean`: Remove cache entries.
- `uv cache prune`: Remove outdated cache entries.
- `uv cache prune`: Remove outdated cache entries and all centralized project environments.
- `uv cache dir`: Show the uv cache directory path.
- `uv tool dir`: Show the uv tool directory path.
- `uv python dir`: Show the uv installed Python versions path.
+6
View File
@@ -194,6 +194,12 @@ Use the `UV_PROJECT_ENVIRONMENT` environment variable to override this location.
see the
[projects environment documentation](../concepts/projects/config.md#project-environment-path).
With the [`centralized-project-envs` preview feature](../concepts/preview.md), uv stores default
project environments in the [cache directory](#cache-directory). They can be removed by
`uv cache clean` or `uv cache prune` and are recreated when next needed. See the
[centralized project environments](../concepts/projects/layout.md#centralized-project-environments)
documentation for details.
### Script virtual environments
When running [scripts with inline metadata](../guides/scripts.md), uv creates a dedicated virtual
+2 -1
View File
@@ -1673,7 +1673,8 @@
"malware-check",
"venv-safe-clear",
"check-command",
"packaged-init"
"packaged-init",
"centralized-project-envs"
]
},
{