diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index d376b3c13ef8f..066c031991524 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1,7 +1,6 @@ use itertools::{Either, Itertools}; use regex::Regex; use same_file::is_same_file; -use std::borrow::Cow; use std::env::consts::EXE_SUFFIX; use std::fmt::{self, Debug, Formatter}; use std::{env, io, iter}; @@ -431,7 +430,11 @@ fn python_executables_from_search_path<'a>( env::var_os("UV_TEST_PYTHON_PATH").unwrap_or(env::var_os("PATH").unwrap_or_default()); let version_request = version.unwrap_or(&VersionRequest::Default); - let possible_names: Vec<_> = version_request.possible_names(implementation).collect(); + let possible_names: Vec<_> = version_request + .executable_names(implementation) + .into_iter() + .map(|name| name.to_string()) + .collect(); trace!( "Searching PATH for executables: {}", @@ -1199,7 +1202,9 @@ impl PythonRequest { } } } - for implementation in ImplementationName::possible_names() { + for implementation in + ImplementationName::long_names().chain(ImplementationName::short_names()) + { if let Some(remainder) = value .to_ascii_lowercase() .strip_prefix(Into::<&str>::into(implementation)) @@ -1474,105 +1479,203 @@ impl EnvironmentPreference { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct ExecutableName { + name: &'static str, + major: Option, + minor: Option, + patch: Option, + prerelease: Option, + free_threaded: bool, +} + +impl ExecutableName { + #[must_use] + fn with_name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + #[must_use] + fn with_major(mut self, major: u8) -> Self { + self.major = Some(major); + self + } + + #[must_use] + fn with_minor(mut self, minor: u8) -> Self { + self.minor = Some(minor); + self + } + + #[must_use] + fn with_patch(mut self, patch: u8) -> Self { + self.patch = Some(patch); + self + } + + #[must_use] + fn with_prerelease(mut self, prerelease: Prerelease) -> Self { + self.prerelease = Some(prerelease); + self + } + + // Enable when we add free-threading support + // #[must_use] + // fn with_free_threaded(mut self, free_threaded: bool) -> Self { + // self.free_threaded = free_threaded; + // self + // } +} + +impl Default for ExecutableName { + fn default() -> Self { + Self { + name: "python", + major: None, + minor: None, + patch: None, + prerelease: None, + free_threaded: false, + } + } +} + +impl std::fmt::Display for ExecutableName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name)?; + if let Some(major) = self.major { + write!(f, "{major}")?; + if let Some(minor) = self.minor { + write!(f, ".{minor}")?; + if let Some(patch) = self.patch { + write!(f, ".{patch}")?; + } + } + } + if let Some(prerelease) = &self.prerelease { + write!(f, "{prerelease}")?; + } + if self.free_threaded { + f.write_str("t")?; + } + f.write_str(std::env::consts::EXE_SUFFIX)?; + Ok(()) + } +} + impl VersionRequest { - pub(crate) fn default_names(&self) -> [Option>; 4] { - let (python, python3, extension) = if cfg!(windows) { - ( - Cow::Borrowed("python.exe"), - Cow::Borrowed("python3.exe"), - ".exe", - ) + pub(crate) fn executable_names( + &self, + implementation: Option<&ImplementationName>, + ) -> Vec { + let prerelease = if let Self::MajorMinorPrerelease(_, _, prerelease) = self { + // Include the prerelease version, e.g., `python3.8a` + Some(prerelease) } else { - (Cow::Borrowed("python"), Cow::Borrowed("python3"), "") + None }; - match self { - Self::Any | Self::Default | Self::Range(_) => [Some(python3), Some(python), None, None], - Self::Major(major) => [ - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - None, - None, - ], - Self::MajorMinor(major, minor) => [ - Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - None, - ], - Self::MajorMinorPatch(major, minor, patch) => [ - Some(Cow::Owned(format!( - "python{major}.{minor}.{patch}{extension}", - ))), - Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - ], - Self::MajorMinorPrerelease(major, minor, prerelease) => [ - Some(Cow::Owned(format!( - "python{major}.{minor}{prerelease}{extension}", - ))), - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - None, - ], + // Push a default one + let mut names = Vec::new(); + names.push(ExecutableName::default()); + + // Collect each variant depending on the number of versions + if let Some(major) = self.major() { + // e.g. `python3` + names.push(ExecutableName::default().with_major(major)); + if let Some(minor) = self.minor() { + // e.g., `python3.12` + names.push( + ExecutableName::default() + .with_major(major) + .with_minor(minor), + ); + if let Some(patch) = self.patch() { + // e.g, `python3.12.1` + names.push( + ExecutableName::default() + .with_major(major) + .with_minor(minor) + .with_patch(patch), + ); + } + } + } else { + // Include `3` by default, e.g., `python3` + names.push(ExecutableName::default().with_major(3)); } - } - pub(crate) fn possible_names<'a>( - &'a self, - implementation: Option<&'a ImplementationName>, - ) -> impl Iterator> + 'a { - implementation - .into_iter() - .flat_map(move |implementation| { - let extension = std::env::consts::EXE_SUFFIX; - let name: &str = implementation.into(); - let (python, python3) = if extension.is_empty() { - (Cow::Borrowed(name), Cow::Owned(format!("{name}3"))) - } else { - ( - Cow::Owned(format!("{name}{extension}")), - Cow::Owned(format!("{name}3{extension}")), - ) - }; + if let Some(prerelease) = prerelease { + // Include the prerelease version, e.g., `python3.8a` + for i in 0..names.len() { + let name = names[i]; + if name.minor.is_none() { + // We don't want to include the pre-release marker here + // e.g. `pythonrc1` and `python3rc1` don't make sense + continue; + } + names.push(name.with_prerelease(*prerelease)); + } + } - match self { - Self::Any | Self::Default | Self::Range(_) => { - [Some(python3), Some(python), None, None] + // Add all the implementation-specific names + if let Some(implementation) = implementation { + for i in 0..names.len() { + let name = names[i].with_name(implementation.into()); + names.push(name); + } + } else { + // When looking for all implementations, include all possible names + if matches!(self, Self::Any) { + for i in 0..names.len() { + for implementation in ImplementationName::long_names() { + let name = names[i].with_name(implementation); + names.push(name); } - Self::Major(major) => [ - Some(Cow::Owned(format!("{name}{major}{extension}"))), - Some(python), - None, - None, - ], - Self::MajorMinor(major, minor) => [ - Some(Cow::Owned(format!("{name}{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("{name}{major}{extension}"))), - Some(python), - None, - ], - Self::MajorMinorPatch(major, minor, patch) => [ - Some(Cow::Owned(format!( - "{name}{major}.{minor}.{patch}{extension}", - ))), - Some(Cow::Owned(format!("{name}{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("{name}{major}{extension}"))), - Some(python), - ], - Self::MajorMinorPrerelease(major, minor, prerelease) => [ - Some(Cow::Owned(format!( - "{name}{major}.{minor}{prerelease}{extension}", - ))), - Some(Cow::Owned(format!("{name}{major}{extension}"))), - Some(python), - None, - ], } - }) - .chain(self.default_names()) - .flatten() + } + } + + // Include free-threaded variants when supported + // if self.is_free_threaded_requested() { + // for i in 0..names.len() { + // let name = names[i].with_free_threaded(true); + // names.push(name); + // } + // } + + names + } + + pub(crate) fn major(&self) -> Option { + match self { + Self::Any | Self::Default | Self::Range(_) => None, + Self::Major(major) => Some(*major), + Self::MajorMinor(major, _) => Some(*major), + Self::MajorMinorPatch(major, _, _) => Some(*major), + Self::MajorMinorPrerelease(major, _, _) => Some(*major), + } + } + + pub(crate) fn minor(&self) -> Option { + match self { + Self::Any | Self::Default | Self::Range(_) => None, + Self::Major(_) => None, + Self::MajorMinor(_, minor) => Some(*minor), + Self::MajorMinorPatch(_, minor, _) => Some(*minor), + Self::MajorMinorPrerelease(_, minor, _) => Some(*minor), + } + } + + pub(crate) fn patch(&self) -> Option { + match self { + Self::Any | Self::Default | Self::Range(_) => None, + Self::Major(_) => None, + Self::MajorMinor(_, _) => None, + Self::MajorMinorPatch(_, _, patch) => Some(*patch), + Self::MajorMinorPrerelease(_, _, _) => None, + } } pub(crate) fn check_supported(&self) -> Result<(), String> { @@ -2392,4 +2495,87 @@ mod tests { ) ); } + + #[test] + fn executable_names_from_request() { + fn case(request: &str, expected: &[&str]) { + let (implementation, version) = match PythonRequest::parse(request) { + PythonRequest::Any => (None, VersionRequest::Any), + PythonRequest::Default => (None, VersionRequest::Default), + PythonRequest::Version(version) => (None, version), + PythonRequest::ImplementationVersion(implementation, version) => { + (Some(implementation), version) + } + PythonRequest::Implementation(implementation) => { + (Some(implementation), VersionRequest::Default) + } + result => { + panic!("Test cases should request versions or implementations; got {result:?}") + } + }; + + let result: Vec<_> = version + .executable_names(implementation.as_ref()) + .into_iter() + .map(|name| name.to_string()) + .collect(); + + let expected: Vec<_> = expected + .iter() + .map(|name| format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX)) + .collect(); + + assert_eq!(result, expected, "mismatch for case \"{request}\""); + } + + case( + "any", + &[ + "python", "python3", "cpython", "pypy", "graalpy", "cpython3", "pypy3", "graalpy3", + ], + ); + + case("default", &["python", "python3"]); + + case("3", &["python", "python3"]); + + case("4", &["python", "python4"]); + + case("3.13", &["python", "python3", "python3.13"]); + + case( + "pypy@3.10", + &[ + "python", + "python3", + "python3.10", + "pypy", + "pypy3", + "pypy3.10", + ], + ); + + // Enable when we add free-threading support + // case( + // "3.13t", + // &[ + // "python", + // "python3", + // "python3.13", + // "pythont", + // "python3t", + // "python3.13t", + // ], + // ); + + case( + "3.13.2", + &["python", "python3", "python3.13", "python3.13.2"], + ); + + case( + "3.13rc2", + &["python", "python3", "python3.13", "python3.13rc2"], + ); + } } diff --git a/crates/uv-python/src/implementation.rs b/crates/uv-python/src/implementation.rs index cc87b56afc80d..6ccad8dddfd2d 100644 --- a/crates/uv-python/src/implementation.rs +++ b/crates/uv-python/src/implementation.rs @@ -25,8 +25,12 @@ pub enum LenientImplementationName { } impl ImplementationName { - pub(crate) fn possible_names() -> impl Iterator { - ["cpython", "pypy", "graalpy", "cp", "pp", "gp"].into_iter() + pub(crate) fn short_names() -> impl Iterator { + ["cp", "pp", "gp"].into_iter() + } + + pub(crate) fn long_names() -> impl Iterator { + ["cpython", "pypy", "graalpy"].into_iter() } pub fn pretty(self) -> &'static str { diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 7b48f19fc151f..dd943e0f56c04 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -1983,124 +1983,6 @@ mod tests { Ok(()) } - #[test] - fn find_python_pypy_prefers_executable_with_implementation_name() -> Result<()> { - let mut context = TestContext::new()?; - - // We should prefer `pypy` executables over `python` executables in the same directory - // even if they are both pypy - TestContext::create_mock_interpreter( - &context.tempdir.join("python"), - &PythonVersion::from_str("3.10.0").unwrap(), - ImplementationName::PyPy, - true, - )?; - TestContext::create_mock_interpreter( - &context.tempdir.join("pypy"), - &PythonVersion::from_str("3.10.1").unwrap(), - ImplementationName::PyPy, - true, - )?; - context.add_to_search_path(context.tempdir.to_path_buf()); - - let python = context.run(|| { - find_python_installation( - &PythonRequest::parse("pypy@3.10"), - EnvironmentPreference::Any, - PythonPreference::OnlySystem, - &context.cache, - ) - })??; - assert_eq!( - python.interpreter().python_full_version().to_string(), - "3.10.1", - ); - - // But `python` executables earlier in the search path will take precedence - context.reset_search_path(); - context.add_python_interpreters(&[ - (true, ImplementationName::PyPy, "python", "3.10.2"), - (true, ImplementationName::PyPy, "pypy", "3.10.3"), - ])?; - let python = context.run(|| { - find_python_installation( - &PythonRequest::parse("pypy@3.10"), - EnvironmentPreference::Any, - PythonPreference::OnlySystem, - &context.cache, - ) - })??; - assert_eq!( - python.interpreter().python_full_version().to_string(), - "3.10.2", - ); - - Ok(()) - } - - #[test] - fn find_python_pypy_prefers_executable_with_version() -> Result<()> { - let mut context = TestContext::new()?; - TestContext::create_mock_interpreter( - &context.tempdir.join("pypy3.10"), - &PythonVersion::from_str("3.10.0").unwrap(), - ImplementationName::PyPy, - true, - )?; - TestContext::create_mock_interpreter( - &context.tempdir.join("pypy"), - &PythonVersion::from_str("3.10.1").unwrap(), - ImplementationName::PyPy, - true, - )?; - context.add_to_search_path(context.tempdir.to_path_buf()); - - let python = context.run(|| { - find_python_installation( - &PythonRequest::parse("pypy@3.10"), - EnvironmentPreference::Any, - PythonPreference::OnlySystem, - &context.cache, - ) - })??; - assert_eq!( - python.interpreter().python_full_version().to_string(), - "3.10.0", - "We should prefer executables with the version number over those with implementation names" - ); - - let mut context = TestContext::new()?; - TestContext::create_mock_interpreter( - &context.tempdir.join("python3.10"), - &PythonVersion::from_str("3.10.0").unwrap(), - ImplementationName::PyPy, - true, - )?; - TestContext::create_mock_interpreter( - &context.tempdir.join("pypy"), - &PythonVersion::from_str("3.10.1").unwrap(), - ImplementationName::PyPy, - true, - )?; - context.add_to_search_path(context.tempdir.to_path_buf()); - - let python = context.run(|| { - find_python_installation( - &PythonRequest::parse("pypy@3.10"), - EnvironmentPreference::Any, - PythonPreference::OnlySystem, - &context.cache, - ) - })??; - assert_eq!( - python.interpreter().python_full_version().to_string(), - "3.10.1", - "We should prefer an implementation name executable over a generic name with a version" - ); - - Ok(()) - } - #[test] fn find_python_graalpy() -> Result<()> { let mut context = TestContext::new()?; @@ -2203,11 +2085,11 @@ mod tests { } #[test] - fn find_python_graalpy_prefers_executable_with_implementation_name() -> Result<()> { + fn find_python_prefers_generic_executable_over_implementation_name() -> Result<()> { let mut context = TestContext::new()?; - // We should prefer `graalpy` executables over `python` executables in the same directory - // even if they are both graalpy + // We prefer `python` executables over `graalpy` executables in the same directory + // if they are both GraalPy TestContext::create_mock_interpreter( &context.tempdir.join("python"), &PythonVersion::from_str("3.10.0").unwrap(), @@ -2232,10 +2114,10 @@ mod tests { })??; assert_eq!( python.interpreter().python_full_version().to_string(), - "3.10.1", + "3.10.0", ); - // But `python` executables earlier in the search path will take precedence + // And `python` executables earlier in the search path will take precedence context.reset_search_path(); context.add_python_interpreters(&[ (true, ImplementationName::GraalPy, "python", "3.10.2"), @@ -2254,6 +2136,88 @@ mod tests { "3.10.2", ); + // But `graalpy` executables earlier in the search path will take precedence + context.reset_search_path(); + context.add_python_interpreters(&[ + (true, ImplementationName::GraalPy, "graalpy", "3.10.3"), + (true, ImplementationName::GraalPy, "python", "3.10.2"), + ])?; + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("graalpy@3.10"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.3", + ); + + Ok(()) + } + + #[test] + fn find_python_prefers_generic_executable_over_one_with_version() -> Result<()> { + let mut context = TestContext::new()?; + TestContext::create_mock_interpreter( + &context.tempdir.join("pypy3.10"), + &PythonVersion::from_str("3.10.0").unwrap(), + ImplementationName::PyPy, + true, + )?; + TestContext::create_mock_interpreter( + &context.tempdir.join("pypy"), + &PythonVersion::from_str("3.10.1").unwrap(), + ImplementationName::PyPy, + true, + )?; + context.add_to_search_path(context.tempdir.to_path_buf()); + + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("pypy@3.10"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.1", + "We should prefer the generic executable over one with the version number" + ); + + let mut context = TestContext::new()?; + TestContext::create_mock_interpreter( + &context.tempdir.join("python3.10"), + &PythonVersion::from_str("3.10.0").unwrap(), + ImplementationName::PyPy, + true, + )?; + TestContext::create_mock_interpreter( + &context.tempdir.join("pypy"), + &PythonVersion::from_str("3.10.1").unwrap(), + ImplementationName::PyPy, + true, + )?; + context.add_to_search_path(context.tempdir.to_path_buf()); + + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("pypy@3.10"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.0", + "We should prefer the generic name with a version over one the implementation name" + ); + Ok(()) } }