mirror of
https://github.com/astral-sh/uv
synced 2026-06-21 13:47:25 +00:00
Add centralised environments in preview
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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])
|
||||
");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-1
@@ -1673,7 +1673,8 @@
|
||||
"malware-check",
|
||||
"venv-safe-clear",
|
||||
"check-command",
|
||||
"packaged-init"
|
||||
"packaged-init",
|
||||
"centralized-project-envs"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user