diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 67f8f37ff..ced9f0cf8 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -52,6 +52,8 @@ pub enum PythonRequest { /// Any Python installation Any, /// A Python version without an implementation name e.g. `3.10` or `>=3.12,<3.13` + Latest, + // Newest resolvable, stable Python version. Version(VersionRequest), /// A path to a directory containing a Python installation, e.g. `.venv` Directory(PathBuf), @@ -181,6 +183,7 @@ pub enum VersionRequest { Default, /// Allow any Python version. Any, + Latest, Major(u8, PythonVariant), MajorMinor(u8, u8, PythonVariant), MajorMinorPatch(u8, u8, u8, PythonVariant), @@ -624,6 +627,7 @@ fn find_all_minor( match version_request { &VersionRequest::Any | VersionRequest::Default + | VersionRequest::Latest | VersionRequest::Major(_, _) | VersionRequest::Range(_, _) => { let regex = if let Some(implementation) = implementation { @@ -1135,6 +1139,19 @@ pub fn find_python_installations<'a>( .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple))) }) } + PythonRequest::Latest => Box::new({ + debug!("Searching for latest Python interpreter in {sources}"); + python_interpreters(&VersionRequest::Any, None, environments, preference, cache) + .filter_ok(|(_source, interpreter)| interpreter.python_version().pre().is_none()) + .max_by(|a, b| match (a.as_ref(), b.as_ref()) { + (Ok((_, interpreter_a)), Ok((_, interpreter_b))) => { + interpreter_a.key().cmp(&interpreter_b.key()) + } + _ => std::cmp::Ordering::Equal, + }) + .into_iter() + .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple))) + }), } } @@ -1673,9 +1690,7 @@ impl PythonRequest { // parsing errors are raised to the caller. if let Some(after_at) = rest.strip_prefix('@') { if after_at == "latest" { - // Handle `@latest` as a special case. It's still an error for now, but we plan to - // support it. TODO(zanieb): Add `PythonRequest::Latest` - return Err(Error::LatestVersionRequest); + return Ok(Some(VersionRequest::Latest)); } return after_at.parse().map(Some); } @@ -1710,7 +1725,7 @@ impl PythonRequest { } match self { - PythonRequest::Default | PythonRequest::Any => true, + PythonRequest::Default | PythonRequest::Any | PythonRequest::Latest => true, PythonRequest::Version(version_request) => { version_request.matches_interpreter(interpreter) } @@ -1799,7 +1814,7 @@ impl PythonRequest { /// Whether this request opts-in to a pre-release Python version. pub(crate) fn allows_prereleases(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Version(version) => version.allows_prereleases(), Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true, @@ -1812,7 +1827,7 @@ impl PythonRequest { /// Whether this request opts-in to an alternative Python implementation, e.g., PyPy. pub(crate) fn allows_alternative_implementations(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Version(_) => false, Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true, @@ -1833,6 +1848,7 @@ impl PythonRequest { match self { Self::Any => "any".to_string(), Self::Default => "default".to_string(), + Self::Latest => "latest".to_string(), Self::Version(version) => version.to_string(), Self::Directory(path) => path.display().to_string(), Self::File(path) => path.display().to_string(), @@ -2238,7 +2254,7 @@ impl VersionRequest { /// Return the major version segment of the request, if any. pub(crate) fn major(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(major, _) => Some(*major), Self::MajorMinor(major, _, _) => Some(*major), Self::MajorMinorPatch(major, _, _, _) => Some(*major), @@ -2249,7 +2265,7 @@ impl VersionRequest { /// Return the minor version segment of the request, if any. pub(crate) fn minor(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(_, _) => None, Self::MajorMinor(_, minor, _) => Some(*minor), Self::MajorMinorPatch(_, minor, _, _) => Some(*minor), @@ -2260,7 +2276,7 @@ impl VersionRequest { /// Return the patch version segment of the request, if any. pub(crate) fn patch(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(_, _) => None, Self::MajorMinor(_, _, _) => None, Self::MajorMinorPatch(_, _, patch, _) => Some(*patch), @@ -2273,7 +2289,7 @@ impl VersionRequest { /// If not, an `Err` is returned with an explanatory message. pub(crate) fn check_supported(&self) -> Result<(), String> { match self { - Self::Any | Self::Default => (), + Self::Any | Self::Latest | Self::Default => (), Self::Major(major, _) => { if *major < 3 { return Err(format!( @@ -2348,6 +2364,7 @@ impl VersionRequest { pub(crate) fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { match self { Self::Any => true, + Self::Latest => interpreter.python_version().pre().is_none(), // Do not use free-threaded interpreters by default Self::Default => PythonVariant::Default.matches_interpreter(interpreter), Self::Major(major, variant) => { @@ -2391,6 +2408,7 @@ impl VersionRequest { pub(crate) fn matches_version(&self, version: &PythonVersion) -> bool { match self { Self::Any | Self::Default => true, + Self::Latest => version.pre().is_none(), Self::Major(major, _) => version.major() == *major, Self::MajorMinor(major, minor, _) => { (version.major(), version.minor()) == (*major, *minor) @@ -2413,7 +2431,7 @@ impl VersionRequest { /// avoid querying interpreters if it's clear it cannot fulfill the request. fn matches_major_minor(&self, major: u8, minor: u8) -> bool { match self { - Self::Any | Self::Default => true, + Self::Any | Self::Latest | Self::Default => true, Self::Major(self_major, _) => *self_major == major, Self::MajorMinor(self_major, self_minor, _) => { (*self_major, *self_minor) == (major, minor) @@ -2460,6 +2478,7 @@ impl VersionRequest { ) -> bool { match self { Self::Any | Self::Default => true, + Self::Latest => prerelease.is_none(), Self::Major(self_major, _) => *self_major == major, Self::MajorMinor(self_major, self_minor, _) => { (*self_major, *self_minor) == (major, minor) @@ -2482,7 +2501,7 @@ impl VersionRequest { /// Whether a patch version segment is present in the request. fn has_patch(&self) -> bool { match self { - Self::Any | Self::Default => false, + Self::Any | Self::Default | Self::Latest => false, Self::Major(..) => false, Self::MajorMinor(..) => false, Self::MajorMinorPatch(..) => true, @@ -2499,6 +2518,7 @@ impl VersionRequest { match self { Self::Default => Self::Default, Self::Any => Self::Any, + Self::Latest => Self::Latest, Self::Major(major, variant) => Self::Major(major, variant), Self::MajorMinor(major, minor, variant) => Self::MajorMinor(major, minor, variant), Self::MajorMinorPatch(major, minor, _, variant) => { @@ -2514,7 +2534,7 @@ impl VersionRequest { /// Whether this request should allow selection of pre-release versions. pub(crate) fn allows_prereleases(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Major(..) => false, Self::MajorMinor(..) => false, @@ -2527,7 +2547,7 @@ impl VersionRequest { /// Whether this request is for a free-threaded Python variant. pub(crate) fn is_freethreaded(&self) -> bool { match self { - Self::Any | Self::Default => false, + Self::Any | Self::Default | Self::Latest => false, Self::Major(_, variant) | Self::MajorMinor(_, _, variant) | Self::MajorMinorPatch(_, _, _, variant) @@ -2544,7 +2564,7 @@ impl VersionRequest { // TODO(zanieb): Replace this entire function with a utility that casts this to a version // without using `VersionRequest::to_string`. match self { - Self::Any | Self::Default => self, + Self::Any | Self::Default | Self::Latest => self, Self::Major(major, _) => Self::Major(major, PythonVariant::Default), Self::MajorMinor(major, minor, _) => { Self::MajorMinor(major, minor, PythonVariant::Default) @@ -2563,7 +2583,7 @@ impl VersionRequest { pub(crate) fn variant(&self) -> Option { match self { Self::Any => None, - Self::Default => Some(PythonVariant::Default), + Self::Default | Self::Latest => Some(PythonVariant::Default), Self::Major(_, variant) | Self::MajorMinor(_, _, variant) | Self::MajorMinorPatch(_, _, _, variant) @@ -2578,7 +2598,7 @@ impl FromStr for VersionRequest { fn from_str(s: &str) -> Result { // Stripping the 't' suffix produces awkward error messages if the user tries a version - // like "latest". HACK: If the version is all letters, don't even try to parse it further. + // like "oldest". HACK: If the version is all letters, don't even try to parse it further. if s.chars().all(char::is_alphabetic) { return Err(Error::InvalidVersionRequest(s.to_string())); } @@ -2718,6 +2738,7 @@ impl fmt::Display for VersionRequest { match self { Self::Any => f.write_str("any"), Self::Default => f.write_str("default"), + Self::Latest => f.write_str("latest"), Self::Major(major, PythonVariant::Default) => write!(f, "{major}"), Self::Major(major, PythonVariant::Freethreaded) => write!(f, "{major}t"), Self::MajorMinor(major, minor, PythonVariant::Default) => write!(f, "{major}.{minor}"), @@ -2746,6 +2767,7 @@ impl fmt::Display for PythonRequest { match self { Self::Default => write!(f, "a default Python"), Self::Any => write!(f, "any Python"), + Self::Latest => write!(f, "latest stable Python"), Self::Version(version) => write!(f, "Python {version}"), Self::Directory(path) => write!(f, "directory `{}`", path.user_display()), Self::File(path) => write!(f, "path `{}`", path.user_display()), diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index ad516d096..5da637473 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -316,7 +316,7 @@ impl PythonDownloadRequest { prereleases: Some(true), // Explicitly allow pre-releases for PythonRequest::Any ..Self::default() }), - PythonRequest::Default => Some(Self::default()), + PythonRequest::Default | PythonRequest::Latest => Some(Self::default()), // We can't download a managed installation for these request kinds PythonRequest::Directory(_) | PythonRequest::ExecutableName(_) diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index d408bc199..ec1568d9b 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -1061,6 +1061,106 @@ mod tests { Ok(()) } + #[test] + fn find_python_latest() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.8.10", "3.11.5", "3.9.18", "3.10.12"])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + + assert!( + matches!( + python, + PythonInstallation { + source: PythonSource::SearchPath, + interpreter: _ + } + ), + "We should find a python; got {python:?}" + ); + assert_eq!( + &python.interpreter().python_full_version().to_string(), + "3.11.5", + "We should find the latest version (3.11.5)" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_with_prereleases() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.10.1", "3.11.2", "3.12.0rc1", "3.13.0a1"])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + + assert_eq!( + &python.interpreter().python_full_version().to_string(), + "3.11.2", + "Latest should find the highest stable version" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_no_pythons() -> Result<()> { + let context = TestContext::new()?; + // Don't add any Python versions + + let result = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })?; + + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not find any python when none are available; got {result:?}" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_only_prereleases() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.12.0rc1", "3.13.0a1", "3.11.0b2"])?; + + let result = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })?; + + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not find any python when only prereleases are available; got {result:?}" + ); + + Ok(()) + } + #[test] fn find_python_from_conda_prefix() -> Result<()> { let context = TestContext::new()?; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index ad1dacac6..6c7be3a1b 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -473,6 +473,7 @@ impl ManagedPythonInstallation { } PythonRequest::Version(version) => version.matches_version(&self.version()), PythonRequest::Key(request) => request.satisfied_by_key(self.key()), + PythonRequest::Latest => true, } } diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index d4dcb216c..b5db75d8e 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2039,14 +2039,15 @@ fn tool_run_python_at_version() { // Request `@latest` (not yet supported) uv_snapshot!(context.filters(), context.tool_run() .arg("python@latest") - .arg("--version"), @r###" - success: false - exit_code: 2 + .arg("--version"), @r" + success: true + exit_code: 0 ----- stdout ----- + Python 3.12.[X] ----- stderr ----- - error: Requesting the 'latest' Python version is not yet supported - "###); + Resolved in [TIME] + "); } #[test]