Add centralised environments in preview

This commit is contained in:
Tomasz (Tom) Kramkowski
2026-06-05 14:55:01 +01:00
parent 006a7ab307
commit 1874d8c9c8
26 changed files with 1270 additions and 72 deletions
+2 -2
View File
@@ -614,8 +614,8 @@ 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 on demand when their project is next used.
match fs_err::read_dir(self.bucket(CacheBucket::Environments)) {
Ok(entries) => {
for entry in entries {
+4
View File
@@ -258,6 +258,7 @@ pub enum PreviewFeature {
VenvSafeClear = 1 << 32,
Check = 1 << 33,
PackagedInit = 1 << 34,
CentralizedEnvs = 1 << 35,
}
impl PreviewFeature {
@@ -299,6 +300,7 @@ impl PreviewFeature {
Self::VenvSafeClear => "venv-safe-clear",
Self::Check => "check-command",
Self::PackagedInit => "packaged-init",
Self::CentralizedEnvs => "centralized-envs",
}
}
}
@@ -353,6 +355,7 @@ impl FromStr for PreviewFeature {
"venv-safe-clear" => Self::VenvSafeClear,
"check" | "check-command" => Self::Check,
"packaged-init" => Self::PackagedInit,
"centralized-envs" => Self::CentralizedEnvs,
_ => return Err(PreviewFeatureParseError),
})
}
@@ -644,6 +647,7 @@ 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::CentralizedEnvs.as_str(), "centralized-envs");
}
#[test]
+7
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.to_string().cache_key(state);
}
}
impl FromStr for PythonInstallationKey {
type Err = PythonInstallationKeyError;
+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(),
+5 -1
View File
@@ -1055,9 +1055,13 @@ impl TestContext {
));
// Filter script environment 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(),
));
filters.push((
r"`([\w.\[\]-]+)-[a-f0-9]{16}`".to_string(),
"`$1-[HASH]`".to_string(),
));
// Filter archive hashes
filters.push((
r"archive-v(\d+)[\\/][A-Za-z0-9\-\_]+".to_string(),
+1 -8
View File
@@ -861,7 +861,7 @@ impl Workspace {
/// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any
/// warnings about mismatch between the active environment and the project environment will be
/// silenced.
pub fn venv_path(&self, active: Option<bool>) -> ProjectEnvironmentTarget {
pub fn venv(&self, active: Option<bool>) -> ProjectEnvironmentTarget {
/// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
@@ -935,13 +935,6 @@ impl Workspace {
project_env
}
/// The path to the workspace virtual environment.
pub fn venv(&self, active: Option<bool>) -> PathBuf {
self.venv_path(active)
.project_path(&self.install_path)
.into_owned()
}
/// The members of the workspace.
pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
&self.packages
+1
View File
@@ -319,6 +319,7 @@ pub(crate) async fn add(
cache,
DryRun::Disabled,
printer,
true,
)
.await?
.into_environment()?;
+1
View File
@@ -225,6 +225,7 @@ pub(crate) async fn check(
cache,
DryRun::Disabled,
printer,
true,
)
.await?
.into_environment()?
+265 -57
View File
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use std::path::{Path, PathBuf};
@@ -22,13 +23,13 @@ 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::{
BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads,
@@ -51,7 +52,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::{ProjectEnvironmentTarget, RequiresPythonSources, Workspace, WorkspaceCache};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications};
@@ -260,6 +261,9 @@ pub(crate) enum ProjectError {
#[error("Project virtual environment directory `{0}` cannot be used because {1}")]
InvalidProjectEnvironmentDir(PathBuf, String),
#[error("`--no-cache` is unsupported with the `centralized-envs` feature enabled")]
CentralizedEnvironmentNoCache,
#[error("Failed to parse `uv.lock`")]
UvLockParse(#[source] toml::de::Error),
@@ -1003,6 +1007,7 @@ fn usable_project_environment(
python_preference: PythonPreference,
requires_python: Option<&RequiresPython>,
keep_incompatible: bool,
centralized: bool,
cache: &Cache,
) -> Option<PythonEnvironment> {
match environment_is_usable(
@@ -1015,10 +1020,21 @@ fn usable_project_environment(
) {
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(),
);
}
Some(environment)
}
Err(err) => {
@@ -1034,6 +1050,7 @@ fn discover_project_environment(
python_preference: PythonPreference,
requires_python: Option<&RequiresPython>,
keep_incompatible: bool,
centralized: bool,
cache: &Cache,
) -> Result<Option<PythonEnvironment>, ProjectError> {
match PythonEnvironment::from_root(root, cache) {
@@ -1043,6 +1060,7 @@ fn discover_project_environment(
python_preference,
requires_python,
keep_incompatible,
centralized,
cache,
)),
Err(uv_python::Error::MissingEnvironment(_)) => Ok(None),
@@ -1055,7 +1073,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(),
@@ -1094,6 +1114,108 @@ fn discover_project_environment(
}
}
fn centralized_environments_enabled(target: &ProjectEnvironmentTarget) -> bool {
target.is_default() && uv_preview::is_enabled(PreviewFeature::CentralizedEnvs)
}
pub(crate) fn ensure_centralized_environment_uses_persistent_cache(
target: &ProjectEnvironmentTarget,
cache: &Cache,
) -> Result<(), ProjectError> {
if centralized_environments_enabled(target) && cache.is_temporary() {
return Err(ProjectError::CentralizedEnvironmentNoCache);
}
Ok(())
}
/// Return whether `path` is a directory link to a centralized environment.
fn is_centralized_environment_link(path: &Path, cache: &Cache) -> bool {
let Ok(target) = fs_err::read_link(path) else {
return false;
};
let environments = cache.bucket(CacheBucket::Environments);
// 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 indirect links. The lexical check above also recognizes dangling direct
// links into the cache.
path.simple_canonicalize().is_ok_and(|target| {
environments
.simple_canonicalize()
.is_ok_and(|environments| starts_with(&target, &environments))
})
}
fn centralized_environment_root(
workspace: &Workspace,
interpreter: &Interpreter,
cache: &Cache,
) -> PathBuf {
let digest = cache_digest(&(workspace.install_path(), interpreter.key()));
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 python_version = interpreter.python_version();
let entry = name.map_or_else(
// A virtual workspace can be nameless if its directory has no cache-safe characters.
|| format!("py{python_version}-{digest}"),
|name| format!("{name}-py{python_version}-{digest}"),
);
cache
.shard(CacheBucket::Environments, entry)
.into_path_buf()
}
fn update_venv_link(
environment: &PythonEnvironment,
workspace: &Workspace,
warn_on_link_errors: bool,
) {
let link = workspace.install_path().join(".venv");
let report_error = |message: &str, err: &std::io::Error| {
if warn_on_link_errors {
warn_user_once!("{message}: {err}");
} else {
warn!("{message}: {err}");
}
};
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 project environment link at `.venv`", &err);
return;
}
}
}
// TODO(tk): Fall back to a path file when directory links are unavailable.
if let Err(err) = uv_fs::replace_symlink(environment.root(), &link) {
report_error("Failed to create project environment link at `.venv`", &err);
}
}
/// An interpreter suitable for the project.
#[derive(Debug)]
#[expect(clippy::large_enum_variant)]
@@ -1125,14 +1247,37 @@ 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_target = workspace.venv(active);
let centralized = centralized_environments_enabled(&environment_target);
// For centralized environments, `.venv` identifies the interpreter. The cache path is
// derived from the workspace and interpreter instead of trusting the link target.
if centralized {
let project_environment_path =
environment_target.project_path(workspace.install_path());
if let Ok(candidate) = PythonEnvironment::from_root(&project_environment_path, cache) {
let root = centralized_environment_root(workspace, candidate.interpreter(), 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_target
.project_path(workspace.install_path())
.as_ref(),
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
keep_incompatible,
centralized,
cache,
)? {
return Ok(Self::Environment(environment));
@@ -1155,6 +1300,21 @@ impl ProjectInterpreter {
)
.await?;
if centralized {
let root = centralized_environment_root(workspace, python.interpreter(), 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();
@@ -1195,7 +1355,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(),
}
}
}
@@ -1466,6 +1626,9 @@ pub(crate) enum ProjectEnvironment {
impl ProjectEnvironment {
/// Initialize a virtual environment for the current project.
///
/// If `warn_on_link_errors` is `true`, failures to update `.venv` are reported to the user.
/// Otherwise, they are logged at warning level.
pub(crate) async fn get_or_init(
workspace: &Workspace,
groups: &DependencyGroupsWithDefaults,
@@ -1480,7 +1643,12 @@ impl ProjectEnvironment {
cache: &Cache,
dry_run: DryRun,
printer: Printer,
warn_on_link_errors: bool,
) -> Result<Self, ProjectError> {
let environment_target = workspace.venv(active);
let centralized = centralized_environments_enabled(&environment_target);
ensure_centralized_environment_uses_persistent_cache(&environment_target, cache)?;
// Lock the project environment to avoid synchronization issues.
let _lock = lock_project_environment(workspace)
.await
@@ -1519,39 +1687,58 @@ 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_venv_link(&environment, workspace, warn_on_link_errors);
}
Ok(Self::Existing(environment))
}
// Otherwise, create a virtual environment with the discovered interpreter.
ProjectInterpreter::Interpreter(interpreter) => {
let root = workspace.venv(active);
let root: Cow<'_, Path> = match &environment_target {
ProjectEnvironmentTarget::Default if centralized => {
Cow::Owned(centralized_environment_root(workspace, &interpreter, cache))
}
_ => environment_target.project_path(workspace.install_path()),
};
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 {
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 {
// We maintain the cache, eagerly recover a mangled env.
true
} else {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root.into_owned(),
"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(),
root.into_owned(),
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:
@@ -1588,33 +1775,50 @@ impl ProjectEnvironment {
false,
upgradeable,
)?;
return Ok(if replace {
Self::WouldReplace(root, environment, temp_dir)
return Ok(if replace_environment {
Self::WouldReplace(root.into_owned(), environment, temp_dir)
} else {
Self::WouldCreate(root, environment, temp_dir)
Self::WouldCreate(root.into_owned(), 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 {
writeln!(
printer.stderr(),
"Removed virtual environment at: {}",
root.user_display().cyan()
)?;
}
}
writeln!(
printer.stderr(),
"Creating virtual environment at: {}",
root.user_display().cyan()
)?;
if centralized {
writeln!(
printer.stderr(),
"Creating virtual environment `{}` in the centralized store",
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,
@@ -1629,7 +1833,11 @@ impl ProjectEnvironment {
upgradeable,
)?;
if replace {
if centralized {
update_venv_link(&environment, workspace, warn_on_link_errors);
}
if replace_environment {
Ok(Self::Replaced(environment))
} else {
Ok(Self::Created(environment))
+12
View File
@@ -33,6 +33,7 @@ use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState,
WorkspacePython, default_dependency_groups,
ensure_centralized_environment_uses_persistent_cache,
};
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
@@ -188,6 +189,16 @@ pub(crate) async fn remove(
let content = toml.to_string();
if frozen.is_none()
&& !no_sync
&& let RemoveTarget::Project(project) = &target
{
ensure_centralized_environment_uses_persistent_cache(
&project.workspace().venv(active),
cache,
)?;
}
// Save the modified `pyproject.toml` or script.
target.write(&content)?;
@@ -266,6 +277,7 @@ pub(crate) async fn remove(
cache,
DryRun::Disabled,
printer,
true,
)
.await?
.into_environment()?;
+1
View File
@@ -713,6 +713,7 @@ pub(crate) async fn run(
&cache,
DryRun::Disabled,
printer,
false,
)
.await?
.into_environment()?
+1
View File
@@ -171,6 +171,7 @@ pub(crate) async fn sync(
cache,
dry_run,
printer,
true,
)
.await?,
),
+8 -1
View File
@@ -37,7 +37,7 @@ 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,
default_dependency_groups, ensure_centralized_environment_uses_persistent_cache,
};
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
@@ -338,6 +338,12 @@ pub(crate) async fn project_version(
let status = if dry_run {
ExitStatus::Success
} else if let Some(new_version) = &new_version {
if frozen.is_none() && !no_sync {
ensure_centralized_environment_uses_persistent_cache(
&project.workspace().venv(active),
cache,
)?;
}
let project = update_project(
project,
new_version,
@@ -653,6 +659,7 @@ async fn lock_and_sync(
cache,
DryRun::Disabled,
printer,
true,
)
.await?
.into_environment()?;
+6 -1
View File
@@ -134,7 +134,12 @@ pub(crate) async fn venv(
let path = path.unwrap_or_else(|| {
project_environment.map_or_else(
|| PathBuf::from(".venv"),
|workspace| workspace.venv(Some(false)),
|workspace| {
workspace
.venv(Some(false))
.project_path(workspace.install_path())
.into_owned()
},
)
});
@@ -22,6 +22,7 @@ use crate::commands::project::lock::{LockMode, LockOperation};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython,
ensure_centralized_environment_uses_persistent_cache,
};
use crate::commands::{ExitStatus, diagnostics};
use crate::printer::Printer;
@@ -65,6 +66,14 @@ pub(crate) async fn metadata(
workspace_cache,
)
.await?;
if sync {
ensure_centralized_environment_uses_persistent_cache(
&virtual_project.workspace().venv(Some(false)),
cache,
)?;
}
let target = LockTarget::Workspace(virtual_project.workspace());
// Don't enable any groups' requires-python for interpreter discovery.
@@ -148,6 +157,7 @@ pub(crate) async fn metadata(
cache,
DryRun::Disabled,
printer,
true,
)
.await?;
let module_owners = collect_module_owners(
+56
View File
@@ -169,6 +169,62 @@ requires-python = ">=3.12"
Ok(())
}
#[test]
fn version_centralized_environment_no_cache_does_not_modify_project() -> Result<()> {
let context = uv_test::test_context!("3.12");
fs_err::remove_dir_all(&context.venv)?;
let pyproject = indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#};
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(pyproject)?;
uv_snapshot!(context.filters(), context.version()
.arg("0.2.0")
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-envs"), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `--no-cache` is unsupported with the `centralized-envs` feature enabled
"#);
assert_eq!(fs_err::read_to_string(&pyproject_toml)?, pyproject);
uv_snapshot!(context.filters(), context.version()
.arg("0.2.0")
.arg("--no-sync")
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-envs"), @"
success: true
exit_code: 0
----- stdout -----
project 0.1.0 => 0.2.0
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 1 package in [TIME]
");
assert!(!context.venv.exists());
assert_snapshot!(fs_err::read_to_string(pyproject_toml)?, @r#"
[project]
name = "project"
version = "0.2.0"
requires-python = ">=3.12"
dependencies = []
"#);
Ok(())
}
// Set the version (--short)
#[test]
fn version_set_value_short() -> Result<()> {
+55
View File
@@ -146,6 +146,61 @@ fn add_registry() -> Result<()> {
Ok(())
}
#[test]
fn remove_centralized_environment_no_cache_does_not_modify_project() -> Result<()> {
let context = uv_test::test_context!("3.12");
fs_err::remove_dir_all(&context.venv)?;
let pyproject = indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#};
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(pyproject)?;
uv_snapshot!(context.filters(), context.remove()
.arg("iniconfig")
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-envs"), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `--no-cache` is unsupported with the `centralized-envs` feature enabled
"#);
assert_eq!(fs_err::read_to_string(pyproject_toml)?, pyproject);
uv_snapshot!(context.filters(), context.remove()
.arg("iniconfig")
.arg("--no-sync")
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-envs"), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 1 package in [TIME]
");
assert!(!context.venv.exists());
assert_snapshot!(context.read("pyproject.toml"), @r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#);
Ok(())
}
/// Add a Git requirement.
#[test]
#[cfg(feature = "test-git")]
+75
View File
@@ -7088,3 +7088,78 @@ 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_cache_is_rejected() -> Result<()> {
let context = uv_test::test_context!("3.12");
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.run()
.arg("--no-cache")
.arg("--preview-features")
.arg("centralized-envs")
.arg("python")
.arg("-c")
.arg("pass"), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `--no-cache` is unsupported with the `centralized-envs` feature enabled
"#);
Ok(())
}
#[test]
fn run_centralized_environment_no_sync_uses_incompatible_python() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]);
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-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-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-py3.12.[X]-[HASH]`) due to `--no-sync` (The project environment's Python version does not satisfy the request: `Python 3.11`)
"#);
Ok(())
}
+33
View File
@@ -8,6 +8,39 @@ 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-envs"), @"
success: true
exit_code: 0
----- stdout -----
project v0.1.0
----- stderr -----
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");
+653
View File
@@ -0,0 +1,653 @@
use anyhow::Result;
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use insta::assert_snapshot;
use serde_json::json;
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"]);
write_project(&context, ">=3.12", &["iniconfig"])?;
// Creates the environment in the centralized store.
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment `project-py3.12.[X]-[HASH]` in the centralized store
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-py3.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-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Would use project environment at: [CACHE_DIR]/environments-v2/project-py3.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-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-py3.12.[X]-[HASH]");
});
let reuse_marker = context.temp_dir.child(".venv").child("reuse-marker");
reuse_marker.touch()?;
context
.sync()
.arg("--preview-features")
.arg("centralized-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-py3.11.[X]-[HASH]");
});
context
.sync()
.arg("--preview-features")
.arg("centralized-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
// The original environment is reused, not recreated.
assert!(reuse_marker.exists());
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-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-py3.12.9-[HASH]");
});
context
.sync()
.arg("--preview-features")
.arg("centralized-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-py3.12.11-[HASH]");
});
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-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-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-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();
environment.child("user-marker").touch()?;
context
.sync()
.arg("--preview-features")
.arg("centralized-envs")
.arg("--active")
.env(EnvVars::VIRTUAL_ENV, environment.path())
.assert()
.success();
assert!(environment.child("user-marker").is_file());
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-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-py3.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 environment without creating it.
uv_snapshot!(context.filters(), context.sync()
.arg("--dry-run")
.arg("--preview-features")
.arg("centralized-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-py3.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 leaves the filesystem unchanged.
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", &[])?;
context
.sync()
.arg("--preview-features")
.arg("centralized-envs")
.assert()
.success();
let link = context.temp_dir.child(".venv");
let target = fs_err::read_link(link.path())?;
context.prune().assert().success();
assert!(!target.exists());
assert_eq!(target, fs_err::read_link(link.path())?);
context
.sync()
.arg("--preview-features")
.arg("centralized-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-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-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_replaces_existing_local_environment() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]);
write_project(&context, ">=3.12", &[])?;
context.sync().assert().success();
context
.sync()
.arg("--preview-features")
.arg("centralized-envs")
.assert()
.success();
let target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
// The local environment is replaced by a link into the cache.
insta::with_settings!({ filters => context.filters() }, {
assert_snapshot!(target.portable_display(), @"[CACHE_DIR]/environments-v2/project-py3.12.[X]-[HASH]");
});
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-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-py3.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-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-py3.12.[X]-[HASH]");
});
}
#[cfg(windows)]
{
// TODO(tk): This behavior will change once path files are added.
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-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-py3.12.[X]-[HASH]");
});
Ok(())
}
#[test]
fn run_and_sync_link_failure_reporting() -> Result<()> {
let context = uv_test::test_context_with_versions!(&["3.12"]).with_filter((
r"(?m)^warning: Failed to create project environment link at `.venv`: .*$",
"warning: Failed to create project environment link at `.venv`: [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()
.arg("--preview-features")
.arg("centralized-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-py3.12.[X]-[HASH]` in the centralized store
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()
.arg("--preview-features")
.arg("centralized-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Failed to create project environment link 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_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-envs")
.assert()
.success();
let environment = context.temp_dir.child(".venv");
let target = fs_err::read_link(environment.path())?;
let marker = target.join("marker");
fs_err::write(&marker, "")?;
let _guard = ReadOnlyDirectoryGuard::new(context.temp_dir.path())?;
uv_snapshot!(context.filters(), context.sync()
.arg("--preview-features")
.arg("centralized-envs"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Failed to create project environment link 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!(marker.is_file());
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-envs")
.arg("--python")
.arg("3.12")
.assert()
.success();
let cache_target = fs_err::read_link(context.temp_dir.child(".venv").path())?;
let cache_marker = cache_target.join("marker");
fs_err::write(&cache_marker, "")?;
let override_environment = context.temp_dir.child("override");
uv_fs::create_symlink(&cache_target, override_environment.path())?;
context
.sync()
.arg("--preview-features")
.arg("centralized-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());
assert!(cache_marker.is_file());
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())?;
context
.sync()
.arg("--python")
.arg("3.11")
.assert()
.success();
// Without the preview, the indirect cache link is replaced by a local environment.
assert!(environment.is_dir());
assert!(fs_err::read_link(environment.path()).is_err());
// The cached environment remains available for later reuse.
assert!(cache_marker.is_file());
// Replacing an incompatible user-managed `.venv` link rebuilds its target in place.
let target = context.temp_dir.child("environment");
fs_err::rename(environment.path(), target.path())?;
uv_fs::create_symlink(target.path(), environment.path())?;
let marker = target.child("marker");
marker.touch()?;
context
.sync()
.arg("--python")
.arg("3.12")
.assert()
.success();
assert!(fs_err::read_link(environment.path()).is_ok());
// The link remains, but its target is a fresh Python 3.12 environment.
assert!(!marker.exists());
assert!(target.child("pyvenv.cfg").is_file());
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_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,
+ CentralizedEnvs,
+ ],
},
python_preference: Managed,
@@ -102,6 +102,59 @@ 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 = []
"#,
)?;
// Rejects `--no-cache` without modifying the project.
uv_snapshot!(context.filters(), context.workspace_metadata()
.arg("--sync")
.arg("--no-cache")
.arg("--preview-features")
.arg("workspace-metadata,centralized-envs"), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `--no-cache` is unsupported with the `centralized-envs` feature enabled
"#);
assert!(!context.temp_dir.child("uv.lock").exists());
assert!(!context.temp_dir.child(".venv").exists());
// A subsequent sync creates and reports the centralized environment.
let assert = context
.workspace_metadata()
.arg("--sync")
.arg("--preview-features")
.arg("workspace-metadata,centralized-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");
+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-envs`: Stores
[project virtual environments](../reference/storage.md#project-virtual-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.
+11
View File
@@ -194,6 +194,17 @@ 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-envs` preview feature](../concepts/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 can reuse their respective cached environments.
Explicit project environment paths, including `UV_PROJECT_ENVIRONMENT` and environments selected
with `--active`, are not centralized. Centralized environments are not supported with `--no-cache`.
Like other cache data, they can be removed by `uv cache clean` or `uv cache prune` and are recreated
when next needed.
### 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-envs"
]
},
{