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. // Discover or create the virtual environment.
let venv = project::init_environment( let venv = project::get_or_init_environment(
project.workspace(), project.workspace(),
python.as_deref().map(ToolchainRequest::parse), python.as_deref().map(ToolchainRequest::parse),
toolchain_preference, toolchain_preference,

View file

@ -17,8 +17,8 @@ use uv_toolchain::{Interpreter, ToolchainPreference, ToolchainRequest};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight};
use uv_warnings::{warn_user, warn_user_once}; use uv_warnings::{warn_user, warn_user_once};
use crate::commands::project::{find_requires_python, ProjectError}; use crate::commands::project::{find_requires_python, FoundInterpreter, ProjectError};
use crate::commands::{pip, project, ExitStatus}; use crate::commands::{pip, ExitStatus};
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::{ResolverSettings, ResolverSettingsRef}; use crate::settings::{ResolverSettings, ResolverSettingsRef};
@ -42,7 +42,7 @@ pub(crate) async fn lock(
let workspace = Workspace::discover(&std::env::current_dir()?, None).await?; let workspace = Workspace::discover(&std::env::current_dir()?, None).await?;
// Find an interpreter for the project // Find an interpreter for the project
let interpreter = project::find_interpreter( let interpreter = FoundInterpreter::discover(
&workspace, &workspace,
python.as_deref().map(ToolchainRequest::parse), python.as_deref().map(ToolchainRequest::parse),
toolchain_preference, toolchain_preference,
@ -51,7 +51,8 @@ pub(crate) async fn lock(
cache, cache,
printer, printer,
) )
.await?; .await?
.into_interpreter();
// Perform the lock operation. // Perform the lock operation.
match do_lock( match do_lock(

View file

@ -1,6 +1,5 @@
use std::fmt::Write; use std::fmt::Write;
use anyhow::{Context, Result};
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use tracing::debug; use tracing::debug;
@ -22,7 +21,6 @@ use uv_toolchain::{
ToolchainPreference, ToolchainRequest, VersionRequest, ToolchainPreference, ToolchainRequest, VersionRequest,
}; };
use uv_types::{BuildIsolation, HashStrategy, InFlight}; use uv_types::{BuildIsolation, HashStrategy, InFlight};
use uv_warnings::warn_user;
use crate::commands::pip; use crate::commands::pip;
use crate::printer::Printer; use crate::printer::Printer;
@ -36,11 +34,14 @@ pub(crate) mod sync;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub(crate) enum ProjectError { pub(crate) enum ProjectError {
#[error("The current Python version ({0}) is not compatible with the locked Python requirement ({1})")] #[error("The current Python version ({0}) is not compatible with the locked Python requirement: `{1}`")]
PythonIncompatibility(Version, RequiresPython), LockedPythonIncompatibility(Version, RequiresPython),
#[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`")]
RequestedPythonIncompatibility(Version, RequiresPython),
#[error(transparent)] #[error(transparent)]
Interpreter(#[from] uv_toolchain::Error), Toolchain(#[from] uv_toolchain::Error),
#[error(transparent)] #[error(transparent)]
Virtualenv(#[from] uv_virtualenv::Error), Virtualenv(#[from] uv_virtualenv::Error),
@ -60,9 +61,6 @@ pub(crate) enum ProjectError {
#[error(transparent)] #[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error(transparent)]
Serialize(#[from] toml::ser::Error),
#[error(transparent)] #[error(transparent)]
Anyhow(#[from] anyhow::Error), Anyhow(#[from] anyhow::Error),
@ -90,7 +88,7 @@ pub(crate) fn find_requires_python(
} }
/// Find the virtual environment for the current project. /// Find the virtual environment for the current project.
pub(crate) fn find_environment( fn find_environment(
workspace: &Workspace, workspace: &Workspace,
cache: &Cache, cache: &Cache,
) -> Result<PythonEnvironment, uv_toolchain::Error> { ) -> Result<PythonEnvironment, uv_toolchain::Error> {
@ -98,122 +96,127 @@ pub(crate) fn find_environment(
} }
/// Check if the given interpreter satisfies the project's requirements. /// Check if the given interpreter satisfies the project's requirements.
pub(crate) fn interpreter_meets_requirements( fn interpreter_meets_requirements(
interpreter: &Interpreter, interpreter: &Interpreter,
requested_python: Option<&ToolchainRequest>, requested_python: Option<&ToolchainRequest>,
requires_python: Option<&RequiresPython>,
cache: &Cache, cache: &Cache,
) -> bool { ) -> bool {
// `--python` has highest precedence, after that we check `requires_python` from let Some(request) = requested_python else {
// `pyproject.toml`. If `--python` and `requires_python` are mutually incompatible, return true;
// 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;
}; };
if request.satisfied(interpreter, cache) {
if let Some(requires_python) = requires_python { debug!("Interpreter meets the requested Python: `{request}`");
if requires_python.contains(interpreter.python_version()) { true
debug!("Interpreter meets the project `Requires-Python` constraint {requires_python}"); } else {
return true; debug!("Interpreter does not meet the request: `{request}`");
} false
}
debug!(
"Interpreter does not meet the project `Requires-Python` constraint {requires_python}"
);
return false;
};
// No requirement to check
true
} }
/// Find the interpreter to use in the current project. #[derive(Debug)]
pub(crate) async fn find_interpreter( pub(crate) enum FoundInterpreter {
workspace: &Workspace, Interpreter(Interpreter),
python_request: Option<ToolchainRequest>, Environment(PythonEnvironment),
toolchain_preference: ToolchainPreference, }
connectivity: Connectivity,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<Interpreter, ProjectError> {
let requires_python = find_requires_python(workspace)?;
// (1) Explicit request from user impl FoundInterpreter {
let python_request = if let Some(request) = python_request { /// Discover the interpreter to use in the current [`Workspace`].
Some(request) pub(crate) async fn discover(
// (2) Request from `.python-version` workspace: &Workspace,
} else if let Some(request) = request_from_version_file().await? { python_request: Option<ToolchainRequest>,
Some(request) toolchain_preference: ToolchainPreference,
// (3) `Requires-Python` in `pyproject.toml` connectivity: Connectivity,
} else { native_tls: bool,
requires_python cache: &Cache,
.as_ref() printer: Printer,
.map(RequiresPython::specifiers) ) -> Result<Self, ProjectError> {
.map(|specifiers| ToolchainRequest::Version(VersionRequest::Range(specifiers.clone()))) let requires_python = find_requires_python(workspace)?;
};
// Read from the virtual environment first // (1) Explicit request from user
match find_environment(workspace, cache) { let python_request = if let Some(request) = python_request {
Ok(venv) => { Some(request)
if interpreter_meets_requirements( // (2) Request from `.python-version`
venv.interpreter(), } else if let Some(request) = request_from_version_file().await? {
python_request.as_ref(), Some(request)
requires_python.as_ref(), // (3) `Requires-Python` in `pyproject.toml`
cache, } else {
) { requires_python
return Ok(venv.into_interpreter()); .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() Ok(Self::Interpreter(interpreter))
.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(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. /// Initialize a virtual environment for the current project.
pub(crate) async fn init_environment( pub(crate) async fn get_or_init_environment(
workspace: &Workspace, workspace: &Workspace,
python: Option<ToolchainRequest>, python: Option<ToolchainRequest>,
toolchain_preference: ToolchainPreference, toolchain_preference: ToolchainPreference,
@ -222,35 +225,7 @@ pub(crate) async fn init_environment(
cache: &Cache, cache: &Cache,
printer: Printer, printer: Printer,
) -> Result<PythonEnvironment, ProjectError> { ) -> Result<PythonEnvironment, ProjectError> {
let requires_python = find_requires_python(workspace)?; match FoundInterpreter::discover(
// 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(
workspace, workspace,
python, python,
toolchain_preference, toolchain_preference,
@ -259,22 +234,43 @@ pub(crate) async fn init_environment(
cache, cache,
printer, printer,
) )
.await?; .await?
{
// If we found an existing, compatible environment, use it.
FoundInterpreter::Environment(environment) => Ok(environment),
let venv = workspace.venv(); // Otherwise, create a virtual environment with the discovered interpreter.
writeln!( FoundInterpreter::Interpreter(interpreter) => {
printer.stderr(), let venv = workspace.venv();
"Creating virtualenv at: {}",
venv.user_display().cyan()
)?;
Ok(uv_virtualenv::create_venv( // Remove the existing virtual environment if it doesn't meet the requirements.
&venv, match fs_err::remove_dir_all(&venv) {
interpreter, Ok(()) => {
uv_virtualenv::Prompt::None, writeln!(
false, printer.stderr(),
false, "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. /// Update a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s.
@ -288,7 +284,7 @@ pub(crate) async fn update_environment(
native_tls: bool, native_tls: bool,
cache: &Cache, cache: &Cache,
printer: Printer, printer: Printer,
) -> Result<PythonEnvironment> { ) -> anyhow::Result<PythonEnvironment> {
// Extract the project settings. // Extract the project settings.
let ResolverInstallerSettings { let ResolverInstallerSettings {
index_locations, index_locations,

View file

@ -81,7 +81,7 @@ pub(crate) async fn remove(
)?; )?;
// Discover or create the virtual environment. // Discover or create the virtual environment.
let venv = project::init_environment( let venv = project::get_or_init_environment(
project.workspace(), project.workspace(),
python.as_deref().map(ToolchainRequest::parse), python.as_deref().map(ToolchainRequest::parse),
toolchain_preference, 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(), project.workspace(),
python.as_deref().map(ToolchainRequest::parse), python.as_deref().map(ToolchainRequest::parse),
toolchain_preference, toolchain_preference,

View file

@ -41,7 +41,7 @@ pub(crate) async fn sync(
let project = VirtualProject::discover(&std::env::current_dir()?, None).await?; let project = VirtualProject::discover(&std::env::current_dir()?, None).await?;
// Discover or create the virtual environment. // Discover or create the virtual environment.
let venv = project::init_environment( let venv = project::get_or_init_environment(
project.workspace(), project.workspace(),
python.as_deref().map(ToolchainRequest::parse), python.as_deref().map(ToolchainRequest::parse),
toolchain_preference, toolchain_preference,
@ -111,7 +111,7 @@ pub(super) async fn do_sync(
// Validate that the Python version is supported by the lockfile. // Validate that the Python version is supported by the lockfile.
if let Some(requires_python) = lock.requires_python() { if let Some(requires_python) = lock.requires_python() {
if !requires_python.contains(venv.interpreter().python_version()) { if !requires_python.contains(venv.interpreter().python_version()) {
return Err(ProjectError::PythonIncompatibility( return Err(ProjectError::LockedPythonIncompatibility(
venv.interpreter().python_version().clone(), venv.interpreter().python_version().clone(),
requires_python.clone(), requires_python.clone(),
)); ));

View file

@ -2047,7 +2047,6 @@ fn lock_requires_python() -> Result<()> {
----- stderr ----- ----- stderr -----
warning: `uv sync` is experimental and may change without warning. 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 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. /// Run with different python versions, which also depend on different dependency versions.
#[test] #[test]
fn run_with_python_version() -> Result<()> { 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"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#" pyproject_toml.write_str(indoc! { r#"
@ -102,8 +102,8 @@ fn run_with_python_version() -> Result<()> {
3.6.0 3.6.0
----- stderr ----- ----- stderr -----
Removing virtual environment at: .venv
Using Python 3.11.[X] interpreter at: [PYTHON-3.11] Using Python 3.11.[X] interpreter at: [PYTHON-3.11]
Removed virtual environment at: .venv
Creating virtualenv at: .venv Creating virtualenv at: .venv
Resolved 5 packages in [TIME] Resolved 5 packages in [TIME]
Prepared 4 packages in [TIME] Prepared 4 packages in [TIME]
@ -114,6 +114,27 @@ fn run_with_python_version() -> Result<()> {
+ sniffio==1.3.1 + 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(()) Ok(())
} }