Prefer target Python version over current version for builds (#1040)

Extends #1029 
Closes https://github.com/astral-sh/puffin/issues/1038

Instead of always using the current Python version for builds when a
target version is provided, we will do our best to use a compatible
Python version for builds.

Removes behavior where Python versions without patch versions were
always assumed to be the latest known patch version (previously
discussed in https://github.com/astral-sh/puffin/pull/534). While this
was convenient for resolutions which include packages which require
minimum patch versions e.g. `requires-python=">=3.7.4"`, it conflicts
with the idea that the target Python version you provide is the
_minimum_ compatible version. Additionally, it complicates interpreter
lookup as we cannot tell if the user has asked for that specific patch
version or not.
This commit is contained in:
Zanie Blue 2024-01-24 11:12:02 -06:00 committed by GitHub
parent 77dcb2421a
commit ea4ab29bad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 153 additions and 92 deletions

View File

@ -33,7 +33,7 @@ fn run() -> Result<(), gourgeist::Error> {
} else {
Cache::from_path(".gourgeist_cache")?
};
let info = Interpreter::query(python.as_std_path(), platform, &cache).unwrap();
let info = Interpreter::query(python.as_std_path(), &platform, &cache).unwrap();
create_bare_venv(&location, &info)?;
Ok(())
}

View File

@ -32,7 +32,7 @@ pub struct Interpreter {
impl Interpreter {
/// Detect the interpreter info for the given Python executable.
pub fn query(executable: &Path, platform: Platform, cache: &Cache) -> Result<Self, Error> {
pub fn query(executable: &Path, platform: &Platform, cache: &Cache) -> Result<Self, Error> {
let info = InterpreterQueryResult::query_cached(executable, cache)?;
debug_assert!(
info.base_prefix == info.base_exec_prefix,
@ -47,7 +47,7 @@ impl Interpreter {
);
Ok(Self {
platform: PythonPlatform(platform),
platform: PythonPlatform(platform.to_owned()),
markers: info.markers,
base_exec_prefix: info.base_exec_prefix,
base_prefix: info.base_prefix,
@ -83,25 +83,80 @@ impl Interpreter {
}
}
/// Detect the python interpreter to use.
/// Find the best available Python interpreter to use.
///
/// Note that `python_version` is a preference here, not a requirement.
/// If no Python version is provided, we will use the first available interpreter.
///
/// We check, in order:
/// * `VIRTUAL_ENV` and `CONDA_PREFIX`
/// * A `.venv` folder
/// * If a python version is given: `pythonx.y`
/// * `python3` (unix) or `python.exe` (windows)
pub fn find(
/// If a Python version is provided, we will first try to find an exact match. If
/// that cannot be found and a patch version was requested, we will look for a match
/// without comparing the patch version number. If that cannot be found, we fall back to
/// the first available version.
///
/// See [`Self::find_strict`] for details on the precedence of Python lookup locations.
pub fn find_best(
python_version: Option<&PythonVersion>,
platform: Platform,
platform: &Platform,
cache: &Cache,
) -> Result<Self, Error> {
let platform = PythonPlatform::from(platform);
// First, check for an exact match (or the first available version if no Python version was provided)
if let Some(interpreter) = Self::find_version(python_version, platform, cache)? {
return Ok(interpreter);
}
if let Some(python_version) = python_version {
// If that fails, and a specific patch version was requested try again allowing a
// different patch version
if python_version.patch().is_some() {
if let Some(interpreter) =
Self::find_version(Some(&python_version.without_patch()), platform, cache)?
{
return Ok(interpreter);
}
}
// If a Python version was requested but cannot be fulfilled, just take any version
if let Some(interpreter) = Self::find_version(None, platform, cache)? {
return Ok(interpreter);
}
}
Err(Error::PythonNotFound)
}
/// Find a Python interpreter.
///
/// We check, in order, the following locations:
///
/// - `VIRTUAL_ENV` and `CONDA_PREFIX`
/// - A `.venv` folder
/// - If a python version is given: `pythonx.y`
/// - `python3` (unix) or `python.exe` (windows)
///
/// If a version is provided and an interpreter cannot be found with the given version,
/// we will return [`None`].
pub fn find_version(
python_version: Option<&PythonVersion>,
platform: &Platform,
cache: &Cache,
) -> Result<Option<Self>, Error> {
let version_matches = |interpreter: &Self| -> bool {
if let Some(python_version) = python_version {
// If a patch version was provided, check for an exact match
python_version.is_satisfied_by(interpreter)
} else {
// The version always matches if one was not provided
true
}
};
let platform = PythonPlatform::from(platform.to_owned());
if let Some(venv) = detect_virtual_env(&platform)? {
let executable = platform.venv_python(venv);
let interpreter = Self::query(&executable, platform.0, cache)?;
return Ok(interpreter);
let interpreter = Self::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
};
if cfg!(unix) {
@ -113,32 +168,43 @@ impl Interpreter {
);
if let Ok(executable) = which::which(&requested) {
debug!("Resolved {requested} to {}", executable.display());
let interpreter = Interpreter::query(&executable, platform.0, cache)?;
return Ok(interpreter);
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
}
}
let executable = which::which("python3")
.map_err(|err| Error::WhichNotFound("python3".to_string(), err))?;
debug!("Resolved python3 to {}", executable.display());
let interpreter = Interpreter::query(&executable, platform.0, cache)?;
Ok(interpreter)
if let Ok(executable) = which::which("python3") {
debug!("Resolved python3 to {}", executable.display());
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
}
} else if cfg!(windows) {
if let Some(python_version) = python_version {
if let Some(path) =
find_python_windows(python_version.major(), python_version.minor())?
{
return Interpreter::query(&path, platform.0, cache);
let interpreter = Interpreter::query(&path, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
}
}
let executable = which::which("python.exe")
.map_err(|err| Error::WhichNotFound("python.exe".to_string(), err))?;
let interpreter = Interpreter::query(&executable, platform.0, cache)?;
Ok(interpreter)
if let Ok(executable) = which::which("python.exe") {
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
}
} else {
unimplemented!("Only Windows and Unix are supported");
}
Ok(None)
}
/// Returns the path to the Python virtual environment.
@ -433,8 +499,7 @@ mod tests {
std::os::unix::fs::PermissionsExt::from_mode(0o770),
)
.unwrap();
let interpreter =
Interpreter::query(&mocked_interpreter, platform.clone(), &cache).unwrap();
let interpreter = Interpreter::query(&mocked_interpreter, &platform, &cache).unwrap();
assert_eq!(
interpreter.markers.python_version.version,
Version::from_str("3.12").unwrap()
@ -447,7 +512,7 @@ mod tests {
"##, json.replace("3.12", "3.13")},
)
.unwrap();
let interpreter = Interpreter::query(&mocked_interpreter, platform, &cache).unwrap();
let interpreter = Interpreter::query(&mocked_interpreter, &platform, &cache).unwrap();
assert_eq!(
interpreter.markers.python_version.version,
Version::from_str("3.13").unwrap()

View File

@ -25,6 +25,8 @@ pub enum Error {
BrokenVenv(PathBuf, PathBuf),
#[error("Both VIRTUAL_ENV and CONDA_PREFIX are set. Please unset one of them.")]
Conflict,
#[error("No versions of Python could be found. Is Python installed?")]
PythonNotFound,
#[error("Could not find `{0}` in PATH")]
WhichNotFound(String, #[source] which::Error),
#[error("Failed to locate a virtualenv or Conda environment (checked: `VIRTUAL_ENV`, `CONDA_PREFIX`, and `.venv`). Run `puffin venv` to create a virtual environment.")]

View File

@ -1,9 +1,8 @@
use std::str::FromStr;
use tracing::debug;
use pep440_rs::Version;
use pep508_rs::{MarkerEnvironment, StringVersion};
use std::str::FromStr;
use crate::Interpreter;
#[derive(Debug, Clone)]
pub struct PythonVersion(StringVersion);
@ -29,34 +28,7 @@ impl FromStr for PythonVersion {
return Err(format!("Python version {s} must be < 4.0"));
}
// If the version lacks a patch, assume the most recent known patch for that minor version.
match version.release() {
[3, 7] => {
debug!("Assuming Python 3.7.17");
Ok(Self(StringVersion::from_str("3.7.17")?))
}
[3, 8] => {
debug!("Assuming Python 3.8.18");
Ok(Self(StringVersion::from_str("3.8.18")?))
}
[3, 9] => {
debug!("Assuming Python 3.9.18");
Ok(Self(StringVersion::from_str("3.9.18")?))
}
[3, 10] => {
debug!("Assuming Python 3.10.13");
Ok(Self(StringVersion::from_str("3.10.13")?))
}
[3, 11] => {
debug!("Assuming Python 3.11.6");
Ok(Self(StringVersion::from_str("3.11.6")?))
}
[3, 12] => {
debug!("Assuming Python 3.12.0");
Ok(Self(StringVersion::from_str("3.12.0")?))
}
_ => Ok(Self(version)),
}
Ok(Self(version))
}
}
@ -83,6 +55,11 @@ impl PythonVersion {
markers
}
/// Return the full parsed Python version.
pub fn version(&self) -> &Version {
&self.0.version
}
/// Return the major version of this Python version.
pub fn major(&self) -> u8 {
u8::try_from(self.0.release()[0]).expect("invalid major version")
@ -93,8 +70,31 @@ impl PythonVersion {
u8::try_from(self.0.release()[1]).expect("invalid minor version")
}
/// Returns the Python version as a simple tuple.
pub fn simple_version(&self) -> (u8, u8) {
(self.major(), self.minor())
/// Check if this Python version is satisfied by the given interpreter.
///
/// If a patch version is present, we will require an exact match.
/// Otherwise, just the major and minor version numbers need to match.
pub fn is_satisfied_by(&self, interpreter: &Interpreter) -> bool {
if self.patch().is_some() {
self.version() == interpreter.python_version()
} else {
(self.major(), self.minor()) == interpreter.python_tuple()
}
}
/// Return the patch version of this Python version, if set.
pub fn patch(&self) -> Option<u8> {
self.0
.release()
.get(2)
.copied()
.map(|patch| u8::try_from(patch).expect("invalid patch version"))
}
/// Returns a copy of the Python version without the patch version
#[must_use]
pub fn without_patch(&self) -> Self {
Self::from_str(format!("{}.{}", self.major(), self.minor()).as_str())
.expect("dropping a patch should always be valid")
}
}

View File

@ -27,7 +27,7 @@ impl Virtualenv {
};
let venv = fs_err::canonicalize(venv)?;
let executable = platform.venv_python(&venv);
let interpreter = Interpreter::query(&executable, platform.0, cache)?;
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
Ok(Self {
root: venv,

View File

@ -122,9 +122,9 @@ pub(crate) async fn pip_compile(
})
.unwrap_or_default();
// Detect the current Python interpreter.
// Find an interpreter to use for building distributions
let platform = Platform::current()?;
let interpreter = Interpreter::find(python_version.as_ref(), platform, &cache)?;
let interpreter = Interpreter::find_best(python_version.as_ref(), &platform, &cache)?;
debug!(
"Using Python {} interpreter at {} for builds",
interpreter.python_version(),
@ -147,7 +147,7 @@ pub(crate) async fn pip_compile(
let tags = if let Some(python_version) = python_version.as_ref() {
Cow::Owned(Tags::from_env(
interpreter.platform(),
python_version.simple_version(),
(python_version.major(), python_version.minor()),
interpreter.implementation_name(),
interpreter.implementation_tuple(),
)?)

View File

@ -94,7 +94,7 @@ async fn venv_impl(
let platform = Platform::current().into_diagnostic()?;
let interpreter =
Interpreter::query(&base_python, platform, cache).map_err(VenvError::InterpreterError)?;
Interpreter::query(&base_python, &platform, cache).map_err(VenvError::InterpreterError)?;
writeln!(
printer,

View File

@ -687,9 +687,9 @@ fn compile_python_37() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because the requested Python version (3.7.17) does not satisfy
Python>=3.8 and black==23.10.1 depends on Python>=3.8, we can conclude
that black==23.10.1 cannot be used.
Because the requested Python version (3.7) does not satisfy Python>=3.8
and black==23.10.1 depends on Python>=3.8, we can conclude that
black==23.10.1 cannot be used.
And because you require black==23.10.1, we can conclude that the
requirements are unsatisfiable.
"###);

View File

@ -128,7 +128,7 @@ fn requires_compatible_python_version_incompatible_override() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because the requested Python version (3.9.18) does not satisfy Python>=3.10 and albatross==1.0.0 depends on Python>=3.10, we can conclude that albatross==1.0.0 cannot be used.
Because the requested Python version (3.9) does not satisfy Python>=3.10 and albatross==1.0.0 depends on Python>=3.10, we can conclude that albatross==1.0.0 cannot be used.
And because you require albatross==1.0.0, we can conclude that the requirements are unsatisfiable.
"###);
});
@ -185,14 +185,15 @@ fn requires_incompatible_python_version_compatible_override_no_wheels() -> Resul
.env("VIRTUAL_ENV", venv.as_os_str())
.env("PUFFIN_NO_WRAP", "1")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by Puffin v[VERSION] via the following command:
# puffin pip compile requirements.in --python-version=3.11 --extra-index-url https://test.pypi.org/simple --cache-dir [CACHE_DIR]
albatross==1.0.0
----- stderr -----
× No solution found when resolving dependencies:
Because the current Python version (3.9.18) does not satisfy Python>=3.10 and albatross==1.0.0 depends on Python>=3.10, we can conclude that albatross==1.0.0 cannot be used.
And because you require albatross==1.0.0, we can conclude that the requirements are unsatisfiable.
Resolved 1 package in [TIME]
"###);
});
@ -314,22 +315,15 @@ fn requires_incompatible_python_version_compatible_override_other_wheel() -> Res
.env("VIRTUAL_ENV", venv.as_os_str())
.env("PUFFIN_NO_WRAP", "1")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by Puffin v[VERSION] via the following command:
# puffin pip compile requirements.in --python-version=3.11 --extra-index-url https://test.pypi.org/simple --cache-dir [CACHE_DIR]
albatross==1.0.0
----- stderr -----
× No solution found when resolving dependencies:
Because the current Python version (3.9.18) does not satisfy Python>=3.10 and albatross==1.0.0 depends on Python>=3.10, we can conclude that albatross==1.0.0 cannot be used.
And because there are no versions of albatross that satisfy any of:
albatross<1.0.0
albatross>1.0.0,<2.0.0
albatross>2.0.0
we can conclude that albatross<2.0.0 cannot be used. (1)
Because the requested Python version (3.11.6) does not satisfy Python>=3.12 and albatross==2.0.0 depends on Python>=3.12, we can conclude that albatross==2.0.0 cannot be used.
And because we know from (1) that albatross<2.0.0 cannot be used, we can conclude that all versions of albatross cannot be used.
And because you require albatross, we can conclude that the requirements are unsatisfiable.
Resolved 1 package in [TIME]
"###);
});