diff --git a/crates/gourgeist/src/main.rs b/crates/gourgeist/src/main.rs index ef4c4adaa..8b107648c 100644 --- a/crates/gourgeist/src/main.rs +++ b/crates/gourgeist/src/main.rs @@ -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(()) } diff --git a/crates/puffin-interpreter/src/interpreter.rs b/crates/puffin-interpreter/src/interpreter.rs index c55747acd..9b5cf3ea4 100644 --- a/crates/puffin-interpreter/src/interpreter.rs +++ b/crates/puffin-interpreter/src/interpreter.rs @@ -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 { + pub fn query(executable: &Path, platform: &Platform, cache: &Cache) -> Result { 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 { - 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, 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() diff --git a/crates/puffin-interpreter/src/lib.rs b/crates/puffin-interpreter/src/lib.rs index 2111c279c..193110e40 100644 --- a/crates/puffin-interpreter/src/lib.rs +++ b/crates/puffin-interpreter/src/lib.rs @@ -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.")] diff --git a/crates/puffin-interpreter/src/python_version.rs b/crates/puffin-interpreter/src/python_version.rs index 126a27f1b..702b2a56e 100644 --- a/crates/puffin-interpreter/src/python_version.rs +++ b/crates/puffin-interpreter/src/python_version.rs @@ -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 { + 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") } } diff --git a/crates/puffin-interpreter/src/virtual_env.rs b/crates/puffin-interpreter/src/virtual_env.rs index 09c38cfed..4d59eaa28 100644 --- a/crates/puffin-interpreter/src/virtual_env.rs +++ b/crates/puffin-interpreter/src/virtual_env.rs @@ -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, diff --git a/crates/puffin/src/commands/pip_compile.rs b/crates/puffin/src/commands/pip_compile.rs index a3e9fc9eb..f8662c5b3 100644 --- a/crates/puffin/src/commands/pip_compile.rs +++ b/crates/puffin/src/commands/pip_compile.rs @@ -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(), )?) diff --git a/crates/puffin/src/commands/venv.rs b/crates/puffin/src/commands/venv.rs index bae540566..67c8ae01a 100644 --- a/crates/puffin/src/commands/venv.rs +++ b/crates/puffin/src/commands/venv.rs @@ -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, diff --git a/crates/puffin/tests/pip_compile.rs b/crates/puffin/tests/pip_compile.rs index 35ca1056d..36dd1d098 100644 --- a/crates/puffin/tests/pip_compile.rs +++ b/crates/puffin/tests/pip_compile.rs @@ -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. "###); diff --git a/crates/puffin/tests/pip_compile_scenarios.rs b/crates/puffin/tests/pip_compile_scenarios.rs index 4303473bf..0557839b9 100644 --- a/crates/puffin/tests/pip_compile_scenarios.rs +++ b/crates/puffin/tests/pip_compile_scenarios.rs @@ -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] "###); });