Preserve project environment selection provenance

This commit is contained in:
Tomasz (Tom) Kramkowski
2026-06-04 20:49:50 +01:00
parent d7b3d315b2
commit a63b00f8f3
3 changed files with 157 additions and 99 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
pub use workspace::{
DiscoveryOptions, Editability, MemberDiscovery, ProjectWorkspace, RequiresPythonSources,
VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, WorkspaceMember,
DiscoveryOptions, Editability, MemberDiscovery, ProjectEnvironmentSelection, ProjectWorkspace,
RequiresPythonSources, VirtualProject, Workspace, WorkspaceCache, WorkspaceError,
WorkspaceErrorKind, WorkspaceMember,
};
pub mod dependency_groups;
+59 -20
View File
@@ -30,6 +30,32 @@ use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
};
/// The workspace project environment selected by configuration and command-line options.
#[derive(Debug)]
pub enum ProjectEnvironmentSelection {
/// Use the workspace's default project environment.
Default,
/// A path selected by `UV_PROJECT_ENVIRONMENT`.
Override(PathBuf),
/// The active virtual environment selected by `VIRTUAL_ENV` and `--active`.
Active(PathBuf),
}
impl ProjectEnvironmentSelection {
/// Returns `true` if the workspace's default project environment was selected.
pub fn is_default(&self) -> bool {
matches!(self, Self::Default)
}
/// Returns the explicitly selected environment path, if any.
pub fn explicit_path(&self) -> Option<&Path> {
match self {
Self::Default => None,
Self::Override(path) | Self::Active(path) => Some(path),
}
}
}
type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
@@ -821,7 +847,7 @@ impl Workspace {
&self.install_path
}
/// The path to the workspace virtual environment.
/// The workspace project environment selection.
///
/// Uses `.venv` in the install path directory by default.
///
@@ -831,7 +857,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(&self, active: Option<bool>) -> PathBuf {
pub fn environment_selection(&self, active: Option<bool>) -> ProjectEnvironmentSelection {
/// 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)?;
@@ -867,32 +893,38 @@ impl Workspace {
Some(CWD.join(path))
}
// Determine the default value
let project_env = from_project_environment_variable(self)
.unwrap_or_else(|| self.install_path.join(".venv"));
let selection = from_project_environment_variable(self)
.map(ProjectEnvironmentSelection::Override)
.unwrap_or(ProjectEnvironmentSelection::Default);
let project_environment_path = selection
.explicit_path()
.map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf);
// Warn if it conflicts with `VIRTUAL_ENV`
if let Some(from_virtual_env) = from_virtual_env_variable() {
if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
{
match active {
Some(true) => {
let matches_project =
uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_environment_path)
.unwrap_or(false);
match active {
Some(true) => {
if !matches_project {
debug!(
"Using active virtual environment `{}` instead of project environment `{}`",
from_virtual_env.user_display(),
project_env.user_display()
);
return from_virtual_env;
}
Some(false) => {}
None => {
warn_user_once!(
"`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
from_virtual_env.user_display(),
project_env.user_display()
project_environment_path.user_display()
);
}
return ProjectEnvironmentSelection::Active(from_virtual_env);
}
Some(false) => {}
None if !matches_project => {
warn_user_once!(
"`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
from_virtual_env.user_display(),
project_environment_path.user_display()
);
}
None => {}
}
} else {
if active.unwrap_or_default() {
@@ -902,7 +934,14 @@ impl Workspace {
}
}
project_env
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.
+95 -77
View File
@@ -694,7 +694,7 @@ impl ScriptInterpreter {
///
/// If `--active` is set, the active virtual environment will be preferred.
///
/// See: [`Workspace::venv`].
/// See: [`Workspace::environment_selection`].
fn root(script: Pep723ItemRef<'_>, active: Option<bool>, cache: &Cache) -> PathBuf {
/// Resolve the `VIRTUAL_ENV` variable, if any.
fn from_virtual_env_variable() -> Option<PathBuf> {
@@ -801,7 +801,7 @@ impl ScriptInterpreter {
let root = Self::root(script, active, cache);
match PythonEnvironment::from_root(&root, cache) {
Ok(venv) => {
match environment_is_usable(
match check_environment_compatibility(
&venv,
EnvironmentKind::Script,
python_request.as_ref(),
@@ -938,7 +938,7 @@ enum EnvironmentIncompatibilityError {
}
/// Whether an environment is usable for a project or script, i.e., if it matches the requirements.
fn environment_is_usable(
fn check_environment_compatibility(
environment: &PythonEnvironment,
kind: EnvironmentKind,
python_request: Option<&PythonRequest>,
@@ -996,6 +996,89 @@ fn environment_is_usable(
Ok(())
}
fn discover_project_environment(
root: &Path,
python_request: Option<&PythonRequest>,
python_preference: PythonPreference,
requires_python: Option<&RequiresPython>,
keep_incompatible: bool,
cache: &Cache,
) -> Result<Option<PythonEnvironment>, ProjectError> {
let environment = match PythonEnvironment::from_root(root, cache) {
Ok(environment) => environment,
Err(uv_python::Error::MissingEnvironment(_)) => return Ok(None),
Err(uv_python::Error::InvalidEnvironment(inner)) => {
match inner.kind {
InvalidEnvironmentKind::NotDirectory => {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root.to_path_buf(),
inner.kind.to_string(),
));
}
InvalidEnvironmentKind::MissingExecutable(_) => {
if 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(),
"it is not a valid Python environment (no Python executable was found)"
.to_string(),
));
}
}
}
InvalidEnvironmentKind::Empty => {}
}
return Ok(None);
}
Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(_))) => {
return Ok(None);
}
Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenLink(BrokenLink {
path,
unix,
venv: _,
}))) => {
if unix {
let target_path = fs_err::read_link(&path)?;
warn_user!(
"Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
path.user_display().cyan(),
target_path.user_display().cyan(),
);
} else {
warn_user!(
"Ignoring existing virtual environment linked to non-existent Python interpreter: {}",
path.user_display().cyan(),
);
}
return Ok(None);
}
Err(err) => return Err(err.into()),
};
match check_environment_compatibility(
&environment,
EnvironmentKind::Project,
python_request,
python_preference,
requires_python,
cache,
) {
Ok(()) => Ok(Some(environment)),
Err(err) if keep_incompatible => {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
environment.root().user_display().cyan(),
);
Ok(Some(environment))
}
Err(err) => {
debug!("{err}");
Ok(None)
}
}
}
/// An interpreter suitable for the project.
#[derive(Debug)]
#[expect(clippy::large_enum_variant)]
@@ -1029,80 +1112,15 @@ impl ProjectInterpreter {
// Read from the virtual environment first.
let root = workspace.venv(active);
match PythonEnvironment::from_root(&root, cache) {
Ok(venv) => {
match environment_is_usable(
&venv,
EnvironmentKind::Project,
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
cache,
) {
Ok(()) => return Ok(Self::Environment(venv)),
Err(err) if keep_incompatible => {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
root.user_display().cyan(),
);
return Ok(Self::Environment(venv));
}
Err(err) => {
debug!("{err}");
}
}
}
Err(uv_python::Error::MissingEnvironment(_)) => {}
Err(uv_python::Error::InvalidEnvironment(inner)) => {
// If there's an invalid environment with existing content, we error instead of
// deleting it later on
match inner.kind {
InvalidEnvironmentKind::NotDirectory => {
return Err(ProjectError::InvalidProjectEnvironmentDir(
root,
inner.kind.to_string(),
));
}
InvalidEnvironmentKind::MissingExecutable(_) => {
// If it's not an empty directory
if fs_err::read_dir(&root).is_ok_and(|mut dir| dir.next().is_some()) {
// ... and there's no `pyvenv.cfg`
if !root.join("pyvenv.cfg").try_exists().unwrap_or_default() {
// ... then it's not a valid Python environment
return Err(ProjectError::InvalidProjectEnvironmentDir(
root,
"it is not a valid Python environment (no Python executable was found)"
.to_string(),
));
}
}
// Otherwise, we'll delete it
}
// If the environment is an empty directory, it's fine to use
InvalidEnvironmentKind::Empty => {}
}
}
Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(_))) => {}
Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenLink(BrokenLink {
path,
unix,
venv: _,
}))) => {
if unix {
let target_path = fs_err::read_link(&path)?;
warn_user!(
"Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
path.user_display().cyan(),
target_path.user_display().cyan(),
);
} else {
warn_user!(
"Ignoring existing virtual environment linked to non-existent Python interpreter: {}",
path.user_display().cyan(),
);
}
}
Err(err) => return Err(err.into()),
if let Some(environment) = discover_project_environment(
&root,
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
keep_incompatible,
cache,
)? {
return Ok(Self::Environment(environment));
}
let reporter = PythonDownloadReporter::single(printer);