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:
Charlie Marsh 2024-07-01 08:31:42 -04:00 committed by GitHub
parent 2d57309b0f
commit 1557ad1b3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 181 additions and 164 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(),
));

View file

@ -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
"###);

View file

@ -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(())
}