mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
DRY up some project interpreter validation and discovery (#4658)
## Summary I noticed that `init_environment` and `find_interpreter` were both calling `find_environment`, which seemed like a code smell to me. Instead, `find_interpreter` now returns either a compatible environment or an interpreter (if no compatible environment was found). Additionally, `interpreter_meets_requirements` now no longer validates `requires-python` if `--python` or `.python-version` is set. Instead, we warn, which matches the behavior we get when creating a new environment at the bottom of `find_interpreter`. In total, I think this makes the data flow in project interpreter discovery less repetitive and easier to reason about.
This commit is contained in:
parent
2d57309b0f
commit
1557ad1b3c
8 changed files with 181 additions and 164 deletions
|
@ -60,7 +60,7 @@ pub(crate) async fn add(
|
|||
};
|
||||
|
||||
// Discover or create the virtual environment.
|
||||
let venv = project::init_environment(
|
||||
let venv = project::get_or_init_environment(
|
||||
project.workspace(),
|
||||
python.as_deref().map(ToolchainRequest::parse),
|
||||
toolchain_preference,
|
||||
|
|
|
@ -17,8 +17,8 @@ use uv_toolchain::{Interpreter, ToolchainPreference, ToolchainRequest};
|
|||
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight};
|
||||
use uv_warnings::{warn_user, warn_user_once};
|
||||
|
||||
use crate::commands::project::{find_requires_python, ProjectError};
|
||||
use crate::commands::{pip, project, ExitStatus};
|
||||
use crate::commands::project::{find_requires_python, FoundInterpreter, ProjectError};
|
||||
use crate::commands::{pip, ExitStatus};
|
||||
use crate::printer::Printer;
|
||||
use crate::settings::{ResolverSettings, ResolverSettingsRef};
|
||||
|
||||
|
@ -42,7 +42,7 @@ pub(crate) async fn lock(
|
|||
let workspace = Workspace::discover(&std::env::current_dir()?, None).await?;
|
||||
|
||||
// Find an interpreter for the project
|
||||
let interpreter = project::find_interpreter(
|
||||
let interpreter = FoundInterpreter::discover(
|
||||
&workspace,
|
||||
python.as_deref().map(ToolchainRequest::parse),
|
||||
toolchain_preference,
|
||||
|
@ -51,7 +51,8 @@ pub(crate) async fn lock(
|
|||
cache,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.into_interpreter();
|
||||
|
||||
// Perform the lock operation.
|
||||
match do_lock(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use itertools::Itertools;
|
||||
use owo_colors::OwoColorize;
|
||||
use tracing::debug;
|
||||
|
@ -22,7 +21,6 @@ use uv_toolchain::{
|
|||
ToolchainPreference, ToolchainRequest, VersionRequest,
|
||||
};
|
||||
use uv_types::{BuildIsolation, HashStrategy, InFlight};
|
||||
use uv_warnings::warn_user;
|
||||
|
||||
use crate::commands::pip;
|
||||
use crate::printer::Printer;
|
||||
|
@ -36,11 +34,14 @@ pub(crate) mod sync;
|
|||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum ProjectError {
|
||||
#[error("The current Python version ({0}) is not compatible with the locked Python requirement ({1})")]
|
||||
PythonIncompatibility(Version, RequiresPython),
|
||||
#[error("The current Python version ({0}) is not compatible with the locked Python requirement: `{1}`")]
|
||||
LockedPythonIncompatibility(Version, RequiresPython),
|
||||
|
||||
#[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`")]
|
||||
RequestedPythonIncompatibility(Version, RequiresPython),
|
||||
|
||||
#[error(transparent)]
|
||||
Interpreter(#[from] uv_toolchain::Error),
|
||||
Toolchain(#[from] uv_toolchain::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Virtualenv(#[from] uv_virtualenv::Error),
|
||||
|
@ -60,9 +61,6 @@ pub(crate) enum ProjectError {
|
|||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Serialize(#[from] toml::ser::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
|
||||
|
@ -90,7 +88,7 @@ pub(crate) fn find_requires_python(
|
|||
}
|
||||
|
||||
/// Find the virtual environment for the current project.
|
||||
pub(crate) fn find_environment(
|
||||
fn find_environment(
|
||||
workspace: &Workspace,
|
||||
cache: &Cache,
|
||||
) -> Result<PythonEnvironment, uv_toolchain::Error> {
|
||||
|
@ -98,122 +96,127 @@ pub(crate) fn find_environment(
|
|||
}
|
||||
|
||||
/// Check if the given interpreter satisfies the project's requirements.
|
||||
pub(crate) fn interpreter_meets_requirements(
|
||||
fn interpreter_meets_requirements(
|
||||
interpreter: &Interpreter,
|
||||
requested_python: Option<&ToolchainRequest>,
|
||||
requires_python: Option<&RequiresPython>,
|
||||
cache: &Cache,
|
||||
) -> bool {
|
||||
// `--python` has highest precedence, after that we check `requires_python` from
|
||||
// `pyproject.toml`. If `--python` and `requires_python` are mutually incompatible,
|
||||
// we'll fail at the build or at last the install step when we aren't able to install
|
||||
// the editable wheel for the current project into the venv.
|
||||
// TODO(konsti): Do we want to support a workspace python version requirement?
|
||||
if let Some(request) = requested_python {
|
||||
if request.satisfied(interpreter, cache) {
|
||||
debug!("Interpreter meets the requested python {}", request);
|
||||
return true;
|
||||
}
|
||||
|
||||
debug!("Interpreter does not meet the request {}", request);
|
||||
return false;
|
||||
let Some(request) = requested_python else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if let Some(requires_python) = requires_python {
|
||||
if requires_python.contains(interpreter.python_version()) {
|
||||
debug!("Interpreter meets the project `Requires-Python` constraint {requires_python}");
|
||||
return true;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Interpreter does not meet the project `Requires-Python` constraint {requires_python}"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
// No requirement to check
|
||||
true
|
||||
if request.satisfied(interpreter, cache) {
|
||||
debug!("Interpreter meets the requested Python: `{request}`");
|
||||
true
|
||||
} else {
|
||||
debug!("Interpreter does not meet the request: `{request}`");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the interpreter to use in the current project.
|
||||
pub(crate) async fn find_interpreter(
|
||||
workspace: &Workspace,
|
||||
python_request: Option<ToolchainRequest>,
|
||||
toolchain_preference: ToolchainPreference,
|
||||
connectivity: Connectivity,
|
||||
native_tls: bool,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<Interpreter, ProjectError> {
|
||||
let requires_python = find_requires_python(workspace)?;
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum FoundInterpreter {
|
||||
Interpreter(Interpreter),
|
||||
Environment(PythonEnvironment),
|
||||
}
|
||||
|
||||
// (1) Explicit request from user
|
||||
let python_request = if let Some(request) = python_request {
|
||||
Some(request)
|
||||
// (2) Request from `.python-version`
|
||||
} else if let Some(request) = request_from_version_file().await? {
|
||||
Some(request)
|
||||
// (3) `Requires-Python` in `pyproject.toml`
|
||||
} else {
|
||||
requires_python
|
||||
.as_ref()
|
||||
.map(RequiresPython::specifiers)
|
||||
.map(|specifiers| ToolchainRequest::Version(VersionRequest::Range(specifiers.clone())))
|
||||
};
|
||||
impl FoundInterpreter {
|
||||
/// Discover the interpreter to use in the current [`Workspace`].
|
||||
pub(crate) async fn discover(
|
||||
workspace: &Workspace,
|
||||
python_request: Option<ToolchainRequest>,
|
||||
toolchain_preference: ToolchainPreference,
|
||||
connectivity: Connectivity,
|
||||
native_tls: bool,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<Self, ProjectError> {
|
||||
let requires_python = find_requires_python(workspace)?;
|
||||
|
||||
// Read from the virtual environment first
|
||||
match find_environment(workspace, cache) {
|
||||
Ok(venv) => {
|
||||
if interpreter_meets_requirements(
|
||||
venv.interpreter(),
|
||||
python_request.as_ref(),
|
||||
requires_python.as_ref(),
|
||||
cache,
|
||||
) {
|
||||
return Ok(venv.into_interpreter());
|
||||
// (1) Explicit request from user
|
||||
let python_request = if let Some(request) = python_request {
|
||||
Some(request)
|
||||
// (2) Request from `.python-version`
|
||||
} else if let Some(request) = request_from_version_file().await? {
|
||||
Some(request)
|
||||
// (3) `Requires-Python` in `pyproject.toml`
|
||||
} else {
|
||||
requires_python
|
||||
.as_ref()
|
||||
.map(RequiresPython::specifiers)
|
||||
.map(|specifiers| {
|
||||
ToolchainRequest::Version(VersionRequest::Range(specifiers.clone()))
|
||||
})
|
||||
};
|
||||
|
||||
// Read from the virtual environment first.
|
||||
match find_environment(workspace, cache) {
|
||||
Ok(venv) => {
|
||||
if interpreter_meets_requirements(
|
||||
venv.interpreter(),
|
||||
python_request.as_ref(),
|
||||
cache,
|
||||
) {
|
||||
if let Some(requires_python) = requires_python.as_ref() {
|
||||
if requires_python.contains(venv.interpreter().python_version()) {
|
||||
return Ok(Self::Environment(venv));
|
||||
}
|
||||
debug!(
|
||||
"Interpreter does not meet the project's Python requirement: `{requires_python}`"
|
||||
);
|
||||
} else {
|
||||
return Ok(Self::Environment(venv));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(uv_toolchain::Error::MissingEnvironment(_)) => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
let client_builder = BaseClientBuilder::default()
|
||||
.connectivity(connectivity)
|
||||
.native_tls(native_tls);
|
||||
|
||||
// Locate the Python interpreter to use in the environment
|
||||
let interpreter = Toolchain::find_or_fetch(
|
||||
python_request,
|
||||
EnvironmentPreference::OnlySystem,
|
||||
toolchain_preference,
|
||||
client_builder,
|
||||
cache,
|
||||
)
|
||||
.await?
|
||||
.into_interpreter();
|
||||
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Using Python {} interpreter at: {}",
|
||||
interpreter.python_version(),
|
||||
interpreter.sys_executable().user_display().cyan()
|
||||
)?;
|
||||
|
||||
if let Some(requires_python) = requires_python.as_ref() {
|
||||
if !requires_python.contains(interpreter.python_version()) {
|
||||
return Err(ProjectError::RequestedPythonIncompatibility(
|
||||
interpreter.python_version().clone(),
|
||||
requires_python.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(uv_toolchain::Error::MissingEnvironment(_)) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let client_builder = BaseClientBuilder::default()
|
||||
.connectivity(connectivity)
|
||||
.native_tls(native_tls);
|
||||
|
||||
// Locate the Python interpreter to use in the environment
|
||||
let interpreter = Toolchain::find_or_fetch(
|
||||
python_request,
|
||||
EnvironmentPreference::OnlySystem,
|
||||
toolchain_preference,
|
||||
client_builder,
|
||||
cache,
|
||||
)
|
||||
.await?
|
||||
.into_interpreter();
|
||||
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Using Python {} interpreter at: {}",
|
||||
interpreter.python_version(),
|
||||
interpreter.sys_executable().user_display().cyan()
|
||||
)?;
|
||||
|
||||
if let Some(requires_python) = requires_python.as_ref() {
|
||||
if !requires_python.contains(interpreter.python_version()) {
|
||||
warn_user!(
|
||||
"The Python interpreter ({}) is incompatible with the project Python requirement {}",
|
||||
interpreter.python_version(),
|
||||
requires_python
|
||||
);
|
||||
}
|
||||
Ok(Self::Interpreter(interpreter))
|
||||
}
|
||||
|
||||
Ok(interpreter)
|
||||
/// Convert the [`FoundInterpreter`] into an [`Interpreter`].
|
||||
pub(crate) fn into_interpreter(self) -> Interpreter {
|
||||
match self {
|
||||
FoundInterpreter::Interpreter(interpreter) => interpreter,
|
||||
FoundInterpreter::Environment(venv) => venv.into_interpreter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a virtual environment for the current project.
|
||||
pub(crate) async fn init_environment(
|
||||
pub(crate) async fn get_or_init_environment(
|
||||
workspace: &Workspace,
|
||||
python: Option<ToolchainRequest>,
|
||||
toolchain_preference: ToolchainPreference,
|
||||
|
@ -222,35 +225,7 @@ pub(crate) async fn init_environment(
|
|||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<PythonEnvironment, ProjectError> {
|
||||
let requires_python = find_requires_python(workspace)?;
|
||||
|
||||
// Check if the environment exists and is sufficient
|
||||
match find_environment(workspace, cache) {
|
||||
Ok(venv) => {
|
||||
if interpreter_meets_requirements(
|
||||
venv.interpreter(),
|
||||
python.as_ref(),
|
||||
requires_python.as_ref(),
|
||||
cache,
|
||||
) {
|
||||
return Ok(venv);
|
||||
}
|
||||
|
||||
// Remove the existing virtual environment if it doesn't meet the requirements
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Removing virtual environment at: {}",
|
||||
venv.root().user_display().cyan()
|
||||
)?;
|
||||
fs_err::remove_dir_all(venv.root())
|
||||
.context("Failed to remove existing virtual environment")?;
|
||||
}
|
||||
Err(uv_toolchain::Error::MissingEnvironment(_)) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Find an interpreter to create the environment with
|
||||
let interpreter = find_interpreter(
|
||||
match FoundInterpreter::discover(
|
||||
workspace,
|
||||
python,
|
||||
toolchain_preference,
|
||||
|
@ -259,22 +234,43 @@ pub(crate) async fn init_environment(
|
|||
cache,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
{
|
||||
// If we found an existing, compatible environment, use it.
|
||||
FoundInterpreter::Environment(environment) => Ok(environment),
|
||||
|
||||
let venv = workspace.venv();
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Creating virtualenv at: {}",
|
||||
venv.user_display().cyan()
|
||||
)?;
|
||||
// Otherwise, create a virtual environment with the discovered interpreter.
|
||||
FoundInterpreter::Interpreter(interpreter) => {
|
||||
let venv = workspace.venv();
|
||||
|
||||
Ok(uv_virtualenv::create_venv(
|
||||
&venv,
|
||||
interpreter,
|
||||
uv_virtualenv::Prompt::None,
|
||||
false,
|
||||
false,
|
||||
)?)
|
||||
// Remove the existing virtual environment if it doesn't meet the requirements.
|
||||
match fs_err::remove_dir_all(&venv) {
|
||||
Ok(()) => {
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Removed virtual environment at: {}",
|
||||
venv.user_display().cyan()
|
||||
)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Creating virtualenv at: {}",
|
||||
venv.user_display().cyan()
|
||||
)?;
|
||||
|
||||
Ok(uv_virtualenv::create_venv(
|
||||
&venv,
|
||||
interpreter,
|
||||
uv_virtualenv::Prompt::None,
|
||||
false,
|
||||
false,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s.
|
||||
|
@ -288,7 +284,7 @@ pub(crate) async fn update_environment(
|
|||
native_tls: bool,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<PythonEnvironment> {
|
||||
) -> anyhow::Result<PythonEnvironment> {
|
||||
// Extract the project settings.
|
||||
let ResolverInstallerSettings {
|
||||
index_locations,
|
||||
|
|
|
@ -81,7 +81,7 @@ pub(crate) async fn remove(
|
|||
)?;
|
||||
|
||||
// Discover or create the virtual environment.
|
||||
let venv = project::init_environment(
|
||||
let venv = project::get_or_init_environment(
|
||||
project.workspace(),
|
||||
python.as_deref().map(ToolchainRequest::parse),
|
||||
toolchain_preference,
|
||||
|
|
|
@ -160,7 +160,7 @@ pub(crate) async fn run(
|
|||
);
|
||||
}
|
||||
|
||||
let venv = project::init_environment(
|
||||
let venv = project::get_or_init_environment(
|
||||
project.workspace(),
|
||||
python.as_deref().map(ToolchainRequest::parse),
|
||||
toolchain_preference,
|
||||
|
|
|
@ -41,7 +41,7 @@ pub(crate) async fn sync(
|
|||
let project = VirtualProject::discover(&std::env::current_dir()?, None).await?;
|
||||
|
||||
// Discover or create the virtual environment.
|
||||
let venv = project::init_environment(
|
||||
let venv = project::get_or_init_environment(
|
||||
project.workspace(),
|
||||
python.as_deref().map(ToolchainRequest::parse),
|
||||
toolchain_preference,
|
||||
|
@ -111,7 +111,7 @@ pub(super) async fn do_sync(
|
|||
// Validate that the Python version is supported by the lockfile.
|
||||
if let Some(requires_python) = lock.requires_python() {
|
||||
if !requires_python.contains(venv.interpreter().python_version()) {
|
||||
return Err(ProjectError::PythonIncompatibility(
|
||||
return Err(ProjectError::LockedPythonIncompatibility(
|
||||
venv.interpreter().python_version().clone(),
|
||||
requires_python.clone(),
|
||||
));
|
||||
|
|
|
@ -2047,7 +2047,6 @@ fn lock_requires_python() -> Result<()> {
|
|||
|
||||
----- stderr -----
|
||||
warning: `uv sync` is experimental and may change without warning.
|
||||
Removing virtual environment at: .venv
|
||||
error: No interpreter found for Python >=3.12 in system path
|
||||
"###);
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ mod common;
|
|||
/// Run with different python versions, which also depend on different dependency versions.
|
||||
#[test]
|
||||
fn run_with_python_version() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&["3.12", "3.11"]);
|
||||
let context = TestContext::new_with_versions(&["3.12", "3.11", "3.8"]);
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
|
@ -102,8 +102,8 @@ fn run_with_python_version() -> Result<()> {
|
|||
3.6.0
|
||||
|
||||
----- stderr -----
|
||||
Removing virtual environment at: .venv
|
||||
Using Python 3.11.[X] interpreter at: [PYTHON-3.11]
|
||||
Removed virtual environment at: .venv
|
||||
Creating virtualenv at: .venv
|
||||
Resolved 5 packages in [TIME]
|
||||
Prepared 4 packages in [TIME]
|
||||
|
@ -114,6 +114,27 @@ fn run_with_python_version() -> Result<()> {
|
|||
+ sniffio==1.3.1
|
||||
"###);
|
||||
|
||||
// This time, we target Python 3.8 instead.
|
||||
let mut command = context.run();
|
||||
let command_with_args = command
|
||||
.arg("--preview")
|
||||
.arg("-p")
|
||||
.arg("3.8")
|
||||
.arg("python")
|
||||
.arg("-B")
|
||||
.arg("main.py")
|
||||
.env_remove("VIRTUAL_ENV");
|
||||
|
||||
uv_snapshot!(context.filters(), command_with_args, @r###"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Using Python 3.8.[X] interpreter at: [PYTHON-3.8]
|
||||
error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.11, <4`
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue