mirror of
https://github.com/astral-sh/uv
synced 2026-06-21 13:47:25 +00:00
Preserve project environment selection provenance
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user